4
\$\begingroup\$

I have a basic JSON API written in PHP. I want to validate incoming JSON data.

I know there are PHP JSON validators out there, here is a quick one I just rolled up.

<?php

$json_payload = json_decode('{
  "data": {
    "a_string": "short",
    "a_number": 7
  }
}');

validateJSONPayload(
  '{
    "data": {
      "a_string": "type=string,min_length=7,max_length=10",
      "a_number": "type=integer,min=8,max=50"
    }
  }',
  $json_payload
);

function validateJSONPayload($json_schema, $json_payload){

  $json_schema = json_decode($json_schema);

  function reCursiveCheck($schema_value, $schema_type, $payload_value, $payload_type){
    if($schema_type === "object" || $schema_type === "array"){
      # KEEP LOOPING
      foreach ($schema_value as $key => $key_val) {
        if(!property_exists($payload_value, $key)) echo "validation failed because the data does not match the schema<br />";
        reCursiveCheck($key_val, gettype($key_val), $payload_value->{$key}, gettype($payload_value->{$key}));
      }
    } else {
      # VALUE IS A STRING, NUMBER, BOOL, NULL
      $validation_param_strings = explode(",", $schema_value);

      # STORE THE PARAMS TO CALL THE VALIDATION FUNCTION
      $validation_params = array();

      foreach($validation_param_strings as $validation_param) {
        $params = explode("=", $validation_param);
        $validation_params[$params[0]] = $params[1];
      }
      // print_r($validation_params);
      validateProperty($payload_value, $payload_type, $validation_params);
    }
  }

  function validateProperty($payload_value, $payload_type, $validation_rule){
    if($payload_type !== $validation_rule["type"]) {
      echo "types do not match<br />";
    }
    # STRING VALIDATION
    if($validation_rule["type"] === "string"){
      if(strlen($payload_value) > $validation_rule["max_length"]) echo "string too long<br />";
      if(strlen($payload_value) < $validation_rule["min_length"]) echo "string too short<br />";
    }
    # NUMBER VALIDATION
    if($validation_rule["type"] === "integer"){
      if($payload_value > (int)$validation_rule["max"]) echo "number too large<br />";
      if($payload_value < (int)$validation_rule["min"]) echo "number too small<br />";
    }
    # NULL VALIDATION
    if($validation_rule["type"] === "null"){
      echo "found a null<br />";
    }
  }

  foreach($json_schema as $key => $value) {
    if(!property_exists($json_payload, $key)) echo "validation failed because the data does not match the schema<br />";
    reCursiveCheck($value, gettype($value), $json_payload->{$key}, gettype($json_payload->{$key}));
  }
}

?>
\$\endgroup\$
1

2 Answers 2

1
\$\begingroup\$

Your solution is very "functional programming" in approach, with nested function declarations and such. This in of itself is not "wrong", it is just a little bit unusual in the PHP world.

I would consider moving this into an object-oriented paradigm which is more common in PHP. I would think in this case perhaps only a class with static functions might be needed, so you could make calls against the validator like:

$result = JsonSchemaValidator::validate($jsonSchema, $jsonPayload);

If you find you have one schema that is used multiple times for validation, then perhaps it makes sense to have concrete validator objects like this:

$validator = new JsonSchemaValidator($jsonSchema);
$result1 = $validator->validate($jsonPayload1);
$result2 = $validator->validate($jsonPatload2);

This would save you the overhead of having to re-validate the schema with each validation request.

I am not sure which is more appropriate to your application, but wanted to present both options for putting this logic into a class.


Some odd naming conventions are in play:

  • Why name a variable $json_payload when it does not contain JSON at all, but rather an object built from JSON? Perhaps $payload is better here.
  • Why are you mixing camelCase and snake_case in your code? I know PHP is not very consistent here, but that doesn't mean you shouldn't be - at least within the confines of a single library, class, set of functions, etc.
  • Why uppercase first c in reCursiveCheck()?

I think your main validateJSONPayload() function name and signature is odd. As noted above, the "payload" is not JSON at all. Why pass one parameter as a JSON string and the other as an actual data structure? This seems to be an incongruous approach. I would probably just pass JSON for both parameters so caller doesn't need to decode one of the parameters beforehand.


Why define validation rules in a string like "type=string,min_length=7,max_length=10"? You are introducing complexity in your code to have to perform string manipulation to get at these values when they should be defined as appropriate properties/values on the schema. Why not mirror the already well-established JSON Schema format?


