Skip to content

Commit

Permalink
relations (needs toMany) + skolem + IRI to resource
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Jun 20, 2024
1 parent 71d50c8 commit d09188e
Show file tree
Hide file tree
Showing 23 changed files with 335 additions and 89 deletions.
27 changes: 16 additions & 11 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Laravel;

use ApiPlatform\Action\NotExposedAction;
use ApiPlatform\Documentation\Action\DocumentationAction;
use ApiPlatform\Documentation\Action\EntrypointAction;
use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory;
Expand Down Expand Up @@ -196,21 +197,21 @@ public function register(): void

$this->app->singleton(PropertyMetadataFactoryInterface::class, function (Application $app) {
return new PropertyInfoPropertyMetadataFactory(
$app->make(PropertyInfoExtractorInterface::class)
$app->make(PropertyInfoExtractorInterface::class),
new EloquentPropertyMetadataFactory(
$app->make(ModelMetadata::class)
)
);
});

$this->app->extend(PropertyMetadataFactoryInterface::class, function (PropertyInfoPropertyMetadataFactory $inner, Application $app) {
return new SchemaPropertyMetadataFactory(
$app->make(ResourceClassResolverInterface::class),
new EloquentPropertyMetadataFactory(
$app->make(ModelMetadata::class),
new SerializerPropertyMetadataFactory(
new SerializerClassMetadataFactory($app->make(ClassMetadataFactoryInterface::class)),
$inner,
$app->make(ResourceClassResolverInterface::class)
),
)
new SerializerPropertyMetadataFactory(
new SerializerClassMetadataFactory($app->make(ClassMetadataFactoryInterface::class)),
$inner,
$app->make(ResourceClassResolverInterface::class)
),
);
});

Expand Down Expand Up @@ -354,11 +355,11 @@ public function register(): void

$this->app->bind(IriConverterInterface::class, IriConverter::class);
$this->app->singleton(IriConverter::class, function (Application $app) {
return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class));
});

$this->app->singleton(SkolemIriConverter::class, function (Application $app) {
return new SkolemIriConverter($app->make(Router::class));
return new SkolemIriConverter($app->make(UrlGeneratorRouter::class));
});

$this->app->bind(IdentifiersExtractorInterface::class, IdentifiersExtractor::class);
Expand Down Expand Up @@ -716,6 +717,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
$uriTemplate = $operation->getRoutePrefix().str_replace('{._format}', '{_format?}', $uriTemplate);
$route = new Route([$operation->getMethod()], $uriTemplate, [ApiPlatformController::class, '__invoke']);
$route->name($operation->getName());
$route->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]);
// Another option then to use a middleware, not sure what's best (you then retrieve $request->getRoute() somehow ?)
// $route->??? = ['operation' => $operation];
$routeCollection->add($route)
Expand Down Expand Up @@ -744,6 +746,9 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
});
$route->name('api_entrypoint')->middleware(ApiPlatformMiddleware::class);
$routeCollection->add($route);
$route = new Route(['GET'], $prefix.'/.well-known/genid/{id}', [NotExposedAction::class, '__invoke']);
$route->name('api_genid')->middleware(ApiPlatformMiddleware::class);
$routeCollection->add($route);
$router->setRoutes($routeCollection);
}

Expand Down
17 changes: 17 additions & 0 deletions src/Laravel/Eloquent/State/PersistProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,33 @@

namespace ApiPlatform\Laravel\Eloquent\State;

