diff --git a/docs/examples/05-schema-validation-of-paramters.md b/docs/examples/05-schema-validation-of-paramters.md new file mode 100644 index 0000000..f117fab --- /dev/null +++ b/docs/examples/05-schema-validation-of-paramters.md @@ -0,0 +1,24 @@ +# Validating parameters with JSON schema + +As templates become more and more complex, the required parameters needed to render an email can become large and complex too. +It's easy to omit a parameter, or forget what parameters are needed to be submitted for any given template. A mistake here, can result in +your template language throwing an error, and a 500 response. + +In order to minimise this, you can optionally define a JSON schema which runtime parameters will be validated against. + +So you've got the hello world working, and you want to try a real template. + +Simply define your JSON schema next to your template's meta file, e.g. `hello-world/hello-world.schema.json`: + +```json +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "required": ["email"] + } +} +``` diff --git a/docs/examples/hello-world/hello-world.schema.json b/docs/examples/hello-world/hello-world.schema.json new file mode 100644 index 0000000..ebc7719 --- /dev/null +++ b/docs/examples/hello-world/hello-world.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "required": ["email"] + } +} diff --git a/src/AppBundle/Controller/OutboxController.php b/src/AppBundle/Controller/OutboxController.php index 09c1e90..8de9348 100644 --- a/src/AppBundle/Controller/OutboxController.php +++ b/src/AppBundle/Controller/OutboxController.php @@ -8,6 +8,7 @@ use Outstack\Enveloper\Mail\Participants\Participant; use Outstack\Enveloper\Outbox; use Outstack\Enveloper\PipeprintBridge\Exceptions\PipelineFailed; +use Outstack\Enveloper\Resolution\ParametersFailedSchemaValidation; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -45,7 +46,7 @@ public function postAction(Request $request) $outbox = $this->outbox; $payload = json_decode($request->getContent()); - $dereferencer = \League\JsonReference\Dereferencer::draft4(); + $dereferencer = \League\JsonReference\Dereferencer::draft6(); $schema = $dereferencer->dereference('file://' . $this->container->getParameter('kernel.root_dir'). '/../schemata/outbox_post.json'); $validator = new \League\JsonGuard\Validator($payload, $schema); @@ -74,6 +75,20 @@ function(ValidationError $e) { ->setDetail($e->getMessage()) ->addField('pipeprintError', $e->getErrorData()) ->buildJsonResponse(); + } catch (ParametersFailedSchemaValidation $e) { + return $this->problemFactory + ->createProblem(400, 'Parameters failed JSON schema validation') + ->setDetail('A template was found but the parameters submitted to it do not validate against the configured JSON schema') + ->addField('errors', array_map( + function(ValidationError $e) { + return [ + 'error' => $e->getMessage(), + 'path' => $e->getSchemaPath() + ]; + }, $e->getErrors()) + ) + ->buildJsonResponse(); + } } diff --git a/src/Outstack/Enveloper/Resolution/MessageResolver.php b/src/Outstack/Enveloper/Resolution/MessageResolver.php index dd16cbb..1bf0e21 100644 --- a/src/Outstack/Enveloper/Resolution/MessageResolver.php +++ b/src/Outstack/Enveloper/Resolution/MessageResolver.php @@ -60,6 +60,17 @@ public function __construct( public function resolve(Template $template, object $parameters): Message { + if (!is_null($template->getSchema())) { + $dereferencer = \League\JsonReference\Dereferencer::draft6(); + $schema = $dereferencer->dereference($template->getSchema()); + + $validator = new \League\JsonGuard\Validator($parameters, $schema); + if ($validator->fails()) { + throw new ParametersFailedSchemaValidation($validator->errors()); + } + + } + if (is_null($template->getSender())) { $from = new Participant($this->defaultSenderName, new EmailAddress($this->defaultSenderEmail)); } else { diff --git a/src/Outstack/Enveloper/Resolution/ParametersFailedSchemaValidation.php b/src/Outstack/Enveloper/Resolution/ParametersFailedSchemaValidation.php new file mode 100644 index 0000000..5f24e04 --- /dev/null +++ b/src/Outstack/Enveloper/Resolution/ParametersFailedSchemaValidation.php @@ -0,0 +1,19 @@ +errors = $validationErrors; + } + + public function getErrors(): array + { + return $this->errors; + } +} \ No newline at end of file diff --git a/src/Outstack/Enveloper/Templates/Loader/FilesystemLoader.php b/src/Outstack/Enveloper/Templates/Loader/FilesystemLoader.php index c4a1f39..da285b6 100644 --- a/src/Outstack/Enveloper/Templates/Loader/FilesystemLoader.php +++ b/src/Outstack/Enveloper/Templates/Loader/FilesystemLoader.php @@ -32,6 +32,7 @@ public function __construct(Filesystem $filesystem) public function find(string $name): Template { $configPath = "$name/$name.meta.yml"; + $schemaPath = "$name/$name.schema.json"; try { $config = Yaml::parse( @@ -45,6 +46,11 @@ public function find(string $name): Template $config = $this->normaliseConfig($config); + $schema = null; + if ($this->filesystem->has($schemaPath)) { + $schema = json_decode($this->filesystem->read($schemaPath)); + } + $textTemplate = null; if (!is_null($config['content']['text'])) { $textTemplate = $this->filesystem->read("$name/{$config['content']['text']}"); @@ -52,6 +58,7 @@ public function find(string $name): Template $htmlTemplate = $this->filesystem->read("$name/{$config['content']['html']}"); return new Template( + $schema, $config['subject'], array_key_exists('from', $config) ? $this->parseRecipientTemplate($config['from']) : null, $this->parseRecipientListTemplate($config['recipients']['to']), diff --git a/src/Outstack/Enveloper/Templates/Template.php b/src/Outstack/Enveloper/Templates/Template.php index 449e3f9..6bc7135 100644 --- a/src/Outstack/Enveloper/Templates/Template.php +++ b/src/Outstack/Enveloper/Templates/Template.php @@ -45,8 +45,13 @@ class Template * @var string */ private $htmlTemplateName; + /** + * @var object + */ + private $schema; public function __construct( + ?object $schema, string $subject, ?ParticipantTemplate $sender, ParticipantListTemplate $recipientsTo, @@ -68,6 +73,12 @@ public function __construct( $this->htmlTemplateName = $htmlTemplateName; $this->sender = $sender; $this->attachments = $attachments; + $this->schema = $schema; + } + + public function getSchema(): ?object + { + return $this->schema; } /** diff --git a/tests/Functional/ErrorHandlingFunctionalTest.php b/tests/Functional/ErrorHandlingFunctionalTest.php index eff2185..39af7e9 100644 --- a/tests/Functional/ErrorHandlingFunctionalTest.php +++ b/tests/Functional/ErrorHandlingFunctionalTest.php @@ -120,4 +120,56 @@ public function test_syntax_error_is_nicely_formatted() throw new \LogicException("Expected HttpException, none caught"); } + + public function test_parameters_sent_to_template_are_validated_by_schema() + { + $convertToStream = function($str) { + $stream = fopen("php://temp", 'r+'); + fputs($stream, $str); + rewind($stream); + return $stream; + }; + + $request = new Request( + '/outbox', + 'POST', + $convertToStream(json_encode([ + 'template' => 'message-with-attachments', + 'parameters' => [ + 'email' => 'bob@example.com', + 'attachments' => [ + ['contents' => 'This is a note', 'filename' => ''] + ] + ] + ])) + ); + + try { + $this->client->sendRequest($request); + } catch (HttpException $e) { + + $response = $e->getResponse(); + $body = (string) $response->getBody(); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertJson($body); + $this->assertEquals([ + 'title' => 'Parameters failed JSON schema validation', + 'detail' => 'A template was found but the parameters submitted to it do not validate against the configured JSON schema', + 'status' => 400, + 'errors' => [ + [ + 'error' => 'The string must be at least 1 characters long.', + 'path' => '/properties/attachments/items/0/properties/filename/minLength' + ] + ] + ], json_decode($body, true)); + $this->assertEquals('application/problem+json', $response->getHeaderLine('Content-type')); + + return; + } + + throw new \LogicException("Expected HttpException, none caught"); + + } } \ No newline at end of file diff --git a/tests/Unit/Outstack/Enveloper/Resolution/MessageResolverTest.php b/tests/Unit/Outstack/Enveloper/Resolution/MessageResolverTest.php index f7c6925..427cccb 100644 --- a/tests/Unit/Outstack/Enveloper/Resolution/MessageResolverTest.php +++ b/tests/Unit/Outstack/Enveloper/Resolution/MessageResolverTest.php @@ -49,6 +49,7 @@ public function test_it_resolves_simplest_message() $message = $this->sut->resolve( new Template( + null, 'Welcome, {{ user.name }}', new ParticipantTemplate(null, 'noreply@example.com'), new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -85,6 +86,7 @@ public function test_it_resolves_message_with_attachments() $message = $this->sut->resolve( new Template( + null, 'Welcome, {{ user.name }}', new ParticipantTemplate(null, 'noreply@example.com'), new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -126,6 +128,7 @@ public function test_it_uses_default_sender_email_if_blank() $message = $this->sut->resolve( new Template( + null, 'Welcome, {{ user.name }}', null, new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -163,6 +166,7 @@ public function test_it_resolves_message_with_multiple_templated_recipients() $message = $this->sut->resolve( new Template( + null, 'Welcome, {{ user.name }}', new ParticipantTemplate(null, 'noreply@example.com'), new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), diff --git a/tests/Unit/Outstack/Enveloper/Templates/Loader/FilesystemLoaderTest.php b/tests/Unit/Outstack/Enveloper/Templates/Loader/FilesystemLoaderTest.php index bbc18df..e67c6f1 100644 --- a/tests/Unit/Outstack/Enveloper/Templates/Loader/FilesystemLoaderTest.php +++ b/tests/Unit/Outstack/Enveloper/Templates/Loader/FilesystemLoaderTest.php @@ -71,6 +71,7 @@ public function test_finds_simplest_possible_template() $this->assertEquals( new Template( + null, 'Welcome, {{ user.handle }}', null, new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -110,6 +111,7 @@ public function test_finds_template_with_attachment() $this->assertEquals( new Template( + null, 'Welcome, {{ user.handle }}', null, new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -152,6 +154,7 @@ public function test_finds_template_with_attachment_iterator() $this->assertEquals( new Template( + null, 'Welcome, {{ user.handle }}', null, new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -196,6 +199,7 @@ public function test_recipients_can_be_array_of_templated_name_and_email() $this->assertEquals( new Template( + null, 'Welcome, {{ user.handle }}', new ParticipantTemplate(null, 'noreply@example.com'), new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), @@ -216,4 +220,71 @@ public function test_recipients_can_be_array_of_templated_name_and_email() $this->sut->find('new-user-welcome') ); } + + public function test_schema_is_loaded() + { + $meta = Yaml::dump([ + 'subject' => 'Welcome, {{ user.handle }}', + 'from' => 'noreply@example.com', + 'recipients' => [ + 'to' => [ + '{{ user.email }}' + ] + ], + 'content' => [ + 'html' => 'new-user-welcome.html.twig' + ] + ]); + + $schema = <<filesystem->write("new-user-welcome/new-user-welcome.html.twig", $html); + $this->filesystem->write("new-user-welcome/new-user-welcome.meta.yml", $meta); + $this->filesystem->write("new-user-welcome/new-user-welcome.schema.json", $schema); + + + $this->assertEquals( + new Template( + (object) [ + 'properties' => (object) [ + 'user' => (object) [ + 'type' => 'object', + 'properties' => (object) [ + 'handle' => (object) [ + 'type' => 'string' + ] + ] + ] + ] + ], + 'Welcome, {{ user.handle }}', + new ParticipantTemplate(null, 'noreply@example.com'), + new ParticipantListTemplate([new ParticipantTemplate(null, '{{ user.email }}')]), + new ParticipantListTemplate([]), + new ParticipantListTemplate([]), + null, + null, + 'new-user-welcome.html.twig', + $html, + new AttachmentListTemplate([]) + ), + $this->sut->find('new-user-welcome') + ); + } } \ No newline at end of file diff --git a/tests/data/templates/message-with-attachments/message-with-attachments.schema.json b/tests/data/templates/message-with-attachments/message-with-attachments.schema.json new file mode 100644 index 0000000..d428925 --- /dev/null +++ b/tests/data/templates/message-with-attachments/message-with-attachments.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contents": { + "type": "string", + "minLength": 1 + }, + "filename": { + "type": "string", + "minLength": 1 + } + } + } + } + } +} \ No newline at end of file