resourceful
an api-platform story.
2
Stefan Adolf
Developer Ambassador
#javascript #mongodb #serverless
#blockchain #codingberlin #elastic
#aws #php #symfony #react
#digitalization #agile #b2b #marketplaces
#spryker #php #k8s #largescale
#turbinejetzt #iot #ux #vuejs
@stadolf
elmariachi111
Not very smart.
Image courtesy of Paramount Pictures Inc. somewhere in America.
But much smarter than these.
💡imagine
a shelf that tracks its inventory
and can place orders

automatically.
what do you need to build a
smart shelf?
1. A SHELF
2. SENSORS
3. A CONCEPT
4. T-SHIRTS
PROFIT
oh. and a backend.
we need a backend.
architecture
a swarm of devices
tracks the stock of
items
that relays them towards

an event gateway
the API translates a sensor change
to an inventory change.
and sends a constant
stream of change events
to a local message broker
which pushes them to
a REST API
hub aggregates inventory change events 

to build the product stream state
smart shelve hub
customer 7 customer 10
fritz kola: 20%
club mate: 70%
water: 42%
ice tea: 5%
orders are negotiated with suppliers
it applies availability rules
to create orders
cust 10
needs
club mate
for
shelve 3
cust 7
needs
ice tea
for
shelve 5
cust 10
needs
milk
for
shelve 3
orders are placed.and shipped.
distance measurement
VL53L1X 

laser ranging sensors
up to 400cm
computing distance A by
Field of View angle
Organization
Concepts
UserUser
Shelf Shelf
Slot Slot Slot Slot
ProductStream
Product
Supplier
A123
465
A654
321
A787
878
dim dim
A787
878
dim dim
ProductStream
Supplier
Product
dim dim
Slot
A123

999
Gateway ~ APIA654321
A787878
A123465
Wifi Router
& IoT Gateway
subscribe
"sensors/+"
{
"location_id": "shelf1",
"payload": {
"battery_mV": 4110,
"build": "20181118-214917
"distance_mm": 518,
"type": "distance-sensor-
"version": "1.0"
},
"sensor_id": "A41E9640"
}
in the wild
snprintf(topic_name, sizeof(topic_name), "sensors/%s", mgos_sys_config_get_device_id());
mgos_mqtt_pubf(topic_name, 1, false,
"{distance_mm: %" PRIu16 ", battery_mV: %d, type: %Q, version: %Q, build: %Q }",
measurement_mm, battery_getvoltage(), MGOS_APP, build_version, build_id);
publish
"location_id": "shelf1",
"payload": {
"battery_mV": 4110,
"build": "20181118-214917",
"distance_mm": 518,
"type": "distance-sensor-firmware",
"version": "1.0"
},
"sensor_id": "A41E9640"
}
POST /sensor/receive_event
= API out of the box.
๏docker / docker-compose dev env
๏API with 1 demo entity
๏json-ld / hal serializer (data + links +
metadata + schema)
๏json-api support if you need it
๏hydra docs provided in <Link>
headers
๏swagger.json (OpenAPI v2 / v3)
๏autogenerated interactive docs
(NelmioApiDocs) & ReDocs
๏auto generated administration
interface (React-Admin)
๏scaffolder for React & Vue PWAs
๏Mercure for realtime http/2 pushes
๏h2 proxy / Varnish support
๏Helm charts for K8S deployment
reuse your entities.
/**
* @ORMEntity
* @ORMHasLifecycleCallbacks()
* @ApiResource(
* attributes={
* "access_control"="is_granted('ROLE_USER')",
* "order"={"product.title": "ASC"}
* }
* )
* @ApiFilter(SearchFilter::class, properties={"product.title": "partial"})
*/
class ProductStream implements OrganizationAwareResourceInterface
{
/**
* @var int
* @ORMId
* @ORMGeneratedValue
* @ORMColumn(type="integer")
* @Groups({"product_stream", "products", "sensor", "receive_event"})
*/
private $id;
/**
* @var string
* @ORMColumn(type="string", length=255)
* @Groups({"product_stream", "products", "shelf"})
*/
protected $title;
/**
* @var Product
* @ORMManyToOne(targetEntity="Product", inversedBy="productStreams")
* @Groups({"product_stream", "shelf", "slot", "sensor"})
*/
protected $product;
Declaration
Filtering
Serialization groups
Relationships
Access control
this is the only “code” I wrote.
just by adding declaration
ReDoc FTW!
Filtering
Pagination
Formats
GET /product_streams?
product.title=Fritz
[
{
"id": 12,
"title": "Fritz Kola",
"product": {
"title": "Fritz Kola",
"subTitle": null,
"gtin8": null,
"ean": "4260107220015",
"mpn": null,
"offers": [
{
"price": "17.70",
"supplierSku": "5360"
},
{
"price": "19.99",
"supplierSku": "373718"
}
],
"productGroups": [],
"images": [],
"itemCount": 24,
"width": 300,
"height": 290,
"length": 400
},
"slots": [
"/slots/54"
],
"minThreshold": 0,
"maxThreshold": 1,
"organization": "/organizations/1"
}
]
inline relations
Filtering
relation links
JSON
{
"@context": "/contexts/ProductStream",
"@id": "/product_streams",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/product_streams/12",
"@type": "ProductStream",
"id": 12,
"title": "Fritz Kola",
"product": {
"@id": "/products/13",
"@type": "Product",
"title": "Fritz Kola",
"subTitle": null,
"gtin8": null,
"ean": "4260107220015",
"mpn": null,
"offers": [
{
"@id": "/product_offers/13",
"@type": "ProductOffer",
"price": "17.70",
"supplierSku": "5360"
},
{
"@id": "/product_offers/49",
"@type": "ProductOffer",
"price": "19.99",
"supplierSku": "373718"
}
],
"productGroups": [],
"images": [],
"itemCount": 24,
"width": 300,
"height": 290,
"length": 400
},
"slots": [
"/slots/54"
],
"minThreshold": 0,
"maxThreshold": 1,
"organization": "/organizations/1"
}
]
}
Semantics
IRI-IDs
Types
inline relations
Filtering
relation links
JSON-LD
{
"_links": {
"self": {
"href": "/product_streams?
product.title=Fritz"
},
"item": [
{
"href": "/product_streams/12"
},
]
},
"totalItems": 4,
"itemsPerPage": 30,
"_embedded": {
"item": [
{
"_links": {
"self": {
"href": "/product_streams/12"
},
"product": {
"href": "/products/13"
},
"slots": [
{
"href": "/slots/54"
}
],
"organization": {
"href": "/organizations/1"
}
},
"_embedded": {
"product": {
"_links": {
"self": {
"href": "/products/13"
},
"offers": [
{
"href": "/product_offers/13"
},
{
"href": "/product_offers/49"
}
]
},
"_embedded": {
"offers": [
{
"_links": {
"self": {
"href": "/product_offers/13"
}
},
"price": "17.70",
"supplierSku": "5360"
},
{
"_links": {
"self": {
"href": "/product_offers/49"
}
},
"price": "19.99",
"supplierSku": "373718"
}
]
},
"title": "Fritz Kola",
"subTitle": null,
"gtin8": null,
"ean": "42601072215",
"mpn": null,
"productGroups": [],
"images": [],
"itemCount": 24,
"width": 300,
"height": 290,
"length": 400
}
},
"id": 12,
"title": "Fritz Kola",
"minThreshold": 0,
"maxThreshold": 1
}
]
}
}
link relations
JSON-HAL
deep relations
{
get: {
tags: [
"ProductStream"
],
operationId:
"api_organizations_product_streams_get_su
bresourceProductStreamCollection",
produces: [
"application/ld+json",
"application/hal+json",
"application/vnd.api+json",
"application/json",
"text/html"
],
summary: "Retrieves the collection of
ProductStream resources.",
responses: {
200: {
description: "ProductStream
collection response",
schema: {
type: "array",
items: {
$ref: "#/definitions/
ProductStream-
products_organization_product_stream"
}
}
}
},
parameters: [
{
name: "product.title",
in: "query",
required: false,
type: "string"
},
{
name: "page",
in: "query",
required: false,
description: "The collection page
number",
type: "integer"
}
]
}
} OpenAPI Docs

