Skip to content

Commit

Permalink
Added JSON schema validation option to templates (#27)
Browse files Browse the repository at this point in the history
* Added JSON schema validation option to templates

* Fixed code style and change made to wrong file
  • Loading branch information
adamquaile committed Mar 6, 2018
1 parent 6a5fc83 commit 14619cc
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 1 deletion.
24 changes: 24 additions & 0 deletions 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"]
}
}
```
10 changes: 10 additions & 0 deletions 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"]
}
}
17 changes: 16 additions & 1 deletion src/AppBundle/Controller/OutboxController.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

}
}

Expand Down
11 changes: 11 additions & 0 deletions src/Outstack/Enveloper/Resolution/MessageResolver.php
Expand Up @@ -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 {
Expand Down
@@ -0,0 +1,19 @@
<?php

namespace Outstack\Enveloper\Resolution;

class ParametersFailedSchemaValidation extends \RuntimeException
{
private $errors;

public function __construct(array $validationErrors)
{
parent::__construct("Parameters failed schema validation");
$this->errors = $validationErrors;
}

public function getErrors(): array
{
return $this->errors;
}
}
7 changes: 7 additions & 0 deletions src/Outstack/Enveloper/Templates/Loader/FilesystemLoader.php
Expand Up @@ -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(
Expand All @@ -45,13 +46,19 @@ 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']}");
}

$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']),
Expand Down
11 changes: 11 additions & 0 deletions src/Outstack/Enveloper/Templates/Template.php
Expand Up @@ -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,
Expand All @@ -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;
}

/**
Expand Down
52 changes: 52 additions & 0 deletions tests/Functional/ErrorHandlingFunctionalTest.php
Expand Up @@ -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");

}
}
Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand Down
Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand Down Expand Up @@ -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 }}')]),
Expand All @@ -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 = <<<SCHEMA
{
"properties": {
"user": {
"type": "object",
"properties": {
"handle": {
"type": "string"
}
}
}
}
}
SCHEMA;


$html = '<!DOCTYPE html><html><body><p>Welcome, {{ user.handle }}.</p></body></html>';

$this->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')
);
}
}
@@ -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
}
}
}
}
}
}

0 comments on commit 14619cc

Please sign in to comment.