Jane

Simplifiez-vous la consommation d'API... et bien plus !

Baptiste Leduc

@Korbeil_ JoliCode

  • 🐘 DĂ©veloppeur PHP
  • AFUP  AFUP
  • đŸ„â€ïž Wakeboard
  • github.com/Korbeil
  • twitter.com/Korbeil_

Un peu de contexte ...

logo Jane

đŸŒ± Un ensemble de librairies pour gĂ©nĂ©rer des modĂšles et clients API basĂ©s sur les spĂ©cifications JSON Schema et OpenAPI

Elasticsearch

Via Elastically

janephp/demo-with-elasticsearch

JSON Schema

2019-09
  • Description
  • Validation

JSON Schema


{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "brewer": { "type": "string" },
    "style": { "type": "string" },
    "volume": { "type": "integer" },
    "alcohol": { "type": "integer" },
    "country": { "type": "string" },
    "color": { "type": "string" }
  }
}
                    

Validation


{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "brewer": { "type": "string" },
    "style": { "type": "string" },
    "volume": {
      "type": "integer",
      "minimum": 0
    },
    "alcohol": { "type": "integer" },
    "country": { "type": "string" },
    "color": { "type": "string" }
  }
}
                    

Tableau d'objets


{
  "type": "object",
  "properties": {
    "names": { 
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "brewer": { "type": "string" },
    "style": { "type": "string" },
    "volume": { "type": "integer" },
    "alcohol": { "type": "integer" },
    "country": { "type": "string" },
    "color": { "type": "string" }
  }
}
                    

Les références


{
  "definitions": {
    "Beer": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "brewer": { "$ref": "#/definitions/Brewer" },
        "style": { "type": "string" },
        "volume": { "type": "integer" },
        "alcohol": { "type": "integer" },
        "color": { "type": "string" }
      }
    },
    "Brewer": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "country": { "type": "string" }
      }
    }
  }
}
                    

Mon modĂšle

project/config/jane/json-schema.json

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "brewer": { "type": "string" },
    "style": { "type": "string" },
    "volume": { "type": "integer" },
    "alcohol": { "type": "integer" },
    "country": { "type": "string" },
    "color": { "type": "string" }
  }
}
                  

Configuration

project/config/jane/json_schema.php

return [
    'json-schema-file' => __DIR__ . '/json-schema.json',
    'root-class' => 'Beer',
    'namespace' => 'Generated',
    'directory' => __DIR__ . '/../../generated',
];
              

Génération


vendor/bin/jane generate -c config/jane/json_schema.php
              

Fichiers générés

ModĂšles


class Beer
{
    /**
     * 
     *
     * @var string
     */
    protected $name;
    /**
     * 
     *
     * @var string
     */
    protected $brewer;
    /**
     * 
     *
     * @var string
     */
    protected $style;
    /**
     * 
     *
     * @var int
     */
    protected $volume;
    /**
     * 
     *
     * @var int
     */
    protected $alcohol;
    /**
     * 
     *
     * @var string
     */
    protected $country;
    /**
     * 
     *
     * @var string
     */
    protected $color;
    /**
     * 
     *
     * @return string
     */
    public function getName() : string
    {
        return $this->name;
    }
    /**
     * 
     *
     * @param string $name
     *
     * @return self
     */
    public function setName(string $name) : self
    {
        $this->name = $name;
        return $this;
    }
    /**
     * 
     *
     * @return string
     */
    public function getBrewer() : string
    {
        return $this->brewer;
    }
    /**
     * 
     *
     * @param string $brewer
     *
     * @return self
     */
    public function setBrewer(string $brewer) : self
    {
        $this->brewer = $brewer;
        return $this;
    }
    /**
     * 
     *
     * @return string
     */
    public function getStyle() : string
    {
        return $this->style;
    }
    /**
     * 
     *
     * @param string $style
     *
     * @return self
     */
    public function setStyle(string $style) : self
    {
        $this->style = $style;
        return $this;
    }
    /**
     * 
     *
     * @return int
     */
    public function getVolume() : int
    {
        return $this->volume;
    }
    /**
     * 
     *
     * @param int $volume
     *
     * @return self
     */
    public function setVolume(int $volume) : self
    {
        $this->volume = $volume;
        return $this;
    }
    /**
     * 
     *
     * @return int
     */
    public function getAlcohol() : int
    {
        return $this->alcohol;
    }
    /**
     * 
     *
     * @param int $alcohol
     *
     * @return self
     */
    public function setAlcohol(int $alcohol) : self
    {
        $this->alcohol = $alcohol;
        return $this;
    }
    /**
     * 
     *
     * @return string
     */
    public function getCountry() : string
    {
        return $this->country;
    }
    /**
     * 
     *
     * @param string $country
     *
     * @return self
     */
    public function setCountry(string $country) : self
    {
        $this->country = $country;
        return $this;
    }
    /**
     * 
     *
     * @return string
     */
    public function getColor() : string
    {
        return $this->color;
    }
    /**
     * 
     *
     * @param string $color
     *
     * @return self
     */
    public function setColor(string $color) : self
    {
        $this->color = $color;
        return $this;
    }
}
                