“Swagger”
this is where the magic begins.
🧞
swagger-codegen generate -l ruby 

-i "http://localhost:8080/docs.json"
autogenerated APIs
in any language.
<?php
use SwaggerClientApiException;
require_once __DIR__ . '/SwaggerClient-php/vendor/autoload.php';
const API_KEY = 'eyJ0eRw';
$config = SwaggerClientConfiguration::getDefaultConfiguration()->setApiKey('Authorization', API_KEY);
$config->setApiKeyPrefix('Authorization', 'Bearer');
$config->setHost('http://localhost:8080');
$client = new GuzzleHttpClient();
$productApi = new SwaggerClientApiProductApi(
$client,
$config
);
try {
$products = $productApi->getProductCollection('Fritz');
foreach ($products as $product) {
echo $product->getTitle() . PHP_EOL;
}
} catch (ApiException $apiException) {
print_r($apiException);
}
consume an autogenerated api-client
scaffold Vue/react/rnative apps!
autogenerated admin interface (thank you React-Admin)
composer req webonyx/graphql-php
GraphQL anyone?
GraphQL anyone?
SELECT DISTINCT p0_.id AS id_0, p1_.title AS title_1 FROM
product_stream p0_ LEFT JOIN product p1_ ON p0_.product_id
= p1_.id LEFT JOIN product_offer p2_ ON p1_.id =
p2_.product_id LEFT JOIN product_group_product p4_ ON
p1_.id = p4_.product_id LEFT JOIN product_group p3_ ON
p3_.id = p4_.product_group_id LEFT JOIN image i5_ ON
p1_.id = i5_.product_id WHERE p0_.id IN (SELECT p6_.id
FROM product_stream p6_ LEFT JOIN product p7_ ON
p6_.product_id = p7_.id LEFT JOIN product_offer p8_ ON
p7_.id = p8_.product_id LEFT JOIN product_group_product
p10_ ON p7_.id = p10_.product_id LEFT JOIN product_group
p9_ ON p9_.id = p10_.product_group_id LEFT JOIN image i11_
ON p7_.id = i11_.product_id WHERE p6_.organization_id = ?
AND p7_.title LIKE '%' || ? || '%') ORDER BY p1_.title ASC
LIMIT 30
Query Optimization

