Skip to content

Commit

Permalink
Add ability to explicitly name class based schemas using `#[SchemaNam…
Browse files Browse the repository at this point in the history
…e]` attribute (#682)

* wip

* wip schema names

* Fix styling

* rethink references collection contexts

* fix tests

* added multiple open api transformers support

* Fix styling

---------

Co-authored-by: romalytvynenko <[email protected]>
  • Loading branch information
romalytvynenko and romalytvynenko authored Jan 15, 2025
1 parent ee384d1 commit 6e0232c
Show file tree
Hide file tree
Showing 36 changed files with 571 additions and 300 deletions.
21 changes: 21 additions & 0 deletions src/Attributes/SchemaName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Dedoc\Scramble\Attributes;

use Attribute;

/**
* Allows naming class based schemas for different contexts.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class SchemaName
{
public function __construct(
public readonly string $name,
/**
* Some classes can be used both as input and output schemas. So this property is used to
* explicitly name the schema when is in input context.
*/
public readonly ?string $input = null,
) {}
}
20 changes: 20 additions & 0 deletions src/ContextReferences.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Dedoc\Scramble;

/** @internal */
class ContextReferences
{
public function __construct(
public readonly ContextReferencesCollection $schemas = new ContextReferencesCollection,
public readonly ContextReferencesCollection $responses = new ContextReferencesCollection,
public readonly ContextReferencesCollection $parameters = new ContextReferencesCollection,
public readonly ContextReferencesCollection $examples = new ContextReferencesCollection,
public readonly ContextReferencesCollection $requestBodies = new ContextReferencesCollection,
public readonly ContextReferencesCollection $headers = new ContextReferencesCollection,
public readonly ContextReferencesCollection $securitySchemes = new ContextReferencesCollection,
public readonly ContextReferencesCollection $links = new ContextReferencesCollection,
public readonly ContextReferencesCollection $callbacks = new ContextReferencesCollection,
public readonly ContextReferencesCollection $pathItems = new ContextReferencesCollection,
) {}
}
79 changes: 79 additions & 0 deletions src/ContextReferencesCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace Dedoc\Scramble;

use Dedoc\Scramble\Support\Generator\Reference;
use Illuminate\Support\Str;

/** @internal */
class ContextReferencesCollection
{
private array $tempNames = [];

/**
* @param array<string, Reference[]> $items The key is reference ID and the value is a list of such references.
*/
public function __construct(
public array $items = [],
) {}

public function has(string $referenceId): bool
{
return array_key_exists($referenceId, $this->items);
}

/**
* @return Reference[]
*/
public function get(string $referenceId): array
{
return $this->items[$referenceId] ?? [];
}

public function add(string $referenceId, Reference $reference): Reference
{
$this->items[$referenceId] ??= [];
$this->items[$referenceId][] = $this->setUniqueName($reference);

return $reference;
}

public function setUniqueName(Reference $reference): Reference
{
$reference->fullName = $reference->shortName ?: $this->uniqueSchemaName($reference->fullName);

return $reference;
}

public function uniqueName(string $referenceId): string
{
if ($this->has($referenceId)) {
$reference = $this->get($referenceId)[0];

return $reference->shortName ?: $reference->fullName;
}

return $this->uniqueSchemaName($referenceId);
}

private function uniqueSchemaName(string $fullName)
{
$shortestPossibleName = class_basename($fullName);

if (
($this->tempNames[$shortestPossibleName] ?? null) === null
|| ($this->tempNames[$shortestPossibleName] ?? null) === $fullName
) {
$this->tempNames[$shortestPossibleName] = $fullName;

return static::slug($shortestPossibleName);
}

return static::slug($fullName);
}

private static function slug(string $name)
{
return Str::replace('\\', '.', $name);
}
}
14 changes: 2 additions & 12 deletions src/Extensions/TypeToSchemaExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Dedoc\Scramble\Extensions;

use Dedoc\Scramble\Infer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Types\Type as OpenApiType;
Expand All @@ -12,18 +13,7 @@

