diff --git a/composer.json b/composer.json index b43f69f5d277f9d092f7abfa57d3b45a25ed338b..7606ec7e58202fcf997212c892fc959bf775011d 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "ext-pdo": "*", "ext-mbstring": "*", "ext-dom": "*", - "opis/json-schema": "^1.0", + "opis/json-schema": "^1.1", "slim/psr7": "1.4", "slim/slim": "4.7.1", "php-di/php-di": "6.3.4", diff --git a/composer.lock b/composer.lock index ea090ceb84c62f1378138e8cc38004e23b89a97e..bf744a8e1d2f84975aa167a1b4dd6fc35359cc50 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5cc4ba67bf29d9dec56b819f4a86e27b", + "content-hash": "4b35b69868f07e1e9694a8df66d0e4a9", "packages": [ { "name": "algo26-matthias/idna-convert", diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/AddOperation.php b/lib/classes/JsonApi/Ext/AtomicOperations/AddOperation.php new file mode 100644 index 0000000000000000000000000000000000000000..0d68ae676bc7e885ed46d906cd26266e5877794f --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/AddOperation.php @@ -0,0 +1,10 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +final class AddOperation extends Operation +{ + public function getMethod(): string + { + return 'POST'; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/Operation.php b/lib/classes/JsonApi/Ext/AtomicOperations/Operation.php new file mode 100644 index 0000000000000000000000000000000000000000..fa14ce15620321764ca453e8c2e61dd30ccfba69 --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/Operation.php @@ -0,0 +1,37 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +use Psr\Http\Message\ServerRequestFactoryInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\StreamFactoryInterface; + +abstract class Operation +{ + abstract public function getMethod(): string; + + /** + * @var mixed + */ + protected $data; + + /** + * @var string + */ + protected $href; + + final public function __construct(string $href, array $data = null) + { + $this->href = $href; + $this->data = $data; + } + + public function getHref(): string + { + return $this->href; + } + + public function getData() + { + return $this->data; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/OperationParser.php b/lib/classes/JsonApi/Ext/AtomicOperations/OperationParser.php new file mode 100644 index 0000000000000000000000000000000000000000..62baa8b65ed05d0878fc2bfd19fad61e4b085a57 --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/OperationParser.php @@ -0,0 +1,86 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +use Opis\JsonSchema\ISchema; +use Opis\JsonSchema\Loaders\File; +use Opis\JsonSchema\ValidationError; +use Opis\JsonSchema\Validator; + +class OperationParser +{ + public static function create(): OperationParser + { + $loader = new File('', [__DIR__]); + $schema = $loader->loadSchema('/atomic-operations-schema.json'); + return new static($schema); + } + + /** + * @var ISchema + */ + protected $schema; + + /** + * @var Validator + */ + protected $validator; + + /** + * @var array + */ + protected $operations = []; + + private final function __construct(ISchema $schema) + { + $this->schema = $schema; + + $this->validator = new Validator(); + } + + /** + * @param array $input + * @param Callable|null $onError + * @return bool + */ + public function parse(object $input, Callable $onError = null): bool + { + $result = $this->validator->schemaValidation($input, $this->schema); + + if (!$result->isValid()) { + if (is_callable($onError)) { + array_map(function (ValidationError $error) use ($onError) { + foreach ($error->subErrors() as $e) { + if ($e->keyword() !== 'const') { + return $onError($e); + } + } + return $onError($error); + }, $result->getErrors()); + } + return false; + } + + $temp = (array) $input; + + $this->operations = []; + foreach ($temp['atomic:operations'] as $op) { + if ($op->op === 'add') { + $this->operations[] = new AddOperation($op->href, (array) $op->data); + } elseif ($op->op === 'update') { + $this->operations[] = new UpdateOperation($op->href, (array) $op->data); + } elseif ($op->op === 'remove') { + $this->operations[] = new RemoveOperation($op->href); + } + } + + return true; + } + + /** + * @return Operation[] + */ + public function getOperations(): array + { + return $this->operations; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/RemoveOperation.php b/lib/classes/JsonApi/Ext/AtomicOperations/RemoveOperation.php new file mode 100644 index 0000000000000000000000000000000000000000..1769c5e013857737c91dc5b4099d992b7cd15970 --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/RemoveOperation.php @@ -0,0 +1,10 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +final class RemoveOperation extends Operation +{ + public function getMethod(): string + { + return 'DELETE'; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/Route.php b/lib/classes/JsonApi/Ext/AtomicOperations/Route.php new file mode 100644 index 0000000000000000000000000000000000000000..7163f19d57122a510b1e33ff911a4b6a17a65e9b --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/Route.php @@ -0,0 +1,226 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +use GuzzleHttp\Psr7\Utils; +use Neomerx\JsonApi\Contracts\Http\Headers\MediaTypeInterface; +use Neomerx\JsonApi\Exceptions\JsonApiException; +use Neomerx\JsonApi\Schema\Error; +use Neomerx\JsonApi\Schema\ErrorCollection; +use Opis\JsonSchema\ValidationError; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use Slim\App; +use Slim\Psr7\Factory\ServerRequestFactory; +use Slim\Psr7\Factory\StreamFactory; +use Slim\Psr7\Factory\UriFactory; + +/** + * Should only respond to application/vnd.api+json;ext="https://jsonapi.org/ext/atomic" + * Should output application/vnd.api+json;ext="https://jsonapi.org/ext/atomic" + * + * @see https://jsonapi.org/ext/atomic + */ +final class Route extends \JsonApi\NonJsonApiController +{ + const MEDIA_TYPE = 'application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"'; + + /** + * @var App + */ + private $app; + + /** + * @var OperationParser + */ + private $parser; + + /** + * @var StreamFactory + */ + private $stream_factory; + + /** + * @var ServerRequestFactory + */ + private $request_factory; + + public function __construct(ContainerInterface $container) + { + parent::__construct($container); + + $this->app = $this->container->get(App::class); + + $this->parser = OperationParser::create(); + $this->stream_factory = new StreamFactory(); + $this->request_factory = new ServerRequestFactory($this->stream_factory); + } + + public function __invoke(Request $request, Response $response, array $args): Response + { + $json = json_decode(file_get_contents(__DIR__ . '/delete-me-before-merge.json')); + $request = $request + ->withHeader('Content-Type', self::MEDIA_TYPE) + ->withParsedBody($json); + + $errors = new ErrorCollection(); + + $this->parser->parse( + $request->getParsedBody(), + function (ValidationError $error) use (&$errors) { + $errors->add($this->convertError($error)); + } + ); + + if ($errors->count() > 0) { + throw new JsonApiException($errors, JsonApiException::HTTP_CODE_BAD_REQUEST); + } + + $requests = array_map( + function (Operation $operation): Request { + return $this->request_factory->createServerRequest( + $operation->getMethod(), + $operation->getHref() + )->withHeader( + 'Content-Type', + MediaTypeInterface::JSON_API_MEDIA_TYPE + )->withHeader( + 'Accept', + MediaTypeInterface::JSON_API_MEDIA_TYPE + )->withBody( + $this->stream_factory->createStream(json_encode($operation->getData())) + )->withParsedBody($operation->getData()); + }, + $this->parser->getOperations() + ); + + // Start transaction + + try { + $responses = []; + foreach ($requests as $index => $request) { + $factory = new UriFactory(); + $uri = $factory->createUri($this->app->getBasePath() . '/v1/discovery'); + $request = $request->withUri($uri); + $request = $request->withMethod('GET'); + $routeResolver = $this->app->getRouteResolver(); + $routingResult = $routeResolver->computeRoutingResults( + $request->getUri()->getPath(), + $request->getMethod() + ); + $route = $routeResolver->resolveRoute($routingResult->getRouteIdentifier()); + dd($route->getCallable()); + $responses[$index] = $route->handle($request); + } + // Commit transaction + } catch (\Exception $e) { + // Rollback transaction on error + throw new JsonApiException($e, JsonApiException::HTTP_CODE_BAD_REQUEST); + } + + $result = [ + 'atomic:results' => array_map(function (Response $response) { + $body = $response->getBody(); + if ($body->isSeekable()) { + $body->rewind(); + } + return json_decode($body->getContents(), true); + }, $responses), + ]; + + return $response + ->withHeader('Content-Type', self::MEDIA_TYPE) + ->withBody(Utils::streamFor(json_encode($result))); + } + + private function getOperationsFromRequest(Request $request, ErrorCollection $errors): array + { + $json = $this->parseJSON($request->getBody()); + + if (!is_array($json) || array_is_list($json)) { + // $errors + } + + $operations = []; + foreach ($json as $index => $value) { + if (!$this->isOperation($value)) { + $errors->add(new Error( + 'no-operation', + null, null, + null, null, + 'Value is not an operation', + "The item at index {$index} of the input array is not an operation", + ['index' => $index] + )); + } else { + $operations[] = $this->parseOperation($value); + } + } + + if ($errors->count() > 0) { + throw new JsonApiException($errors, JsonApiException::HTTP_CODE_BAD_REQUEST); + } + + return $operations; + } + + /** + * @param string $input + * @return mixed + * @throws \JsonException + */ + private function parseJSON(string $input) + { + $result = json_decode($input, true, 512); + $error = json_last_error(); + if ($error) { + throw new \JsonException(json_last_error_msg(), $error); + } + return $result; + } + + private function isOperation($value): bool + { + return true; + } + + private function parseOperation($value): AtomicOperation + { + return new AtomicOperation(); + } + + private function convertError(ValidationError $error): Error + { + $i = -1; + $where = implode('', array_map(function ($what) use (&$i) { + $i += 1; + return is_numeric($what) ? "[{$what}]" : ($i ? ".{$what}" : $what); + }, $error->dataPointer())); + + return new Error( + 'validation-error', + null, null, + null, null, + $this->getErrorDescription($error), + $where, + ['index' => '?'] + ); + } + + private function getErrorDescription(ValidationError $error): string + { + $what = $error->keyword(); + + if (count($error->keywordArgs()) > 0) { + $what .= ': ' . json_encode($error->keywordArgs()); + } + + if ($error->subErrorsCount() > 0) { + $what .= ' ['; + $what .= implode(', ', array_map([$this, 'getErrorDescription'], $error->subErrors())); + $what .= ']'; + } + + return $what; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/UpdateOperation.php b/lib/classes/JsonApi/Ext/AtomicOperations/UpdateOperation.php new file mode 100644 index 0000000000000000000000000000000000000000..94d233517746c1ceb0842ef5c9ab440b4cd7214f --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/UpdateOperation.php @@ -0,0 +1,10 @@ +<?php +namespace JsonApi\Ext\AtomicOperations; + +final class UpdateOperation extends Operation +{ + public function getMethod(): string + { + return 'PATCH'; + } +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/atomic-operations-schema.json b/lib/classes/JsonApi/Ext/AtomicOperations/atomic-operations-schema.json new file mode 100644 index 0000000000000000000000000000000000000000..9ea97b0067386177bfb1d9363c023e366cdb48bf --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/atomic-operations-schema.json @@ -0,0 +1,64 @@ +{ + "$comment": "https://jsonapi.org/ext/atomic/", + + "type": "object", + "properties": { + "atomic:operations": { + "type": "array", + "items": { + "type": "object", + "anyOf": [ + { + "properties": { + "op": { + "const": "add" + }, + "href": { + "$comment": "This differs from the specification where this property is optional.", + "type": "string", + "pattern": "^\\/" + }, + "data": { + "type": "object" + } + }, + "required": ["op", "href","data"], + "allowAdditionalProperties": false + }, + { + "properties": { + "op": { + "const": "update" + }, + "href": { + "$comment": "This differs from the specification where this property is optional.", + "type": "string", + "pattern": "^\\/" + }, + "data": { + "type": "object" + } + }, + "required": ["op", "href", "data"], + "allowAdditionalProperties": false + }, + { + "properties": { + "op": { + "const": "remove" + }, + "href": { + "$comment": "This differs from the specification where this property is optional.", + "type": "string", + "pattern": "^\\/" + } + }, + "required": ["op", "href"], + "allowAdditionalProperties": false + } + ] + } + } + }, + "additionalProperties": false +} diff --git a/lib/classes/JsonApi/Ext/AtomicOperations/delete-me-before-merge.json b/lib/classes/JsonApi/Ext/AtomicOperations/delete-me-before-merge.json new file mode 100644 index 0000000000000000000000000000000000000000..067381d6893a19f8a647ed032a08e5b3a6c62ec2 --- /dev/null +++ b/lib/classes/JsonApi/Ext/AtomicOperations/delete-me-before-merge.json @@ -0,0 +1,22 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/blogs", + "data": { + "foo": 42 + } + }, + { + "op": "update", + "href": "/blogs/1", + "data": { + "foo": 23 + } + }, + { + "op": "remove", + "href": "/blogs/1" + } + ] +} diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index 0709ef3a75d82e7c760d22c5b2b59e1b6c9bac92..322ecbe0ec744343d306ec72836d6f0eee15e2e3 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -5,8 +5,6 @@ namespace JsonApi; use JsonApi\Contracts\JsonApiPlugin; use JsonApi\Middlewares\Authentication; use JsonApi\Middlewares\DangerousRouteHandler; -use JsonApi\Middlewares\JsonApi as JsonApiMiddleware; -use JsonApi\Middlewares\StudipMockNavigation; use Slim\Routing\RouteCollectorProxy; /** @@ -84,6 +82,9 @@ class RouteMap $group->group('', [$this, 'unauthenticatedRoutes']); $group->get('/discovery', Routes\DiscoveryIndex::class); + $group->post('/operations', Ext\AtomicOperations\Route::class); + // TODO: Remove next, debug only + $group->get('/operations', Ext\AtomicOperations\Route::class); } /** @@ -561,4 +562,3 @@ class RouteMap $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler); } } - diff --git a/tests/_data/jsonapi-atomic-operations/invalid/invalid-operation.json b/tests/_data/jsonapi-atomic-operations/invalid/invalid-operation.json new file mode 100644 index 0000000000000000000000000000000000000000..12bb8a91fe2c0c528adfdeb71776f9a1bfe5a1ae --- /dev/null +++ b/tests/_data/jsonapi-atomic-operations/invalid/invalid-operation.json @@ -0,0 +1,7 @@ +{ + "atomic:operations": [ + { + "op": "invalid" + } + ] +} diff --git a/tests/_data/jsonapi-atomic-operations/invalid/invalid-root.json b/tests/_data/jsonapi-atomic-operations/invalid/invalid-root.json new file mode 100644 index 0000000000000000000000000000000000000000..21cf573ae7b4f964de1b422650190a907c9160b9 --- /dev/null +++ b/tests/_data/jsonapi-atomic-operations/invalid/invalid-root.json @@ -0,0 +1,4 @@ +{ + "atomic:operations": [], + "atomic:results": [] +} diff --git a/tests/_data/jsonapi-atomic-operations/valid/operations-empty.json b/tests/_data/jsonapi-atomic-operations/valid/operations-empty.json new file mode 100644 index 0000000000000000000000000000000000000000..c9b94e9bc7d92c80b144b177a5e513de6c9b879a --- /dev/null +++ b/tests/_data/jsonapi-atomic-operations/valid/operations-empty.json @@ -0,0 +1,3 @@ +{ + "atomic:operations": [] +} diff --git a/tests/_support/_generated/JsonapiTesterActions.php b/tests/_support/_generated/JsonapiTesterActions.php index 9faaeec96dbbe35f2edf4acde9118df8e197d724..722a32c4286adeeb2d277213a8b2a9ecfd7ccfe7 100644 --- a/tests/_support/_generated/JsonapiTesterActions.php +++ b/tests/_support/_generated/JsonapiTesterActions.php @@ -1,4 +1,4 @@ -<?php //[STAMP] bc9d12565d6304b308d375b50b9e8556 +<?php //[STAMP] d9421758f0cb24a6ac9e5ddde03c18a3 namespace _generated; // This class was automatically generated by build task diff --git a/tests/jsonapi/AtomicOperationsParserTest.php b/tests/jsonapi/AtomicOperationsParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..15a5b27c8f8c21633cb1a15a6feab4da6351a154 --- /dev/null +++ b/tests/jsonapi/AtomicOperationsParserTest.php @@ -0,0 +1,68 @@ +<?php +class AtomicOperationsParserTest extends \Codeception\Test\Unit +{ + protected function getErrorCollector() + { + return new class implements Countable { + public $errors = []; + public function __invoke($error) + { + $this->errors[] = $error; + } + + public function count(): int + { + return count($this->errors); + } + }; + } + + /** + * @dataProvider validInputProvider + */ + public function testValidInputs(object $input): void + { + $collector = $this->getErrorCollector(); + + $parser = \JsonApi\Ext\AtomicOperations\OperationParser::create(); + $parser->parse($input, $collector); + + $this->assertCount(0, $collector); + } + + /** + * @dataProvider invalidInputProvider + */ + public function testInvalidInputs(object $input): void + { + $collector = $this->getErrorCollector(); + + $parser = \JsonApi\Ext\AtomicOperations\OperationParser::create(); + $parser->parse($input, $collector); + + $this->assertGreaterThan(0, count($collector)); + } + + public static function validInputProvider(): array + { + return self::loadInputsFromDirectory(TEST_FIXTURES_PATH . '/jsonapi-atomic-operations/valid'); + } + + public static function invalidInputProvider(): array + { + return self::loadInputsFromDirectory(TEST_FIXTURES_PATH . '/jsonapi-atomic-operations/invalid'); + } + + protected static function loadInputsFromDirectory(string $directory): array + { + $result = []; + foreach (glob("{$directory}/*.json") as $filename) { + $result[basename($filename, '.json')] = [ + json_decode(file_get_contents($filename)), + ]; + } + + return $result; + + } +} diff --git a/tests/jsonapi/_bootstrap.php b/tests/jsonapi/_bootstrap.php index d9c5adcef74f6097bcfd246aef501bd59a62da93..383ae0d378126439b49508199981b7fda9ce9433 100644 --- a/tests/jsonapi/_bootstrap.php +++ b/tests/jsonapi/_bootstrap.php @@ -97,4 +97,6 @@ class DB_Seminar extends DB_Sql } } +define('TEST_FIXTURES_PATH', dirname(__DIR__) . '/_data/'); + require_once __DIR__.'/../../composer/autoload.php';