happycode/blueprint

A Tool for Describing, Reading and Formatting Data.

v1.1.1 2023-11-25 22:04 UTC

This package is auto-updated.

Last update: 2024-09-25 23:59:24 UTC


README

A PHP Library for Defining, validating, reading and transforming 3rd Party data.

Problem

[disclaimer] - This is JSON only for now. Everything else is coming soon!

Ok, so it's a common thing. Your app talks to an API somewhere.

You get some JSON back from a 3rd party request, it seems to have everything you need in it (somewhere) but now you have to work out what to do with it.

You have 2 options:

  • json_decode and start passing the result around. - and with that you've spread that madness to every corner of your app.
  • start building custom objects and hydrating data - then about a month later someone asks why there's a PR with 200 new files in it, and you nearly died of boredom.

oh no, no, no, NO!!

Blueprint

Tldr;

  • Defines the shape of your data
  • Validates input
  • Applies Transformations
  • Produces Access Objects (Models) the way you want them
  • Renders back to your custom / desired json

Blueprint allows us to DEFINE the data we're expecting, like a table in a DB. We tell it the SCHEMA (the shape and types) of the JSON, We tell it how to validate it, our rules not theirs, we filter out the stuff we won't want, and how to change the bits we won't like.

Then we give it the data.

Validation is implicit, with detailed errors on what went wrong.

It gives us back smart Objects, Safe data, Structured and Formatted the way our apps need it.

Lets have a look...

Install

composer require happycode/blueprint

Usage

let's say we get this from a 3rd party API - it's in a variable called $json

  {
    "id": 1,
    "firstName": "Roger",
    "lastName": "Rabbit",
  }

Let's try to define it.

    use HappyCode\Blueprint\Model;

    $userSchema = Model::Define('User', [
        "id" => 'integer',
        "firstName" => 'string',
        "lastName" => 'string',
    ])

Now we can add some data

    $user = $userSchema->adapt($json);

by the way - that was where the validation happened! and so now...

    echo $user->getFirstName();

will give you what you might imagine.

nice! - lets go deeper.

More Help

Types

when we specify a field...

    $userSchema = Model::Define('User', [
        "fieldName" => 'string',
    ])

its actually shorthand for

    $userSchema = Model::Define('User', [
        "fieldName" => Type::String(),
    ])

the Type Class gives us access to make more complex configuration.

The available Primitive types are Type::String() Type::Int() Type::Float() Type::Boolean()

Meta-Data

All Types have some associated meta-data, specifically the following booleans

  • isNullable - when true the field is allowed a null value
  • isRequired - when true a validation error will occur if the field does not exist
  • isHidden - when true a required field will not be rendered in transformed data (more on this later) they can be set as follows
    $userSchema = Model::Define('User', [
        "fieldName" => Type::String(isNullable: true, isRequired: false, isHidden: false),
    ])

The default values are ( x denotes a property that cannot be set )

Enum

Enumerated values - will only accept values that match the specified set example

    $userSchema = Model::Define('User', [
        "status" => Type::Enum(values: ["PENDING", "ACTIVE", "DISABLED"]),
    ])

DateTime

Dates and times are a common use case for inline transformations and so have we're able to create PHP date format specifications for reading and rendering. example

    $userSchema = Model::Define('User', [
        "status" => Type::DateTime(inputFormat: 'd-m-Y', outputFormat: 'm/d/Y'),
    ])

the defaults are

  • inputFormat - 'd/m/y H:i:s'
  • output format - whatever the input format is

ArrayOf

Suppose we're looking at this kind of json

  {
    "lotteryNumbers": [1,2,3,4,5,6]
  }

Because it can be described as 'an array of primitive types' we can model it like this...

    $userSchema = Model::Define('User', [
        "lotteryNumbers" => Type::ArrayOf(Type::Int()),
    ])
    // or with shorthand
    $userSchema = Model::Define('User', [
        "lotteryNumbers" => 'int[]'
    ])

Non-Primitives (Custom Objects)

Yep nested structures can be typed too... Here's an example

  {
    "geoLocation": {
      "lat": "84.9999572",
      "long": "-135.000413,21"
    }
  }

We can create a custom (sub) Model on the fly

    $mapPinSchema = Model::Define('MapPin', [
        "geoLocation" => Type::Model(
            Model::Define('GeoLocation', [
                  "lat" => Type::String(),
                  "long" => Type::String()
            ])
        ),
    ])

although you could always make it more reusable should the schema need to repeat the object

  {
    "pickupLocation": {
      "lat": "84.9999572",
      "long": "-135.000413,21"
    },
    "dropLocation": {
      "lat": "49.4296032",
      "long": "0.737196,7"
    }
  }

like...

    $geoLocationSchema = Model::Define('GeoLocation', [
          "lat" => Type::String(),
          "long" => Type::String()
    ]);
    $deliverySchema = Model::Define('Delivery', [
        "pickupLocation" => Type::Model($geoLocationSchema),
        "dropLocation" => Type::Model($geoLocationSchema),
    ])

Collections

ok - lets combine Custom Objects and Arrays - in Blueprint a set of Custom Objects (Model Schemas) is called a Collection

Using the $geoLocationSchema from the custom object example (above)

    $geoLocationSchema = Model::Define('GeoLocation', [
          "lat" => Type::String(),
          "long" => Type::String()
    ]);
    
    $deliverySchema = Model::Define('Delivery', [
        "journeyTracking" => Type::Collection($geoLocationSchema),
    ])

allows for json like

  {
    "journeyTracking": [
       { "lat": "84.9999572", "long": "-135.000413,21" },
       { "lat": "49.4296032", "long": "0.737196,7" },
    ]
  }

RootCollections

Sometimes json will have a root level array instead of an Object - this is valid, annoying and quite common.