abstract class TypeToSchemaExtension
{
protected Infer $infer;

protected TypeTransformer $openApiTransformer;

protected Components $components;

public function __construct(Infer $infer, TypeTransformer $openApiTransformer, Components $components)
{
$this->infer = $infer;
$this->openApiTransformer = $openApiTransformer;
$this->components = $components;
}
public function __construct(protected Infer $infer, protected TypeTransformer $openApiTransformer, protected Components $components, protected OpenApiContext $openApiContext) {}

/**
* @param Type $type The type being transformed to schema.
Expand Down
27 changes: 19 additions & 8 deletions src/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\Infer\Services\FileParser;
use Dedoc\Scramble\OpenApiVisitor\SchemaEnforceVisitor;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\InfoObject;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Operation;
Expand All @@ -31,7 +32,6 @@ class Generator
protected bool $throwExceptions = true;

public function __construct(
private TypeTransformer $transformer,
private OperationBuilder $operationBuilder,
private ServerFactory $serverFactory,
private FileParser $fileParser,
Expand All @@ -52,11 +52,13 @@ public function __invoke(?GeneratorConfig $config = null)
->afterOpenApiGenerated(Scramble::$openApiExtender);

$openApi = $this->makeOpenApi($config);
$context = new OpenApiContext($openApi, $config);
$typeTransformer = $this->buildTypeTransformer($context);

$this->getRoutes($config)
->map(function (Route $route, int $index) use ($openApi, $config) {
->map(function (Route $route, int $index) use ($openApi, $config, $typeTransformer) {
try {
$operation = $this->routeToOperation($openApi, $route, $config);
$operation = $this->routeToOperation($openApi, $route, $config, $typeTransformer);
$operation->setAttribute('index', $index);

return $operation;
Expand Down Expand Up @@ -92,7 +94,9 @@ public function __invoke(?GeneratorConfig $config = null)
$this->moveSameAlternativeServersToPath($openApi);

if ($afterOpenApiGenerated = $config->afterOpenApiGenerated()) {
$afterOpenApiGenerated($openApi);
foreach ($afterOpenApiGenerated as $openApiTransformer) {
$openApiTransformer($openApi, $context);
}
}

return $openApi->toArray();
Expand All @@ -113,7 +117,7 @@ private function createOperationsSorter(): array
private function makeOpenApi(GeneratorConfig $config)
{
$openApi = OpenApi::make('3.1.0')
->setComponents($this->transformer->getComponents())
->setComponents(new Components)
->setInfo(
InfoObject::make($config->get('ui.title', $default = config('app.name')) ?: $default)
->setVersion($config->get('info.version', '0.0.1'))
Expand Down Expand Up @@ -191,15 +195,22 @@ private function getRoutes(GeneratorConfig $config): Collection
->values();
}

private function routeToOperation(OpenApi $openApi, Route $route, GeneratorConfig $config)
private function buildTypeTransformer(OpenApiContext $context): TypeTransformer
{
return app()->make(TypeTransformer::class, [
'context' => $context,
]);
}

private function routeToOperation(OpenApi $openApi, Route $route, GeneratorConfig $config, TypeTransformer $typeTransformer)
{
$routeInfo = new RouteInfo($route, $this->fileParser, $this->infer);
$routeInfo = new RouteInfo($route, $this->infer, $typeTransformer);

if (! $routeInfo->isClassBased()) {
return null;
}

$operation = $this->operationBuilder->build($routeInfo, $openApi, $config);
$operation = $this->operationBuilder->build($routeInfo, $openApi, $config, $typeTransformer);

$this->ensureSchemaTypes($route, $operation);

Expand Down
8 changes: 5 additions & 3 deletions src/GeneratorConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class GeneratorConfig
public function __construct(
private array $config = [],
private ?Closure $routeResolver = null,
private ?Closure $afterOpenApiGenerated = null,
private array $afterOpenApiGenerated = [],
) {}

public function config(array $config)
Expand Down Expand Up @@ -43,13 +43,15 @@ private function defaultRoutesFilter(Route $route)
&& (! $expectedDomain || $route->getDomain() === $expectedDomain);
}

public function afterOpenApiGenerated(?Closure $afterOpenApiGenerated = null)
public function afterOpenApiGenerated(?callable $afterOpenApiGenerated = null)
{
if (count(func_get_args()) === 0) {
return $this->afterOpenApiGenerated;
}

$this->afterOpenApiGenerated = $afterOpenApiGenerated;
if ($afterOpenApiGenerated) {
$this->afterOpenApiGenerated[] = $afterOpenApiGenerated;
}

return $this;
}
Expand Down
15 changes: 15 additions & 0 deletions src/OpenApiContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Dedoc\Scramble;

use Dedoc\Scramble\Support\Generator\OpenApi;

/** @internal */
class OpenApiContext
{
public function __construct(
public readonly OpenApi $openApi,
public readonly GeneratorConfig $config,
public ContextReferences $references = new ContextReferences,
) {}
}
20 changes: 8 additions & 12 deletions src/ScrambleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use Dedoc\Scramble\Support\ExceptionToResponseExtensions\HttpExceptionToResponseExtension;
use Dedoc\Scramble\Support\ExceptionToResponseExtensions\NotFoundExceptionToResponseExtension;
use Dedoc\Scramble\Support\ExceptionToResponseExtensions\ValidationExceptionToResponseExtension;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\IndexBuilders\IndexBuilder;
use Dedoc\Scramble\Support\InferExtensions\AbortHelpersExceptionInfer;
Expand Down Expand Up @@ -53,6 +52,7 @@
use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResourceResponseTypeToSchema;
use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResponseTypeToSchema;
use Dedoc\Scramble\Support\TypeToSchemaExtensions\VoidTypeToSchema;
use Illuminate\Contracts\Foundation\Application;
use PhpParser\ParserFactory;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
Expand Down Expand Up @@ -116,9 +116,7 @@ public function configurePackage(Package $package): void

new ArrayMergeReturnTypeExtension,

/*
* Keep this extension last, so the trace info is preserved.
*/
/* Keep this extension last, so the trace info is preserved. */
new TypeTraceInfer,
],
array_map(function ($class) {
Expand Down Expand Up @@ -163,7 +161,7 @@ public function configurePackage(Package $package): void

$this->app->singleton(ServerFactory::class);

$this->app->singleton(TypeTransformer::class, function () {
$this->app->bind(TypeTransformer::class, function (Application $application, array $parameters) {
$extensions = array_merge(config('scramble.extensions', []), Scramble::$extensions);

$typesToSchemaExtensions = array_values(array_filter(
Expand All @@ -177,9 +175,9 @@ public function configurePackage(Package $package): void
));

return new TypeTransformer(
app()->make(Infer::class),
new Components,
array_merge([
$parameters['infer'] ?? $application->make(Infer::class),
$parameters['context'],
typeToSchemaExtensions: $parameters['typeToSchemaExtensions'] ?? array_merge([
EnumToSchema::class,
JsonResourceTypeToSchema::class,
ModelToSchema::class,
Expand All @@ -193,7 +191,7 @@ public function configurePackage(Package $package): void
ResourceResponseTypeToSchema::class,
VoidTypeToSchema::class,
], $typesToSchemaExtensions),
array_merge([
exceptionToResponseExtensions: $parameters['exceptionToResponseExtensions'] ?? array_merge([
ValidationExceptionToResponseExtension::class,
AuthorizationExceptionToResponseExtension::class,
AuthenticationExceptionToResponseExtension::class,
Expand All @@ -210,9 +208,7 @@ public function bootingPackage()
$this->package->hasRoute('web');
}

Scramble::registerApi('default', config('scramble'))
->routes(Scramble::$routeResolver)
->afterOpenApiGenerated(Scramble::$openApiExtender);
Scramble::registerApi('default', config('scramble'));

$this->app->booted(function () {
Scramble::getGeneratorConfig('default')
Expand Down
30 changes: 30 additions & 0 deletions src/Support/Generator/ClassBasedReference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Dedoc\Scramble\Support\Generator;

use Dedoc\Scramble\Attributes\SchemaName;
use ReflectionClass;

class ClassBasedReference
{
public static function create(string $referenceType, string $className, Components $components)
{
return new Reference($referenceType, $className, $components, static::getClassBasedName($className));
}

public static function createInput(string $referenceType, string $className, Components $components)
{
return new Reference($referenceType, $className, $components, static::getClassBasedName($className, input: true));
}

private static function getClassBasedName(string $className, bool $input = false): ?string
{
$reflectionClass = new ReflectionClass($className);

$schemaNameAttribute = ($reflectionClass->getAttributes(SchemaName::class)[0] ?? null)?->newInstance();

return $schemaNameAttribute
? ($input && $schemaNameAttribute->input ? $schemaNameAttribute->input : $schemaNameAttribute->name)
: null;
}
}
8 changes: 8 additions & 0 deletions src/Support/Generator/Components.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ public function toArray()
return $result;
}

/**
* @deprecated Use context instead
*/
public function uniqueSchemaName(string $fullName)
{
$shortestPossibleName = class_basename($fullName);
Expand All @@ -103,6 +106,11 @@ public function getSchema(string $schemaName)
return $this->schemas[$schemaName];
}

/**
* @internal
*
* @deprecated
*/
public static function slug(string $name)
{
return Str::replace('\\', '.', $name);
Expand Down
Loading

0 comments on commit 6e0232c

Please sign in to comment.