use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
* @implements ProcessorInterface<\Illuminate\Database\Eloquent\Model, \Illuminate\Database\Eloquent\Model>
*/
class PersistProcessor implements ProcessorInterface
{
public function __construct(private readonly ModelMetadata $modelMetadata)
{
}

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
foreach ($this->modelMetadata->getRelations($data) as $relation) {
if (!isset($data->{$relation['name']})) {
continue;
}

if (BelongsTo::class === $relation['type']) {
$data->{$relation['name']}()->associate($data->{$relation['name']});
unset($data->{$relation['name']});
}
}

$data->saveOrFail();
$data->refresh();

Expand Down
1 change: 0 additions & 1 deletion src/Laravel/Exception/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ public function __construct(
public function register(): void
{
$this->renderable(function (\Throwable $exception, Request $request) {
dd($exception);
$apiOperation = $this->initializeOperation($request);
if (!$apiOperation) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*/
final class EloquentPropertyMetadataFactory implements PropertyMetadataFactoryInterface
{
public function __construct(private ModelMetadata $modelMetadata, private readonly PropertyMetadataFactoryInterface $decorated)
public function __construct(private ModelMetadata $modelMetadata, private readonly ?PropertyMetadataFactoryInterface $decorated = null)
{
}

Expand All @@ -43,15 +43,15 @@ public function create(string $resourceClass, string $property, array $options =
$refl = new \ReflectionClass($resourceClass);
$model = $refl->newInstanceWithoutConstructor();
} catch (\ReflectionException) {
return $this->decorated->create($resourceClass, $property, $options);
return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty();
}

if (!$model instanceof Model) {
return $this->decorated->create($resourceClass, $property, $options);
return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty();
}

try {
$propertyMetadata = $this->decorated->create($resourceClass, $property, $options);
$propertyMetadata = $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty();
} catch (PropertyNotFoundException) {
$propertyMetadata = new ApiProperty();
}
Expand All @@ -78,7 +78,7 @@ public function create(string $resourceClass, string $property, array $options =

$propertyMetadata = $propertyMetadata
->withBuiltinTypes([$type])
->withWritable($propertyMetadata->isWritable() ?? $p['fillable'])
->withWritable($propertyMetadata->isWritable() ?? true)
->withReadable($propertyMetadata->isReadable() ?? false === $p['hidden']);

return $propertyMetadata;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public function create(string $resourceClass, array $options = []): PropertyName
$properties = [];
// When it's an Eloquent model we read attributes from database (@see ShowModelCommand)
foreach ($this->modelMetadata->getAttributes($model) as $property) { // @phpstan-ignore-line
if ($property['hidden']) {
if ('id' !== $property['name'] && $property['hidden']) {
continue;
}

Expand Down
22 changes: 3 additions & 19 deletions src/Laravel/Routing/IriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,8 @@ public function __construct(private readonly ProviderInterface $provider, privat
*/
public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object
{
try {
// TODO: This is not working rn
$parameters = $this->router->match($iri);
} catch (RoutingExceptionInterface $e) {
throw new InvalidArgumentException(sprintf('No route matches "%s".', $iri), $e->getCode(), $e);
}

$parameters['_api_operation_name'] ??= null;

if (!isset($parameters['_api_resource_class'], $parameters['_api_operation_name'])) {
$parameters = $this->router->match($iri);
if (!isset($parameters['_api_resource_class'], $parameters['_api_operation_name'], $parameters['uri_variables'])) {
throw new InvalidArgumentException(sprintf('No resource associated to "%s".', $iri));
}

Expand All @@ -86,16 +78,8 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation
if (!$operation instanceof HttpOperation) {
throw new RuntimeException(sprintf('The iri "%s" does not reference an HTTP operation.', $iri));
}
// $attributes = AttributesExtractor::extractAttributes($parameters);
//
// try {
// $uriVariables = $this->getOperationUriVariables($operation, $parameters, $attributes['resource_class']);
// } catch (InvalidIdentifierException $e) {
// throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
// }

$uriVariables = [];
if ($item = $this->provider->provide($operation, $uriVariables, $context)) {
if ($item = $this->provider->provide($operation, $parameters['uri_variables'], $context)) {
return $item; // @phpstan-ignore-line
}

Expand Down
41 changes: 11 additions & 30 deletions src/Laravel/Routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@

namespace ApiPlatform\Laravel\Routing;

use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use Illuminate\Http\Request as LaravelRequest;
use Illuminate\Routing\Router as BaseRouter;
use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Generator\UrlGenerator;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
Expand Down Expand Up @@ -73,37 +73,18 @@ public function getRouteCollection(): RouteCollection
/**
* {@inheritdoc}
*
* @return array<string, mixed>
* @return array{_api_resource_class: class-string|string, _api_operation_name: string, uri_variables: array<string, mixed>}
*/
public function match(string $pathInfo): array
{
// TODO

return [];
// $baseContext = $this->router->getContext();
// $baseUrl = $baseContext->getBaseUrl();
// if (str_starts_with($pathInfo, $baseUrl)) {
// $pathInfo = substr($pathInfo, \strlen($baseUrl));
// }
//
// $request = Request::create($pathInfo, Request::METHOD_GET, [], [], [], ['HTTP_HOST' => $baseContext->getHost()]);
// try {
// $context = (new RequestContext())->fromRequest($request);
// } catch (RequestExceptionInterface) {
// throw new ResourceNotFoundException('Invalid request context.');
// }
//
// $context->setPathInfo($pathInfo);
// $context->setScheme($baseContext->getScheme());
// $context->setHost($baseContext->getHost());
//
// try {
// $this->router->setContext($context);
//
// return $this->router->match($request->getPathInfo());
// } finally {
// $this->router->setContext($baseContext);
// }
$request = LaravelRequest::create($pathInfo, Request::METHOD_GET);
$route = $this->router->getRoutes()->match($request);

if (!$route) {
throw new InvalidArgumentException(sprintf('No route matches "%s".', $pathInfo));
}

return $route->defaults + ['uri_variables' => array_diff_key($route->parameters, $route->defaults)];
}

/**
Expand Down
14 changes: 4 additions & 10 deletions src/Laravel/Routing/SkolemIriConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use Illuminate\Routing\Router;

/**
* {@inheritdoc}
Expand All @@ -36,11 +35,9 @@ final class SkolemIriConverter implements IriConverterInterface
* @var array<string, string>
*/
private array $classHashMap = [];
// private $router;

public function __construct(/* Router $router */)
public function __construct(private readonly Router $router)
{
// $this->router = $router;
$this->objectHashMap = new \SplObjectStorage();
}

Expand All @@ -59,13 +56,11 @@ public function getIriFromResource(object|string $resource, int $referenceType =
{
$referenceType = $operation ? ($operation->getUrlGenerationStrategy() ?? $referenceType) : $referenceType;
if (($isObject = \is_object($resource)) && $this->objectHashMap->contains($resource)) {
return '';
// return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$resource]], $referenceType);
return $this->router->generate('api_genid', ['id' => $this->objectHashMap[$resource]], $referenceType);
}

if (\is_string($resource) && isset($this->classHashMap[$resource])) {
return '';
// return $this->router->generate('api_genid', ['id' => $this->classHashMap[$resource]], $referenceType);
return $this->router->generate('api_genid', ['id' => $this->classHashMap[$resource]], $referenceType);
}

$id = bin2hex(random_bytes(10));
Expand All @@ -76,7 +71,6 @@ public function getIriFromResource(object|string $resource, int $referenceType =
$this->classHashMap[$resource] = $id;
}

return '';
// return $this->router->generate('api_genid', ['id' => $id], $referenceType);
return $this->router->generate('api_genid', ['id' => $id], $referenceType);
}
}
11 changes: 11 additions & 0 deletions src/Laravel/Test/ApiTestAssertionsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Laravel\Test;

use ApiPlatform\Laravel\Test\Constraint\ArraySubset;
use ApiPlatform\Metadata\IriConverterInterface;

trait ApiTestAssertionsTrait
{
Expand Down Expand Up @@ -60,4 +61,14 @@ public static function assertJsonContains(array|string $subset, array $json, boo

static::assertArraySubset($subset, $json, $checkForObjectIdentity, $message);
}

/**
* Generate the IRI of a resource item.
*/
protected function getIriFromResource(object $resource): ?string
{
$iriConverter = $this->app->make(IriConverterInterface::class);

return $iriConverter->getIriFromResource($resource);
}
}
8 changes: 6 additions & 2 deletions src/Laravel/Tests/JsonApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\Author;
use Workbench\App\Models\Book;

class JsonApiTest extends TestCase
Expand Down Expand Up @@ -54,7 +55,6 @@ public function testGetBook(): void
'id' => '/api/books/1',
'type' => 'Book',
'attributes' => [
'id' => 1,
'name' => $book->name, // @phpstan-ignore-line
],
],
Expand All @@ -63,10 +63,14 @@ public function testGetBook(): void

public function testCreateBook(): void
{
$author = Author::find(1);
$response = $this->postJson(
'/api/books',
[
'data' => ['attributes' => ['name' => 'Don Quichotte']],
'data' => [
'attributes' => ['name' => 'Don Quichotte'],
'relationships' => ['author' => ['data' => ['id' => $this->getIriFromResource($author), 'type' => 'Author']]],
],
],
[
'accept' => 'application/vnd.api+json',
Expand Down
Loading

0 comments on commit d09188e

Please sign in to comment.