Serializer ?

Serializer
symfony/serializer
jms/serializer

Normalizers


class BeerNormalizer implements DenormalizerInterface, NormalizerInterface, DenormalizerAwareInterface, NormalizerAwareInterface
{
    use DenormalizerAwareTrait;
    use NormalizerAwareTrait;
    use CheckArray;
    public function supportsDenormalization($data, $type, $format = null)
    {
        return $type === 'Generated\\Model\\Beer';
    }
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof \Generated\Model\Beer;
    }
    public function denormalize($data, $class, $format = null, array $context = array())
    {
        if (isset($data['$ref'])) {
            return new Reference($data['$ref'], $context['document-origin']);
        }
        if (isset($data['$recursiveRef'])) {
            return new Reference($data['$recursiveRef'], $context['document-origin']);
        }
        $object = new \Generated\Model\Beer();
        if (\array_key_exists('name', $data)) {
            $object->setName($data['name']);
        }
        if (\array_key_exists('brewer', $data)) {
            $object->setBrewer($data['brewer']);
        }
        if (\array_key_exists('style', $data)) {
            $object->setStyle($data['style']);
        }
        if (\array_key_exists('volume', $data)) {
            $object->setVolume($data['volume']);
        }
        if (\array_key_exists('alcohol', $data)) {
            $object->setAlcohol($data['alcohol']);
        }
        if (\array_key_exists('country', $data)) {
            $object->setCountry($data['country']);
        }
        if (\array_key_exists('color', $data)) {
            $object->setColor($data['color']);
        }
        return $object;
    }
    public function normalize($object, $format = null, array $context = array())
    {
        $data = array();
        if (null !== $object->getName()) {
            $data['name'] = $object->getName();
        }
        if (null !== $object->getBrewer()) {
            $data['brewer'] = $object->getBrewer();
        }
        if (null !== $object->getStyle()) {
            $data['style'] = $object->getStyle();
        }
        if (null !== $object->getVolume()) {
            $data['volume'] = $object->getVolume();
        }
        if (null !== $object->getAlcohol()) {
            $data['alcohol'] = $object->getAlcohol();
        }
        if (null !== $object->getCountry()) {
            $data['country'] = $object->getCountry();
        }
        if (null !== $object->getColor()) {
            $data['color'] = $object->getColor();
        }
        return $data;
    }
}
                

Indexation

project/src/Command/IndexCommand.php

// On va récupérer l'index Elasticsearch

$beers = $this->beerRepository->findAll();
foreach ($beers as $beer) {
  $model = new \Generated\Beer();
  $model->setName($beer->getName());
  $model->setBrewer($beer->getBrewer());
  $model->setStyle($beer->getStyle());
  $model->setVolume($beer->getVolume());
  $model->setAlcohol($beer->getAlcohol());
  $model->setCountry($beer->getCountry());
  $model->setColor($beer->getColor());

  $document = new \Elastica\Document($beer->getId(), $model);
  $indexer->scheduleIndex(self::BEERS_INDEX, $document);
}

// Puis on envoit les documents Ă  Elasticsearch
                  

AutoMapper

janephp/automapper

C'est quoi ?

