Skip to content

Commit

Permalink
Merge pull request #1 from carsdotcom/initial-commit
Browse files Browse the repository at this point in the history
Initial Commit
  • Loading branch information
jwadhams authored Jul 6, 2023
2 parents 70b3dbb + f7b2d41 commit 0193c37
Show file tree
Hide file tree
Showing 28 changed files with 9,641 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
node_modules/
npm-debug.log
yarn-error.log
.ackrc
.idea/

# Laravel 4 specific
bootstrap/compiled.php
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
# laravel-json-schema
Use JsonSchema in Laravel apps

## Purpose

This library builds on the _outstanding_ JsonSchema validator [opis/json-schema](https://opis.io/json-schema/)

The entire intent of this library is to make JsonSchema feel like a first class citizen in a Laravel project.

It adds a new config file, `config/json-schema.php` to configure your root directory for self-hosted schema files.

It adds a Facade, `SchemaValidator` that can be used to instantiate the validator with appropriate loaders.

It adds new PhpUnit assertions in `JsonSchemaAssertions` like validating that a mixed item validates for a specific schema.

Most interestingly, it lets you use JsonSchema to validate incoming Requests bodies, and/or validate your own outgoing response bodies, all using JsonSchema schemas that you can then export into OpenAPI documentation.

## Coming Soon

More documentation will be coming soon, including some more projects that build on this, including Guzzle outgoing- and incoming-body validation, and a new kind of Laravel Model that persists to JSON instead of to a relational database.
41 changes: 41 additions & 0 deletions app/Console/Commands/Schemas/Generate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/**
* Generate JsonSchema files automatically for anything in the App\Enums namespace
* that implements the trait GeneratesSchema
*/

namespace Carsdotcom\JsonSchemaValidation\Console\Commands\Schemas;

use Carsdotcom\JsonSchemaValidation\Helpers\FindClasses;
use Carsdotcom\JsonSchemaValidation\Traits\GeneratesSchemaTrait;
use Carsdotcom\JsonSchemaValidation\Traits\VerboseLineTrait;
use Illuminate\Console\Command;

class Generate extends Command
{
use VerboseLineTrait;

/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'schemas:generate';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate Json Schema files';

/**
* Execute the console command.
*/
public function handle()
{
FindClasses::inAppPath('Enums')
->filter(fn($class) => in_array(GeneratesSchemaTrait::class, class_uses_recursive($class), true))
->each(fn($class) => $class::generateSchema());
}
}
24 changes: 24 additions & 0 deletions app/Contracts/CanValidate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* Handy interface for "Does this implement ValidatesWithJsonSchema"
*/

namespace Carsdotcom\JsonSchemaValidation\Contracts;

use Exception;

/**
* Interface CanValidate
* @package Carsdotcom\JsonSchemaValidation\Contracts
*/
interface CanValidate
{
/**
* Does this object pass its own standard for validation?
* @return true
* @throws Exception if the data is invalid. Exact exception is up to the implementation,
* but should implement HasExtendedExceptionData like JsonSchemaValidationException
*/
public function validateOrThrow(string $exceptionMessage = null): bool;
}
22 changes: 22 additions & 0 deletions app/Contracts/HasExtendedExceptionData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/**
* The implementing exception has extra data it would like to apply when being serialized by app/Exceptions/Handler.php
*/

declare(strict_types=1);

namespace Carsdotcom\JsonSchemaValidation\Contracts;

/**
* Class HasExtendedExceptionData
* @package Carsdotcom\JsonSchemaValidation\Contracts
*/
interface HasExtendedExceptionData
{
/**
* Returns an array of extra properties that can be merged onto the json representation being assembled
* @return array
*/
public function getExtendedData(): array;
}
83 changes: 83 additions & 0 deletions app/Exceptions/JsonSchemaValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

/**
* Exception thrown by failing JsonSchema validation.
* Formats errors into the same structure you'd get from
* Laravel ValidationException->validator->errors()
*/

namespace Carsdotcom\JsonSchemaValidation\Exceptions;

use Carsdotcom\JsonSchemaValidation\Contracts\HasExtendedExceptionData;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Errors\ValidationError;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;

/**
* Class JsonSchemaValidationException
* @package Carsdotcom\JsonSchemaValidation\Exceptions
*/
class JsonSchemaValidationException extends RuntimeException implements HasExtendedExceptionData
{
protected ValidationError $error;
protected int $failureHttpStatusCode = Response::HTTP_BAD_REQUEST;

/**
* @param string $message
* @param ValidationError $error
* @param \Throwable|null $previous
* @param int $code
*/
public function __construct(
string $message,
ValidationError $error,
\Throwable $previous = null,
int $code = Response::HTTP_BAD_REQUEST,
) {
$this->error = $error;
$this->failureHttpStatusCode = $code;

parent::__construct(message: $message, code: $code, previous: $previous);
}

/**
* @return array formatted like message bag
*/
public function errors(): array
{
return (new ErrorFormatter())->format($this->error, true, null, [$this, 'formatErrorKey']);
}

/**
* Returns extra properties that will be appended to this exception when rendered by app/Exceptions/Handler.php
* @return array
*/
public function getExtendedData(): array
{
return ['errors' => $this->errors()];
}

/**
* We format our error keys using dot-notation, Opis by default would use Json-pointer
* @param ValidationError $error
* @return string
*/
public static function formatErrorKey(ValidationError $error): string
{
$path = $error->data()->fullPath();
if (!$path) {
return 'root element';
}
return implode('.', $path);
}

/**
* Used in Handler::determineMessageStringAndStatusCode() to determine return http status code
* @return int
*/
public function getStatusCode(): int
{
return $this->failureHttpStatusCode;
}
}
63 changes: 63 additions & 0 deletions app/Helpers/FindClasses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* Helper to find classes.
* This is useful in places where we use folder structure as an organizing convention,
* like email notifications being customized by an Account in App\Notifications\
* or the complete set of analytics events in App\Events\
*/

namespace Carsdotcom\JsonSchemaValidation\Helpers;

use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Finder\Finder;

class FindClasses
{
/**
* Returns all the classes in a subfolder of Laravel's app_path(), recursively
* @param string $subfolder
* @return Collection
*/
public static function inAppPath(string $subfolder): Collection
{
$fullPath = Str::finish(app_path($subfolder), '/');

return collect(
(new Finder())
->in($fullPath)
->files()
->name('*.php'),
)
->map(static function (string $filename) {
// Remove app path from the left, and .php from the right
$className = substr($filename, strlen(app_path()), -4);

// Add App (root of the namespace) and change / to \
return 'App' . str_replace('/', '\\', $className);
})
->filter(static function (string $className) {
return class_exists($className, true) && !(new ReflectionClass($className))->isAbstract();
})
->values(); // Restore 0-index no gaps
}

/**
* Get one class if you know its folder (or any parent folder) and the class name.
* @param string $subfolder
* @param string $name
* @return string|null
*/
public static function byPathAndName(string $subfolder, string $name): ?string
{
return self::inAppPath($subfolder)->first(function ($item) use ($name) {
return Str::endsWith($item, '\\' . $name);
});
}

public static function byPathAndType(string $subfolder, string $implements): Collection
{
return self::inAppPath($subfolder)->filter(fn($item) => is_a($item, $implements, true));
}
}
37 changes: 37 additions & 0 deletions app/Helpers/FriendlyClassName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/**
* Given an object, or a full class name and namespace (e.g. thing::class or ModelNotFoundException->getModel())
* strip off the name space, and turn the camel case into separated words.
*/

declare(strict_types=0);

namespace Carsdotcom\JsonSchemaValidation\Helpers;

class FriendlyClassName
{
public function __invoke(string|object $class): string
{
$class = is_object($class) ? get_class($class) : $class;

if (str_contains($class, '@anonymous')) {
$parent = get_parent_class($class);
if (!$parent || $parent === 'stdClass') {
return 'Anonymous Class';
}
return 'Anonymous Descendent of ' . (new self())($parent);
}

// Get just the class name (strip namespace)
$className = class_basename($class);

// Replace punctuation with spaces (especially _ )
$className = preg_replace('/[^a-z]+/i', ' ', $className);

// Insert spaces in camel case names
$className = preg_replace('/([a-z])([A-Z])/', '$1 $2', $className);

return $className;
}
}
30 changes: 30 additions & 0 deletions app/SchemaValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* Facade for the Schema Validator service
*/

declare(strict_types=1);

namespace Carsdotcom\JsonSchemaValidation;

use Illuminate\Support\Facades\Facade;
use Symfony\Component\HttpFoundation\Response;

/**
* @method static array|object getSchemaContents(string $relativeUri, bool $associative = true)
* @method static void putSchemaContents(string $relativeUri, array|object $schema)
* @method static string registerRawSchema(bool|object|string $schema)
* @method static bool validate(array|object $data, string $schema)
* @method static bool validateOrThrow(string|array|object $data, string $schema, string $exceptionMessage = null, bool $appendValidationDescriptions = false, int $failureHttpStatusCode = Response::HTTP_BAD_REQUEST)
*/
class SchemaValidator extends Facade
{
/**
* @return string
*/
public static function getFacadeAccessor(): string
{
return SchemaValidatorService::class;
}
}
40 changes: 40 additions & 0 deletions app/SchemaValidatorProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Carsdotcom\JsonSchemaValidation;

use Carsdotcom\JsonSchemaValidation\Console\Commands\Schemas\Generate;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;

class SchemaValidatorProvider extends ServiceProvider implements DeferrableProvider
{
public function register()
{
$this->app->singleton(SchemaValidatorService::class, function () {
return new SchemaValidatorService();
});
}

/**
* Get the services provided by the provider. (So Laravel can defer loading until one is requested)
*
* @return array
*/
public function provides()
{
return [SchemaValidatorService::class];
}

public function boot()
{
$this->publishes([
__DIR__ . '/../config/json-schema.php' => config_path('json-schema.php'),
]);

if ($this->app->runningInConsole()) {
$this->commands([
Generate::class,
]);
}
}
}
Loading

0 comments on commit 0193c37

Please sign in to comment.