Do not echo error/validation messages to standard output. A validator like this should do one thing - validate, not deliver end user messaging. Leave that up to functionality further up the call stack that is more well-positioned to understand the context for how end user messaging should be delivered. This code might throw exceptions and/or log errors when it gets put into a bad state (i.e. invalid JSON is passed) but outside of that, it's contract to the caller should be to just deliver validation results and leave it up to the calling code to determine what action to take from there.

If you are delivering error messages, you should also remove any HTML markup from it. What if this code is being used in a RESTful service where response is going to be JSON format and not HMTL? Now you have to go strip out the HTML mark-up that has been added.

The takeaway here is to separate your display concerns from your business logic.


This code is incredibly fragile and strictly "happy path" in nature, as you are doing no validation of the passed dependencies and you just assume that all of the steps in the code will work properly. Most critically, you just assume the passed schema JSON will decode properly.

What if it doesn't?

Right now, your code will just silently fail, perhaps giving the caller the perception that validation passed, since you currently do not return any validation result to the caller (you just echo out failures).

To extend on my earlier class-based example, perhaps you need to do something like:

try {
    $result = JsonSchemaValidator::validate($jsonSchema, $jsonPayload);
} catch (InvalidArgumentException $e) {
    // perhaps we had schema or payload which could not be decoded
    // and we have the code throw InvalidArgumentExpection
    // perhaps log the error
    error_log($e->getMessage());
    // then perhaps do something to recover
    $result = false;
}

or

try {
    $validator = new JsonSchemaValidator($jsonSchema);
    $result1 = $validator->validate($jsonPayload1);
    $result2 = $validator->validate($jsonPatload2);
} catch (InvalidArgumentException) {
    // do something
}

I echo the sentiment mentioned in comments to question above that I am really not quite sure why you would want to roll your own solution to this problem.

\$\endgroup\$
2
\$\begingroup\$

Here are additional review points on the proposed code focused on functionality, concision and performance:

  1. You could initiate your recursive call directly without a foreach loop. This means less code.
  2. The gettype calls for the json and the schema can be in the recursive function, thus removing the need for the types arguments.
  3. If a key in your json is not found in the schema, it is considered as valid. The opposite may be preferred.
  4. If a key in your schema is not found in the json, it is considered as invalid. The opposite may be preferred.
  5. For points 3. and 4., my preference goes for a general loop through the json rather than through the schema.
  6. A json may contain arrays.
  7. Your validateProperty function would have less repetitions and be a bit faster with a switch to replace all the if conditions on types. You could also have a default handling for unsupported types.
  8. You may want to handle situations where properties are missing.
  9. Json values can be null.

While I mostly agree with the comment and accepted answer, I must write that I reached this question because I was looking for a simpler solution to validate a json against a schema than those based on composer. Simpler, in my case, was desired because I must be able to adapt the solution to some specific needs within my project. Composer based solutions made me lose the patience to dive into huge amounts of complex code and I'd rather reinvent the bike if the cost is not too high and if what is proposed is a bunch of tanks (maybe very good ones, but not what I need). If there is a non-composer, simple and efficient solution available, I would be happy to use it.

So, here is a rewrite of a simple php json schema validator without composer in case someone also needs it:

<?php