Serializer

Schéma Symfony

Jane

Schéma Jane AutoMapper

Transformer


final class Mapper_App_Entity_Beer_Generated_Model_Beer extends \Jane\AutoMapper\Mapper
{
    protected $hash = '15937785381593778538';
    public function __construct()
    {
    }
    public function &map($value, \Jane\AutoMapper\Context $context)
    {
        if (null === $value) {
            return $value;
        }
        $result = $context->getObjectToPopulate();
        if (null === $result) {
            $result = new \Generated\Model\Beer();
        }
        if ($context->isAllowedAttribute('style')) {
            $result->setstyle($value->getstyle());
        }
        if ($context->isAllowedAttribute('volume')) {
            $result->setvolume($value->getvolume());
        }
        if ($context->isAllowedAttribute('alcohol')) {
            $result->setalcohol($value->getalcohol());
        }
        if ($context->isAllowedAttribute('name')) {
            $result->setname($value->getname());
        }
        if ($context->isAllowedAttribute('brewer')) {
            $result->setbrewer($value->getbrewer());
        }
        if ($context->isAllowedAttribute('country')) {
            $result->setcountry($value->getcountry());
        }
        if ($context->isAllowedAttribute('color')) {
            $result->setcolor($value->getcolor());
        }
        return $result;
    }
    public function injectMappers(\Jane\AutoMapper\AutoMapperInterface $autoMapper)
    {
    }
}
              

Et en pratique ?

Un utilisateur


class User
{
  private int $id;
  public string $name;
  public int $age;

  public function __construct(int $id, string $name, int $age)
  {
      $this->id = $id;
      $this->name = $name;
      $this->age = $age;
  }

  public function getId(): int
  {
      return $this->id;
  }
}
                    

Un utilisateur (simplifié)


class UserName
{
  public string $name;
}
                    

Depuis un tableau


$source = [
  'id' => 42,
  'name' => 'Bastien',
  'age' => 30,
];

$target = $automapper->map($source, User::class);

// same as 👇
$target = new UserName();
$target = $automapper->map($source, $target);
                    

Vers un tableau


$source = new User(42, 'Bastien', 30);
$target = $automapper->map($source, 'array');
                    

D'un objet vers un objet


$source = new User(42, 'Bastien', 30);
$target = $automapper->map($source, UserName::class);
                    

Des impacts possibles ...

  • Remplacer l'ObjectNormalizer de Symfony
  • Bridge symfony/form pour mapper les data en objet

Indexation

project/src/Command/IndexCommand.php

// On va récupérer l'index Elasticsearch

$beers = $this->beerRepository->findAll();
foreach ($beers as $beer) {
  $model = $this->autoMapper->map($beer, \Generated\Beer::class);
  $document = new \Elastica\Document($beer->getId(), $model);
  $indexer->scheduleIndex(self::BEERS_INDEX, $document);
}

// Puis on envoie les documents Ă  Elasticsearch
              

Récupération

project/src/Controller/BeerController.php

// On récupÚre tous les Document de notre index

$beers = [];
foreach ($results as $result) {
  $beers[] = $result->getModel();
}

return $this->json($beers);
                  

Model dump

Elasticsearch model dump

Rendu

Rendu démo - Elasticsearch

API externe

janephp/demo-external-api

OpenAPI

actual: 3.0.3
next: 3.1.0
  • Endpoints
  • Authentification

OpenAPI

Endpoint


paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
                  

ParamĂštres


paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            format: int32
      responses:
        '200':
          description: A paged array of pets
          headers:
            x-next:
              description: A link to the next page of responses
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pets"
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
                  

RĂ©ponses


paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            format: int32
      responses:
        '200':
          description: A paged array of pets
          headers:
            x-next:
              description: A link to the next page of responses
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pets"
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
                  

Réponse par défaut


paths:
  /pets:
    get:
      summary: List all pets
      operationId: listPets
      tags:
        - pets
      parameters:
        - name: limit
          in: query
          description: How many items to return at one time (max 100)
          required: false
          schema:
            type: integer
            format: int32
      responses:
        '200':
          description: A paged array of pets
          headers:
            x-next:
              description: A link to the next page of responses
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Pets"
        default:
          description: unexpected error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
                  