[
 {
  "name": "Roger"
 },
 {
  "name": "Jessica"
 }
]

easily done...

    $rabbitSchema = Model::Define('Rabbit', [
          "name" => Type::String()
    ]);
    
    $loadsOfRabbitsSchema = Model::CollectionOf($rabbitSchema)

Virtual Fields (Transformations)

Suppose you want a field that isn't there, and you can construct it from the data you already have. for example:

{
 "first": "Roger",
 "last": "Rabbit"
}

Don't you just wish you had a fullName field in there? well...

    $rabbitSchema = Model::Define('Rabbit', [
          "first" => Type::String(isHidden: true),
          "last" => Type::String(isHidden: true),
          "fullName" => Type::Virtual(function($rabbit) {
                return $rabbit['first'] . ' ' . $rabbit['last'];
          }),
    ]);

The function passed to the Type::Virtual() method will have an assoc array with all the decoded properties (including hidden) and values from the input json.

The only requirement is that any fields being used are present in the schema (obviously).

By the way, this is why we might want to hide fields using isHidden - when we get to rendering the hydrated models, we may not want them visible.

Shorthand

As long as you aren't messing with the default values, Primitive types all have shorthand notation, in case you find that more readable.

    $schema = Model::Define('Thing', [
          "name" => Type::String(),
          "weight" => Type::Int(),
          "lotteryNumbers" => Type::ArrayOf(Type::Int())
    ]);
// is equivalent to
    $schema = Model::Thing([
          "name" => 'string',           // or 'text'
          "rich" => 'bool',             // or 'boolean'
          "weight" => 'float',          // 'double' or 'decimal' also work
          "lotteryNumbers" => 'int[]'   // works for all primitive types
    ]);

Adapting (Hydrating)

Hoo wee Tiger!!! - You have a schema! Nice work!

Lets

    $model = $schema->adapt($jsonString);
  1. The first thing that happens here is Validation - Blueprint knows what it needs from the json so it makes sure it's there.
  2. The second thing is Filtering - If there's data in the Json, but your schema doesn't define it anywhere, it's disregarded.
  3. and the last thing that happens is it returns a model representing your data the way you need it,

watch this...

    $user = (Model::Define('User', [ 'name' => 'string' ])->adapt('{ "name": "Roger" }'));

    echo $user->getName(); // Roger

Rendering

now this

    $user = (Model::Define('User', ['name' => 'string']))->adapt('{ "name": "Roger" }');

    echo $user->json(); // { "name": "Roger" }

Well that's pointless right?? - or is it?

    $spy = (Model::Define('Spy', [
        "first" => Type::String(isHidden: true),
        "last" => Type::String(isHidden: true),
        "fullName" => Type::Virtual(function($who) {
                return sprintf("%s, %s %s!", $who['last'], $who['first'], $who['last']);
        }),
    ])->adapt('{ "first": "James", "last": "Bond" }'));

    echo $spy->json(); // A string = {"fullName":"Bond, James Bond!"}

notice how the rendered json does not include the hidden fields first and last

Exceptions

During run-time blueprint will throw various Exceptions, In all cases they will extend the HappyCode\Blueprint\Error\BlueprintError Exception

Wrapping the adapt method like the following is guaranteed to encapsulate all cases:

try {
    $user = $userSchema->adapt('{ "name": "Roger" }');
} catch (\HappyCode\Blueprint\Error\BlueprintError $e) {
    // handle this
}

for more fine grain control

try {
    $user = $userSchema->adapt('{ "name": "Roger" }');
} catch (\HappyCode\Blueprint\Error\BuildError $e) {
    // Type Help code generator errors
} catch (\HappyCode\Blueprint\Error\ValidationError $e) {
    // Validation based issues while parsing the data
    // $e->getMessage() - will be helpful
}

IDE's and Type Help

When blueprint has parsed the input json ($schema->adapt($json))) - you may notice a problem with the model.

Your IDE (PHPStorm / Visual Studio / Whatever) - is unable to provide any intellisense, this IS a problem! Due to the fact that the data models are created at runtime, your editor is unable to perform any static analysis on them. This means you'll probably get some squiggly lines when you write perfectly legit code like $model->getId().

To fix this we have a solution - It's not necessary but we all like a squiggle free file right!

On your Schema definition add ->setHelperNamespace(<NS>) and provide a namespace for some TypeHelpers. on the first run the adapter will generate a file for each model in your project.

The namespace you provide will map to a file path located in your project directory

If your composer.json file has any psr-4 mappings - it will obey these - otherwise the mapping will be from the project root. (Where the composer file lives)

Example:

- /<project_root>
  - /src
    - index.php
  - /lib
   - helper.php
 - composer.json

let's say you composer.json autoload settings look like.

{
 "autoload": {
  "psr-4": {
   "App\\": "src/"
  }
 }
}

in your code

    $userSchema = Model::Define('User', [
        "name" => 'string',
    ])->setHelperNamespace("App\TypeHintThing");

    $user = $userSchema->adapt('{ "name": "Roger" }');

After the first time you run this - you'll notice one or more new files in your project

- /<project_root>
    - /src
        - index.php
        - /TypeHintThing
          - ...?
          - UserModel.php
    - /lib
    - helper.php
- composer.json

you can use this in your code to tell your IDE about the model Types.

    /** @var \App\TypeHintThing\UserModel $user */
    $user = $userSchema->adapt('{ "name": "Roger" }');

Important Note:

Once these files have been generated they will not be recreated - even if you update the schema, yuo should delete the helper files and allow them to be regenerated.

Copyright and License

The happycode/blueprint library is copyright © Paul Rooney and licensed for use under the terms of the MIT License (MIT). Please see LICENSE for more information.