function validateJsonSchema($json, $schema, $checkRequired=true)
{
    $decodeIfNeeded=function ($maybeJson) {
        if (is_string($maybeJson)) {
            try {
                return json_decode($maybeJson, false, 512, JSON_THROW_ON_ERROR);
            } catch (Exception $e) {
                return false;
            }
        } else {
            return $maybeJson;
        }
    };
    $json = $decodeIfNeeded($json);
    $schema = $decodeIfNeeded($schema);
    if (!$json||!$schema) {
        return "failed to decode json";
    }
    if ($checkRequired) {
        $missingRequiredKeys=[];
        foreach ($schema as $schemaKey => $schemaValue) {
            if (property_exists($schemaValue, "required")
                &&$schemaValue->required) {
                $missingRequiredKeys[]=$schemaKey;
            }
        }
    }
    foreach ($json as $jsonKey => $jsonValue) {
        $type=gettype($jsonValue);
        if ($type=="object") {//Needed to handle array situations
            foreach ($schema as $schemaKey => $schemaValue) {
                if (!property_exists($schemaValue, "type")
                    ||!is_string($schemaValue->type)) {
                    $res=validateJsonSchema($jsonValue, $schemaValue, $checkRequired);
                    if ($res!="") {
                        return $res;
                    }
                }
            }
        } else {
            $schemaProperties=null;
            if (!is_array($schema)) {
                if ($checkRequired) {
                    $missingKey = array_search($jsonKey, $missingRequiredKeys, true);
                    if ($missingKey!== false) {
                        unset($missingRequiredKeys[$missingKey]);
                    }
                }
                if (!is_array($schema)&&!property_exists($schema, $jsonKey)) {
                    return "key not in schema";
                }
                $schemaProperties=$schema->$jsonKey;
            } else {
                foreach ($schema as $schemaKey => $schemaValue) {
                    if (property_exists($schemaValue, "type")
                        &&is_string($schemaValue->type)) {//This is a single value within an array
                        if ($type==$schemaValue->type) {
                            $schemaProperties=$schemaValue;
                            break;
                        }
                    }
                }
            }
            if (!(is_null($jsonValue)&&property_exists($schemaProperties, "nullable")&&$schemaProperties->nullable)) {
                if (is_null($schemaProperties)) {
                    return "type not allowed: ".$type;
                }
                if ($schemaProperties->type!=$type) {
                    if (!($type=="integer"&&$schemaProperties->type=="double")) {//integer instead of double is acceptable
                        return "type mismatch, expected: ".$schemaProperties->type." instead of ".$type." for ".$jsonKey;
                    }
                }
                $getSchemaProperty=function ($schemaProperties, $name) {
                    if (property_exists($schemaProperties, $name)) {
                        return $schemaProperties->$name;
                    }
                    if ($name=="min") {
                        if ($schemaProperties->type=="double") {
                            return PHP_FLOAT_MIN;
                        }
                        return PHP_INT_MIN;
                    }
                    if ($name=="max") {
                        if ($schemaProperties->type=="double") {
                            return PHP_FLOAT_MAX;
                        }
                        return PHP_INT_MAX;
                    }
                    return null;
                };
                switch ($schemaProperties->type) {
                    case "string":
                        $count=strlen($jsonValue);
                    break;
                    case "integer":
                    case "double":
                        $count=$jsonValue;
                    break;
                    case "boolean":
                        $count=0;
                    break;
                    case "array":
                        $res=validateJsonSchema($jsonValue, $getSchemaProperty($schemaProperties, "data"), $checkRequired);
                        if ($res!="") {
                            return $res;
                        }
                        $count=count($jsonValue);
                    break;
                    default:
                        return "unknown type ".$schemaProperties->type;
                    break;
                }
                $min=$getSchemaProperty($schemaProperties, "min");
                $max=$getSchemaProperty($schemaProperties, "max");
                if ($count<$min) {
                    return "too short";
                }
                if ($count>$max) {
                    return "too long";
                }
            }
        }
    }
    if ($checkRequired&&count($missingRequiredKeys)>0) {
        return "missing required key(s)";
    }
    return "";
}

$json='
{
    "a_string":"not short",
    "a_number":null,
    "a_double":3.14159265359,
    "a_boolean":true,
    "an_array":[
        "no name",
        3.14,
        true,
        {
            "a_string2":"not short",
            "a_number2":4
        }
    ]
}
';

$schema='
{
    "a_string":{
        "type":"string",
        "min":3,
        "max":10,
        "required":true
    },
    "a_number":{
        "type":"integer",
        "min":3,
        "max":10,
        "required":true,
        "nullable":true
    },
    "a_double":{
        "type":"double",
        "min":3,
        "max":10,
        "required":true,
        "nullable":false
    },
    "a_boolean":{
        "type":"boolean",
        "required":true
    },
    "an_array":{
        "type":"array",
        "min":2,
        "max":10,
        "required":false,
        "data":
            [
                {
                    "type":"string",
                    "min":3,
                    "max":10
                },
                {
                    "type":"double",
                    "min":3,
                    "max":10
                },
                {
                    "type":"boolean"
                },
                {
                    "a_string2":{
                        "type":"string",
                        "min":3,
                        "max":10,
                        "required":true
                    },
                    "a_number2":{
                        "type":"integer",
                        "min":3,
                        "max":10,
                        "required":true
                    }
                }
            ]
    }
}
';

echo(validateJsonSchema($json, $schema)."\n");
?>
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.