fetch=“EAGER" is enabled by default
the not so obvious.
๏custom operations
๏custom data providers
๏authentication
๏testing
๏server sent events
/**
* @ORMEntity
* @ApiResource(
* itemOperations={
* "put", "delete", "get",
* "assign_product"={
* "method"="PUT",
* "path"="/slots/{id}/products/{product_id}",
* "controller"=AppControllerAssignProductToSlot::class,
* "denormalization_context"={"groups"={"assign_slot_product"}},
* "normalization_context"={"groups"={"shelf"}},
* "swagger_context"={
* "summary"="assigns a product (stream) to your slot",
* "description"="assigns a product (stream) to your slot"
* }
* }
* },
* collectionOperations={"post", "get"}
* )
*/
class Slot
{
custom operations
add any endpoint you need, with or without entities
class AssignProductToSlot
{
/** @var DoctrineORMEntityManagerInterface */
private $em;
/** @var InventoryChangeEventStorageInterface */
private $eventStorage;
/** @var LoggerInterface $logger */
private $logger;
/**
* the __invoke() method parameter MUST be called $data, otherwise, it will not be filled correctly
*/
public function __invoke(Slot $data, $product_id)
{
$product = $this->em->getRepository(Product::class)->find($product_id);
if (!$product) {
throw new NotFoundHttpException("product $product_id unknown");
}
$organization = $data->getShelf()->getOrganization();
$productStreamRepo = $this->em->getRepository(ProductStream::class);
$productStream = $productStreamRepo->findOneBy(['organization' => $organization, 'product' => $product_id]
custom operations
Action Class (“Controller”)
Action Class

Action Domain Responder pattern
Automatic Deserialization
custom data providers
we use:
we should use:
custom data providers
Who said you need Doctrine anyway?
namespace AppModelEvents;


/**
* @ApiResource(
* attributes={"pagination_items_per_page"=10},
* itemOperations={},
* collectionOperations={
* "get"={
* "normalization_context"={"groups"={"list_events"}},
* }
* }
* )
* @ApiFilter(EventFilter::class)
*/
class InventoryChangeEvent
{
/**
* @var
* @Groups({"list_events"})
*/
protected $id;
/**
* @Groups({"list_events"})
* @var CakeChronosChronos
*/
protected $timestamp;
look Ma, 

I’m not an entity
custom data providers
Who said you need Doctrine anyway?

https://github.com/dunglas/doctrine-json-odm
<?php
namespace AppEntityEvents
use AppModelEventsInventoryChangeEvent as ModelInventoryChangeEvent;
use DoctrineORMMapping as ORM;
/**
* @ORMEntity()
*/
class InventoryChangeEvent extends ModelInventoryChangeEvent
{
/**
* @ORMColumn(type="datetime")
* @var CakeChronosChronos
*/
protected $timestamp;
/**
* @var SensorEvent
* @ORMColumn(type="json_document")
*/
protected $originalEvent;
/**
* @var array
* @ORMColumn(type="json_document", nullable=true)
*/
protected $product;
look Ma, 

I am an entity!
look Ma, 

I use NoSQL
custom data providers
Who said you need an event store anyway?
look Ma, 

I use NoSQL
I put a JSON in your SQL
{
"id": "A419BA74_20190327_124614:063960",
"timestamp": "2019-03-27T12:46:14+00:00",
"originalEvent": {
"@id": "/sensor_events/A419BA74_20190327_124614%253A063960",
"@type": "SensorEvent",
"id": "A419BA74_20190327_124614:063960",
"sensorId": "A419BA74",
"payload": {
"batteryMV": 3982,
"distanceMm": 456
}
},
"product": {
"id": 51,
"gtin8": null,
"ean": null,
"mpn": null
},
"organization": {
"@id": "/organizations/3",
"@type": "Organization"
},
"oldPercentage": 0.636,
"newPercentage": 0.6352,
"oldStreamPercentage": 0.66666666666667,
"newStreamPercentage": 0.66666666666667,
"itemCount": 2
},
original sensor event
domain event (what has changed?)
custom data providers
data provider interfacesnamespace ApiPlatformCoreDataProvider;
use ApiPlatformCoreExceptionResourceClassNotSupportedException;
/**
* Retrieves items from a persistence layer.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface CollectionDataProviderInterface
{
/**
* Retrieves a collection.
*
* @throws ResourceClassNotSupportedException
*
* @return array|Traversable
*/
public function getCollection(string $resourceClass, string $operationName = null);
}
/**
* Restricts a data provider based on a condition.
*/
interface RestrictedDataProviderInterface
{
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool;
}
custom data providersclass InventoryChangeEventDataProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
/** @var InventoryChangeEventStorageInterface */
private $eventStorage;
/** @var RequestStack $requestStack */
private $requestStack;
/** @var ResourceMetadataFactoryInterface */
private $resourceMetadataFactory;
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return InventoryChangeEvent::class === $resourceClass;
}
public function getCollection(string $resourceClass, string $operationName = null)
{
$request = $this->requestStack->getCurrentRequest();
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$currentPage = $request->get($this->pageParameter, 1);
$itemsPerPage = $resourceMetadata->getCollectionOperationAttribute(
$operationName, 'pagination_items_per_page', $this->itemsPerPage, true
);
$paginator = $this->eventStorage->searchWithPagination($currentPage, $itemsPerPage, [
'deviceId' => $request->get('deviceId', null),
'productId' => $request->get('productId', null)
]);
return $paginator;
}
storage abstraction
you must be able

to paginate
custom data providers
class InventoryChangeEventRelationalStorage implements InventoryChangeEventStorageInterface
{
/** @var DoctrineORMEntityManagerInterface */
private $em;
public function createEventInstance(): AppModelEventsInventoryChangeEvent {
return new InventoryChangeEvent();
}
public function store(AppModelEventsInventoryChangeEvent $event)
{
$this->em->persist($event);
}
public function searchWithPagination(?int $page = 1, ?int $itemsPerPage = 30, ?array $criteria = []): PaginatorInterface {
$firstResult = ($page-1) * $itemsPerPage;
$qb = $this->createQueryBuilder($criteria);
$query = $qb->getQuery()
->setFirstResult($firstResult)
->setMaxResults($itemsPerPage);
$doctrinePaginator = new Paginator($query);
$paginator = new ApiPlatformCoreBridgeDoctrineOrmPaginator($doctrinePaginator);
return $paginator;
}
/**
* @return iterable
*/
public function getIterator(?array $criteria = []): iterable {
return $this->createQueryBuilder($criteria)
->orderBy("ice.timestamp", "ASC");
}
}
factory for concrete

Models / Entities
decorate Paginator
do your query business
Authenticationsecurity:
providers:
jwt:
lexik_jwt:
class: AppEntityUser
app_user_provider:
entity:
class: AppEntityUser
property: email
encoders:
AppEntityUser:
algorithm: argon2i
firewalls:
login:
pattern: ^/login
stateless: true
anonymous: true
provider: app_user_provider
json_login:
check_path: /login_check
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
main:
pattern: ^/
provider: jwt
stateless: true
guard:
authenticators:
- lexik_jwt_authentication.jwt_token_authenticator
provider: jwt
anonymous: ~
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
database-less authentication
JWT creation endpoint
https://packagist.org/packages/lexik/jwt-authentication-bundle
Authentication
Feature: Deliver sensor events
In order to hand over my sensor events
As a gateway
I need to be able to push SensorEvents into the API.
Background:
Given There is an Organization titled "organization"
And The organization has a Shelf titled "shelf1"
And The user "user@organization.com" exists and has the role "ROLE_USER"
And The shelf has a slot called "links oben" with dimensions '{"width":300, "height": 400, "length": 1250}'
And the product "Ice Tea" exists with packageDimensions '{"width":300, "height": 300, "length": 400}'
And the product "Warm Beer" exists with packageDimensions '{"width":400, "height": 300, "length": 300}'
Scenario: Push another event, create a slot and associate it with a product stream
And I authenticate using the email address "gateway1@resourceful.de" having the role "ROLE_GATEWAY"
And I send a "POST" request to "/sensor/receive_event" with body:
"""
{
"sensor_id": "A123457",
"payload": {
"distance_mm": 950,
"battery_mV": 3500
},
"location_id": "shelve"
}
"""
Then the response status code should be 201
And the response should be in JSON
When I send a "POST" request to "/slots" with body:
"""
{
"shelf": "/shelves/1",
"sensor": "/sensors/1",
"title": "rechts unten",
"dimensions": {
"width": 350,
"height": 400,
"length": 1250
}
}
"""
Then the response status code should be 201
And the shelf contains 2 slots
Testinghttp://behat.org/en/latest/
sensorevents.feature
default:
autoload: [ 'features/contexts' ]
suites:
default:
paths: [ 'features' ]
contexts:
- AuthenticationContext:
doctrine: "@doctrine"
jwtManager: "@lexik_jwt_authentication.jwt_manager"
userManager: "@AppSecurityUserManager"
- FeatureContext:
doctrine: "@doctrine"
- ResourcefulContext:
doctrine: "@doctrine"
userManager: "@AppSecurityUserManager"
- BehatMinkExtensionContextMinkContext
- behatch:context:json:
evaluationMode: javascript
- behatch:context:system:
root: "."
- behatch:context:table
- behatch:context:xml
extensions:
BehatSymfony2Extension:
kernel:
bootstrap: config/behat_bootstrap.php
class: AppKernel
BehatchExtension: ~
BehatMinkExtension:
sessions:
default:
symfony2: ~
symfony2:
extensions:
BehatMinkExtension:
default_session: symfony2
Testingbehat.yml
“When I authenticate as “
“I send a "POST" request to“
“I send a "POST" request to“
use the Symfony Kernel & DI
/**
* @Then the ProductStream for product :productTitle contains :count items
*/
public function theProductstreamForContainsItems($productTitle, $count)
{
$product = $this->em->getRepository(Product::class)->findOneBy(["title" => $productTitle]);
$productStream = $product->getProductStreams()->first();
foreach ($productStream->getSlots() as $slot) {
$slot = $this->findOrRefresh($slot);
}
$this->assertEquals($count, $productStream->getCurrentBoxCount());
}
Testing
ResourcefulContext.php
real realtime.
update the fullness indicators
in “real” time
❤ Symfony
๏ official Symfony component since March
2019
๏ written in Go
๏ high level replacement for WebSockets
(socket.io)
๏ following the server-sent events
WHATWG standard
๏ works in all browsers
๏ polyfill for Micro$oft “browsers” is
available
๏ compatible with HTTP/2
๏ (which isn’t supported by some PaaS)
๏ auto-discoverable
๏ using hub address in <Link> header
๏ compatible with GraphQL subscriptions
(apollo)
/**
*
* @ORMEntity
* @ORMHasLifecycleCallbacks()
* @ApiResource(
* attributes={
* "mercure"=true
* },
* collectionOperations={
* "post"={
* "normalization_context"={"groups"={}}
* },
* "get"={
* "normalization_context"={"groups"={"product_stream", "products"}}
* },
* }
* )
*/
class ProductStream implements OrganizationAwareResourceInterface
{
Realtime
GET "http://localhost:8080/product_streams/50"



—> 



cache-control: private, must-revalidate
content-type: application/ld+json; charset=utf-8
date: Wed, 27 Mar 2019 14:19:32 GMT
link: <http://localhost:8080/docs.jsonld>; rel="http://www.w3.org/core#api",<https://mercure.stadolf.de/hub>; rel="mercure"
server: nginx/1.15.9
x-content-type-options: nosniff
x-debug-token: 21fce0
x-powered-by: PHP/7.2.15
methods: {
enableUpdates(iri) {
const url = new URL("https://mercure.stadolf.de/hub");
url.searchParams.append("topic", iri);
this.eventSource = new EventSource(url.toString());
this.eventSource.onmessage = this.receiveUpdate;
},
receiveUpdate(evt) {
const streamUpdate = JSON.parse(evt.data);
const id = streamUpdate["@id"];
const streams = this.productStreams.edges.filter(e => e.node.id == id);
if (streams.length > 0) {
streams[0].node.currentBoxCount = streamUpdate["currentBoxCount"];
}
}
},
ProductStreams.vue
native(!) server sent events
https://html.spec.whatwg.org/multipage/server-sent-events.html
contains the updated resource.
<?php
namespace AppController;
use ApiPlatformCoreApiIriConverterInterface;
use ApiPlatformCoreApiUrlGeneratorInterface;
use AppEntityProductStream;
use SymfonyComponentMercurePublisher;
use SymfonyComponentMercureUpdate;
use SymfonyComponentSerializerSerializerInterface;
class SensorReceiveChange
{
public function __construct(SerializerInterface $serializer, IriConverterInterface $iriConverter, Publisher $publisher) {}
public function __invoke() {
...
$this->publish($productStream);
...
}
protected function publish(ProductStream $stream) {
$iri = $this->iriConverter->getIriFromItem($stream, UrlGeneratorInterface::ABS_URL);
$data = $this->serializer->serialize($stream, 'jsonld', ['groups' => ['receive_event']]);


$update = new Update($iri, $data);
($this->publisher)($update);
}
}
Custom updates
in case you’re not persisting ;)
Too long, didn’t listen
๏builds API out of plain entity
descriptions
๏filters, pagination, ordering are
builtin
๏compatible to all major
standards
๏scaffolds applications and clients
๏comes with container templates,
ready to deploy to production
(K8S)
๏supports Mongo, Elasticsearch,
Mercure
๏if you know Symfony the learning
curve is very low
That’s all, folks
Stefan Adolf

https://www.facebook.com/stadolf

https://twitter.com/stadolf

https://github.com/elmariachi111

https://www.linkedin.com/in/stadolf/

api-platform: the ultimate API platform

  • 1.
  • 2.
    2 Stefan Adolf Developer Ambassador #javascript#mongodb #serverless #blockchain #codingberlin #elastic #aws #php #symfony #react #digitalization #agile #b2b #marketplaces #spryker #php #k8s #largescale #turbinejetzt #iot #ux #vuejs @stadolf elmariachi111
  • 3.
    Not very smart. Imagecourtesy of Paramount Pictures Inc. somewhere in America.
  • 4.
    But much smarterthan these.
  • 5.
    💡imagine a shelf thattracks its inventory and can place orders
 automatically.
  • 6.
    what do youneed to build a smart shelf?
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
    oh. and abackend. we need a backend.
  • 13.
  • 14.
    a swarm ofdevices tracks the stock of items that relays them towards
 an event gateway the API translates a sensor change to an inventory change. and sends a constant stream of change events to a local message broker which pushes them to a REST API hub aggregates inventory change events 
 to build the product stream state smart shelve hub customer 7 customer 10 fritz kola: 20% club mate: 70% water: 42% ice tea: 5% orders are negotiated with suppliers it applies availability rules to create orders cust 10 needs club mate for shelve 3 cust 7 needs ice tea for shelve 5 cust 10 needs milk for shelve 3 orders are placed.and shipped.
  • 15.
    distance measurement VL53L1X 
 laserranging sensors up to 400cm computing distance A by Field of View angle
  • 16.
    Organization Concepts UserUser Shelf Shelf Slot SlotSlot Slot ProductStream Product Supplier A123 465 A654 321 A787 878 dim dim A787 878 dim dim ProductStream Supplier Product dim dim Slot A123
 999
  • 17.
    Gateway ~ APIA654321 A787878 A123465 WifiRouter & IoT Gateway subscribe "sensors/+" { "location_id": "shelf1", "payload": { "battery_mV": 4110, "build": "20181118-214917 "distance_mm": 518, "type": "distance-sensor- "version": "1.0" }, "sensor_id": "A41E9640" } in the wild snprintf(topic_name, sizeof(topic_name), "sensors/%s", mgos_sys_config_get_device_id()); mgos_mqtt_pubf(topic_name, 1, false, "{distance_mm: %" PRIu16 ", battery_mV: %d, type: %Q, version: %Q, build: %Q }", measurement_mm, battery_getvoltage(), MGOS_APP, build_version, build_id); publish
  • 18.
    "location_id": "shelf1", "payload": { "battery_mV":4110, "build": "20181118-214917", "distance_mm": 518, "type": "distance-sensor-firmware", "version": "1.0" }, "sensor_id": "A41E9640" } POST /sensor/receive_event
  • 19.
    = API outof the box. ๏docker / docker-compose dev env ๏API with 1 demo entity ๏json-ld / hal serializer (data + links + metadata + schema) ๏json-api support if you need it ๏hydra docs provided in <Link> headers ๏swagger.json (OpenAPI v2 / v3) ๏autogenerated interactive docs (NelmioApiDocs) & ReDocs ๏auto generated administration interface (React-Admin) ๏scaffolder for React & Vue PWAs ๏Mercure for realtime http/2 pushes ๏h2 proxy / Varnish support ๏Helm charts for K8S deployment
  • 23.
  • 24.
    /** * @ORMEntity * @ORMHasLifecycleCallbacks() *@ApiResource( * attributes={ * "access_control"="is_granted('ROLE_USER')", * "order"={"product.title": "ASC"} * } * ) * @ApiFilter(SearchFilter::class, properties={"product.title": "partial"}) */ class ProductStream implements OrganizationAwareResourceInterface { /** * @var int * @ORMId * @ORMGeneratedValue * @ORMColumn(type="integer") * @Groups({"product_stream", "products", "sensor", "receive_event"}) */ private $id; /** * @var string * @ORMColumn(type="string", length=255) * @Groups({"product_stream", "products", "shelf"}) */ protected $title; /** * @var Product * @ORMManyToOne(targetEntity="Product", inversedBy="productStreams") * @Groups({"product_stream", "shelf", "slot", "sensor"}) */ protected $product; Declaration Filtering Serialization groups Relationships Access control this is the only “code” I wrote.
  • 25.
    just by addingdeclaration
  • 26.
  • 27.
  • 28.
  • 29.
    [ { "id": 12, "title": "FritzKola", "product": { "title": "Fritz Kola", "subTitle": null, "gtin8": null, "ean": "4260107220015", "mpn": null, "offers": [ { "price": "17.70", "supplierSku": "5360" }, { "price": "19.99", "supplierSku": "373718" } ], "productGroups": [], "images": [], "itemCount": 24, "width": 300, "height": 290, "length": 400 }, "slots": [ "/slots/54" ], "minThreshold": 0, "maxThreshold": 1, "organization": "/organizations/1" } ] inline relations Filtering relation links JSON
  • 30.
    { "@context": "/contexts/ProductStream", "@id": "/product_streams", "@type":"hydra:Collection", "hydra:member": [ { "@id": "/product_streams/12", "@type": "ProductStream", "id": 12, "title": "Fritz Kola", "product": { "@id": "/products/13", "@type": "Product", "title": "Fritz Kola", "subTitle": null, "gtin8": null, "ean": "4260107220015", "mpn": null, "offers": [ { "@id": "/product_offers/13", "@type": "ProductOffer", "price": "17.70", "supplierSku": "5360" }, { "@id": "/product_offers/49", "@type": "ProductOffer", "price": "19.99", "supplierSku": "373718" } ], "productGroups": [], "images": [], "itemCount": 24, "width": 300, "height": 290, "length": 400 }, "slots": [ "/slots/54" ], "minThreshold": 0, "maxThreshold": 1, "organization": "/organizations/1" } ] } Semantics IRI-IDs Types inline relations Filtering relation links JSON-LD
  • 31.
    { "_links": { "self": { "href":"/product_streams? product.title=Fritz" }, "item": [ { "href": "/product_streams/12" }, ] }, "totalItems": 4, "itemsPerPage": 30, "_embedded": { "item": [ { "_links": { "self": { "href": "/product_streams/12" }, "product": { "href": "/products/13" }, "slots": [ { "href": "/slots/54" } ], "organization": { "href": "/organizations/1" } }, "_embedded": { "product": { "_links": { "self": { "href": "/products/13" }, "offers": [ { "href": "/product_offers/13" }, { "href": "/product_offers/49" } ] }, "_embedded": { "offers": [ { "_links": { "self": { "href": "/product_offers/13" } }, "price": "17.70", "supplierSku": "5360" }, { "_links": { "self": { "href": "/product_offers/49" } }, "price": "19.99", "supplierSku": "373718" } ] }, "title": "Fritz Kola", "subTitle": null, "gtin8": null, "ean": "42601072215", "mpn": null, "productGroups": [], "images": [], "itemCount": 24, "width": 300, "height": 290, "length": 400 } }, "id": 12, "title": "Fritz Kola", "minThreshold": 0, "maxThreshold": 1 } ] } } link relations JSON-HAL deep relations
  • 32.
    { get: { tags: [ "ProductStream" ], operationId: "api_organizations_product_streams_get_su bresourceProductStreamCollection", produces:[ "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/json", "text/html" ], summary: "Retrieves the collection of ProductStream resources.", responses: { 200: { description: "ProductStream collection response", schema: { type: "array", items: { $ref: "#/definitions/ ProductStream- products_organization_product_stream" } } } }, parameters: [ { name: "product.title", in: "query", required: false, type: "string" }, { name: "page", in: "query", required: false, description: "The collection page number", type: "integer" } ] } } OpenAPI Docs
 “Swagger”
  • 33.
    this is wherethe magic begins. 🧞
  • 34.
    swagger-codegen generate -lruby 
 -i "http://localhost:8080/docs.json" autogenerated APIs in any language.
  • 35.
    <?php use SwaggerClientApiException; require_once __DIR__. '/SwaggerClient-php/vendor/autoload.php'; const API_KEY = 'eyJ0eRw'; $config = SwaggerClientConfiguration::getDefaultConfiguration()->setApiKey('Authorization', API_KEY); $config->setApiKeyPrefix('Authorization', 'Bearer'); $config->setHost('http://localhost:8080'); $client = new GuzzleHttpClient(); $productApi = new SwaggerClientApiProductApi( $client, $config ); try { $products = $productApi->getProductCollection('Fritz'); foreach ($products as $product) { echo $product->getTitle() . PHP_EOL; } } catch (ApiException $apiException) { print_r($apiException); } consume an autogenerated api-client
  • 36.
  • 37.
    autogenerated admin interface(thank you React-Admin)
  • 38.
  • 39.
  • 40.
    SELECT DISTINCT p0_.idAS id_0, p1_.title AS title_1 FROM product_stream p0_ LEFT JOIN product p1_ ON p0_.product_id = p1_.id LEFT JOIN product_offer p2_ ON p1_.id = p2_.product_id LEFT JOIN product_group_product p4_ ON p1_.id = p4_.product_id LEFT JOIN product_group p3_ ON p3_.id = p4_.product_group_id LEFT JOIN image i5_ ON p1_.id = i5_.product_id WHERE p0_.id IN (SELECT p6_.id FROM product_stream p6_ LEFT JOIN product p7_ ON p6_.product_id = p7_.id LEFT JOIN product_offer p8_ ON p7_.id = p8_.product_id LEFT JOIN product_group_product p10_ ON p7_.id = p10_.product_id LEFT JOIN product_group p9_ ON p9_.id = p10_.product_group_id LEFT JOIN image i11_ ON p7_.id = i11_.product_id WHERE p6_.organization_id = ? AND p7_.title LIKE '%' || ? || '%') ORDER BY p1_.title ASC LIMIT 30 Query Optimization
 fetch=“EAGER" is enabled by default
  • 41.
    the not soobvious. ๏custom operations ๏custom data providers ๏authentication ๏testing ๏server sent events
  • 42.
    /** * @ORMEntity * @ApiResource( *itemOperations={ * "put", "delete", "get", * "assign_product"={ * "method"="PUT", * "path"="/slots/{id}/products/{product_id}", * "controller"=AppControllerAssignProductToSlot::class, * "denormalization_context"={"groups"={"assign_slot_product"}}, * "normalization_context"={"groups"={"shelf"}}, * "swagger_context"={ * "summary"="assigns a product (stream) to your slot", * "description"="assigns a product (stream) to your slot" * } * } * }, * collectionOperations={"post", "get"} * ) */ class Slot { custom operations add any endpoint you need, with or without entities
  • 43.
    class AssignProductToSlot { /** @varDoctrineORMEntityManagerInterface */ private $em; /** @var InventoryChangeEventStorageInterface */ private $eventStorage; /** @var LoggerInterface $logger */ private $logger; /** * the __invoke() method parameter MUST be called $data, otherwise, it will not be filled correctly */ public function __invoke(Slot $data, $product_id) { $product = $this->em->getRepository(Product::class)->find($product_id); if (!$product) { throw new NotFoundHttpException("product $product_id unknown"); } $organization = $data->getShelf()->getOrganization(); $productStreamRepo = $this->em->getRepository(ProductStream::class); $productStream = $productStreamRepo->findOneBy(['organization' => $organization, 'product' => $product_id] custom operations Action Class (“Controller”) Action Class
 Action Domain Responder pattern Automatic Deserialization
  • 44.
    custom data providers weuse: we should use:
  • 45.
    custom data providers Whosaid you need Doctrine anyway? namespace AppModelEvents; 
 /** * @ApiResource( * attributes={"pagination_items_per_page"=10}, * itemOperations={}, * collectionOperations={ * "get"={ * "normalization_context"={"groups"={"list_events"}}, * } * } * ) * @ApiFilter(EventFilter::class) */ class InventoryChangeEvent { /** * @var * @Groups({"list_events"}) */ protected $id; /** * @Groups({"list_events"}) * @var CakeChronosChronos */ protected $timestamp; look Ma, 
 I’m not an entity
  • 46.
    custom data providers Whosaid you need Doctrine anyway?
 https://github.com/dunglas/doctrine-json-odm <?php namespace AppEntityEvents use AppModelEventsInventoryChangeEvent as ModelInventoryChangeEvent; use DoctrineORMMapping as ORM; /** * @ORMEntity() */ class InventoryChangeEvent extends ModelInventoryChangeEvent { /** * @ORMColumn(type="datetime") * @var CakeChronosChronos */ protected $timestamp; /** * @var SensorEvent * @ORMColumn(type="json_document") */ protected $originalEvent; /** * @var array * @ORMColumn(type="json_document", nullable=true) */ protected $product; look Ma, 
 I am an entity! look Ma, 
 I use NoSQL
  • 47.
    custom data providers Whosaid you need an event store anyway? look Ma, 
 I use NoSQL
  • 48.
    I put aJSON in your SQL { "id": "A419BA74_20190327_124614:063960", "timestamp": "2019-03-27T12:46:14+00:00", "originalEvent": { "@id": "/sensor_events/A419BA74_20190327_124614%253A063960", "@type": "SensorEvent", "id": "A419BA74_20190327_124614:063960", "sensorId": "A419BA74", "payload": { "batteryMV": 3982, "distanceMm": 456 } }, "product": { "id": 51, "gtin8": null, "ean": null, "mpn": null }, "organization": { "@id": "/organizations/3", "@type": "Organization" }, "oldPercentage": 0.636, "newPercentage": 0.6352, "oldStreamPercentage": 0.66666666666667, "newStreamPercentage": 0.66666666666667, "itemCount": 2 }, original sensor event domain event (what has changed?)
  • 49.
    custom data providers dataprovider interfacesnamespace ApiPlatformCoreDataProvider; use ApiPlatformCoreExceptionResourceClassNotSupportedException; /** * Retrieves items from a persistence layer. * * @author Kévin Dunglas <dunglas@gmail.com> */ interface CollectionDataProviderInterface { /** * Retrieves a collection. * * @throws ResourceClassNotSupportedException * * @return array|Traversable */ public function getCollection(string $resourceClass, string $operationName = null); } /** * Restricts a data provider based on a condition. */ interface RestrictedDataProviderInterface { public function supports(string $resourceClass, string $operationName = null, array $context = []): bool; }
  • 50.
    custom data providersclassInventoryChangeEventDataProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface { /** @var InventoryChangeEventStorageInterface */ private $eventStorage; /** @var RequestStack $requestStack */ private $requestStack; /** @var ResourceMetadataFactoryInterface */ private $resourceMetadataFactory; public function supports(string $resourceClass, string $operationName = null, array $context = []): bool { return InventoryChangeEvent::class === $resourceClass; } public function getCollection(string $resourceClass, string $operationName = null) { $request = $this->requestStack->getCurrentRequest(); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $currentPage = $request->get($this->pageParameter, 1); $itemsPerPage = $resourceMetadata->getCollectionOperationAttribute( $operationName, 'pagination_items_per_page', $this->itemsPerPage, true ); $paginator = $this->eventStorage->searchWithPagination($currentPage, $itemsPerPage, [ 'deviceId' => $request->get('deviceId', null), 'productId' => $request->get('productId', null) ]); return $paginator; } storage abstraction you must be able
 to paginate
  • 51.
    custom data providers classInventoryChangeEventRelationalStorage implements InventoryChangeEventStorageInterface { /** @var DoctrineORMEntityManagerInterface */ private $em; public function createEventInstance(): AppModelEventsInventoryChangeEvent { return new InventoryChangeEvent(); } public function store(AppModelEventsInventoryChangeEvent $event) { $this->em->persist($event); } public function searchWithPagination(?int $page = 1, ?int $itemsPerPage = 30, ?array $criteria = []): PaginatorInterface { $firstResult = ($page-1) * $itemsPerPage; $qb = $this->createQueryBuilder($criteria); $query = $qb->getQuery() ->setFirstResult($firstResult) ->setMaxResults($itemsPerPage); $doctrinePaginator = new Paginator($query); $paginator = new ApiPlatformCoreBridgeDoctrineOrmPaginator($doctrinePaginator); return $paginator; } /** * @return iterable */ public function getIterator(?array $criteria = []): iterable { return $this->createQueryBuilder($criteria) ->orderBy("ice.timestamp", "ASC"); } } factory for concrete
 Models / Entities decorate Paginator do your query business
  • 52.
    Authenticationsecurity: providers: jwt: lexik_jwt: class: AppEntityUser app_user_provider: entity: class: AppEntityUser property:email encoders: AppEntityUser: algorithm: argon2i firewalls: login: pattern: ^/login stateless: true anonymous: true provider: app_user_provider json_login: check_path: /login_check success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure main: pattern: ^/ provider: jwt stateless: true guard: authenticators: - lexik_jwt_authentication.jwt_token_authenticator provider: jwt anonymous: ~ access_control: - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/, roles: IS_AUTHENTICATED_ANONYMOUSLY } database-less authentication JWT creation endpoint https://packagist.org/packages/lexik/jwt-authentication-bundle
  • 53.
  • 54.
    Feature: Deliver sensorevents In order to hand over my sensor events As a gateway I need to be able to push SensorEvents into the API. Background: Given There is an Organization titled "organization" And The organization has a Shelf titled "shelf1" And The user "user@organization.com" exists and has the role "ROLE_USER" And The shelf has a slot called "links oben" with dimensions '{"width":300, "height": 400, "length": 1250}' And the product "Ice Tea" exists with packageDimensions '{"width":300, "height": 300, "length": 400}' And the product "Warm Beer" exists with packageDimensions '{"width":400, "height": 300, "length": 300}' Scenario: Push another event, create a slot and associate it with a product stream And I authenticate using the email address "gateway1@resourceful.de" having the role "ROLE_GATEWAY" And I send a "POST" request to "/sensor/receive_event" with body: """ { "sensor_id": "A123457", "payload": { "distance_mm": 950, "battery_mV": 3500 }, "location_id": "shelve" } """ Then the response status code should be 201 And the response should be in JSON When I send a "POST" request to "/slots" with body: """ { "shelf": "/shelves/1", "sensor": "/sensors/1", "title": "rechts unten", "dimensions": { "width": 350, "height": 400, "length": 1250 } } """ Then the response status code should be 201 And the shelf contains 2 slots Testinghttp://behat.org/en/latest/ sensorevents.feature
  • 55.
    default: autoload: [ 'features/contexts'] suites: default: paths: [ 'features' ] contexts: - AuthenticationContext: doctrine: "@doctrine" jwtManager: "@lexik_jwt_authentication.jwt_manager" userManager: "@AppSecurityUserManager" - FeatureContext: doctrine: "@doctrine" - ResourcefulContext: doctrine: "@doctrine" userManager: "@AppSecurityUserManager" - BehatMinkExtensionContextMinkContext - behatch:context:json: evaluationMode: javascript - behatch:context:system: root: "." - behatch:context:table - behatch:context:xml extensions: BehatSymfony2Extension: kernel: bootstrap: config/behat_bootstrap.php class: AppKernel BehatchExtension: ~ BehatMinkExtension: sessions: default: symfony2: ~ symfony2: extensions: BehatMinkExtension: default_session: symfony2 Testingbehat.yml “When I authenticate as “ “I send a "POST" request to“ “I send a "POST" request to“ use the Symfony Kernel & DI
  • 56.
    /** * @Then theProductStream for product :productTitle contains :count items */ public function theProductstreamForContainsItems($productTitle, $count) { $product = $this->em->getRepository(Product::class)->findOneBy(["title" => $productTitle]); $productStream = $product->getProductStreams()->first(); foreach ($productStream->getSlots() as $slot) { $slot = $this->findOrRefresh($slot); } $this->assertEquals($count, $productStream->getCurrentBoxCount()); } Testing ResourcefulContext.php
  • 57.
  • 58.
    update the fullnessindicators in “real” time
  • 60.
    ❤ Symfony ๏ officialSymfony component since March 2019 ๏ written in Go ๏ high level replacement for WebSockets (socket.io) ๏ following the server-sent events WHATWG standard ๏ works in all browsers ๏ polyfill for Micro$oft “browsers” is available ๏ compatible with HTTP/2 ๏ (which isn’t supported by some PaaS) ๏ auto-discoverable ๏ using hub address in <Link> header ๏ compatible with GraphQL subscriptions (apollo)
  • 61.
    /** * * @ORMEntity * @ORMHasLifecycleCallbacks() *@ApiResource( * attributes={ * "mercure"=true * }, * collectionOperations={ * "post"={ * "normalization_context"={"groups"={}} * }, * "get"={ * "normalization_context"={"groups"={"product_stream", "products"}} * }, * } * ) */ class ProductStream implements OrganizationAwareResourceInterface { Realtime GET "http://localhost:8080/product_streams/50"
 
 —> 
 
 cache-control: private, must-revalidate content-type: application/ld+json; charset=utf-8 date: Wed, 27 Mar 2019 14:19:32 GMT link: <http://localhost:8080/docs.jsonld>; rel="http://www.w3.org/core#api",<https://mercure.stadolf.de/hub>; rel="mercure" server: nginx/1.15.9 x-content-type-options: nosniff x-debug-token: 21fce0 x-powered-by: PHP/7.2.15
  • 62.
    methods: { enableUpdates(iri) { consturl = new URL("https://mercure.stadolf.de/hub"); url.searchParams.append("topic", iri); this.eventSource = new EventSource(url.toString()); this.eventSource.onmessage = this.receiveUpdate; }, receiveUpdate(evt) { const streamUpdate = JSON.parse(evt.data); const id = streamUpdate["@id"]; const streams = this.productStreams.edges.filter(e => e.node.id == id); if (streams.length > 0) { streams[0].node.currentBoxCount = streamUpdate["currentBoxCount"]; } } }, ProductStreams.vue native(!) server sent events https://html.spec.whatwg.org/multipage/server-sent-events.html contains the updated resource.
  • 63.
    <?php namespace AppController; use ApiPlatformCoreApiIriConverterInterface; useApiPlatformCoreApiUrlGeneratorInterface; use AppEntityProductStream; use SymfonyComponentMercurePublisher; use SymfonyComponentMercureUpdate; use SymfonyComponentSerializerSerializerInterface; class SensorReceiveChange { public function __construct(SerializerInterface $serializer, IriConverterInterface $iriConverter, Publisher $publisher) {} public function __invoke() { ... $this->publish($productStream); ... } protected function publish(ProductStream $stream) { $iri = $this->iriConverter->getIriFromItem($stream, UrlGeneratorInterface::ABS_URL); $data = $this->serializer->serialize($stream, 'jsonld', ['groups' => ['receive_event']]); 
 $update = new Update($iri, $data); ($this->publisher)($update); } } Custom updates in case you’re not persisting ;)
  • 64.
    Too long, didn’tlisten ๏builds API out of plain entity descriptions ๏filters, pagination, ordering are builtin ๏compatible to all major standards ๏scaffolds applications and clients ๏comes with container templates, ready to deploy to production (K8S) ๏supports Mongo, Elasticsearch, Mercure ๏if you know Symfony the learning curve is very low
  • 65.
    That’s all, folks StefanAdolf
 https://www.facebook.com/stadolf
 https://twitter.com/stadolf
 https://github.com/elmariachi111
 https://www.linkedin.com/in/stadolf/