Authentification


components:
  securitySchemes:
    BasicAuth:
      type: http
      scheme: basic
    BearerAuth:
      type: http
      scheme: bearer
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
    OpenID:
      type: openIdConnect
      openIdConnectUrl: https://example.com/.well-known/openid-configuration
    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://example.com/oauth/authorize
          tokenUrl: https://example.com/oauth/token
          scopes:
            read: Grants read access
            write: Grants write access
            admin: Grants access to admin operations
                  

Schéma OpenAPI

Endpoint

project/config/jane/open-api.yaml

openapi: 3.0.0
info:
  version: 1.0.0
  title: 'CatFacts API'
servers:
- url: https://cat-fact.herokuapp.com
paths:
  /facts/random:
    get:
      operationId: randomFact
      responses:
        200:
          description: 'Get a random `Fact`'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Fact'
components:
  schemas:
    Fact:
      type: object
      properties:
        _id:
          type: string
          description: 'Unique ID for the `Fact`'
        __v:
          type: integer
          description: 'Version number of the `Fact`'
        user:
          type: string
          description: 'ID of the `User` who added the `Fact`'
        text:
          type: string
          description: 'The `Fact` itself'
        updatedAt:
          type: string
          format: date-time
          description: 'Date in which `Fact` was last modified'
        sendDate:
          type: string
          description: 'If the `Fact` is meant for one time use, this is the date that it is used'
        deleted:
          type: boolean
          description: 'Weather or not the `Fact` has been deleted (Soft deletes are used)'
        source:
          type: string
          description: 'Can be `user` or `api`, indicates who added the fact to the DB'
        used:
          type: boolean
          description: 'Weather or not the `Fact` has been sent by the CatBot. This value is reset each time every `Fact` is used'
        type:
          type: string
          description: 'Type of animal the `Fact` describes (e.g. ‘cat’, ‘dog’, ‘horse’)'
                    

ModĂšle


openapi: 3.0.0
info:
  version: 1.0.0
  title: 'CatFacts API'
servers:
  - url: https://cat-fact.herokuapp.com
paths:
  /facts/random:
    get:
      operationId: randomFact
      responses:
        200:
          description: 'Get a random `Fact`'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Fact'
components:
  schemas:
    Fact:
      type: object
      properties:
        _id: { type: string }
        __v: { type: integer }
        user: { type: string }
        text: { type: string }
        updatedAt: { type: string, format: date-time }
        sendDate: { type: string }
        deleted: { type: boolean }
        source: { type: string }
        used: { type: boolean }
        type: { type: string }
                    

ModĂšle


openapi: 3.0.0
info:
  version: 1.0.0
  title: 'CatFacts API'
servers:
  - url: https://cat-fact.herokuapp.com
paths:
  /facts/random:
    get:
      operationId: randomFact
      responses:
        200:
          description: 'Get a random `Fact`'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Fact'
components:
  schemas:
    Fact:
      type: object
      properties:
        text: { type: string }
        updatedAt: { type: string, format: date-time }
                    

Configuration de la génération

project/config/jane/open_api.php

return [
  'openapi-file' => __DIR__ . '/open-api.yaml',
  'namespace' => 'CatFacts\Api',
  'directory' => __DIR__ . '/../../generated',
  'date-format' => \DateTimeInterface::RFC3339_EXTENDED,
];
                

Configuration des services

project/config/packages/jane.yaml

services:
_defaults:
    autowire: true
    autoconfigure: true

CatFacts\Api\Normalizer\JaneObjectNormalizer: ~

CatFacts\Api\Client:
    factory: ['CatFacts\Api\Client', 'create']
                

Controller

project/src/Controller/FactController.php

class FactController extends AbstractController
{
  public function index(CatFacts\Api\Client $client)
  {
    return $this->render('fact.html.twig', [
      'fact' => $client->randomFact(),
    ]);
  }
}
                

Fact dump

External API model dump

Rendu

Rendu démo - API Externe

DĂ©mos

janephp

janephp/demo-with-apiplatform
janephp/demo-between-two-apps

Merci

Des questions ?