Initial commit
This commit is contained in:
766
skills/symfony-skill/references/api-platform.md
Normal file
766
skills/symfony-skill/references/api-platform.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# API Platform & RESTful API Development
|
||||
|
||||
## Installation & Configuration
|
||||
|
||||
### Installing API Platform
|
||||
|
||||
```bash
|
||||
composer require api
|
||||
|
||||
# Or manually
|
||||
composer require symfony/serializer-pack
|
||||
composer require symfony/validator
|
||||
composer require symfony/property-access
|
||||
composer require symfony/property-info
|
||||
composer require doctrine/annotations
|
||||
composer require api-platform/core
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/api_platform.yaml
|
||||
api_platform:
|
||||
title: 'My API'
|
||||
version: '1.0.0'
|
||||
description: 'API documentation'
|
||||
|
||||
# Swagger/OpenAPI configuration
|
||||
swagger:
|
||||
versions: [3]
|
||||
api_keys:
|
||||
apiKey:
|
||||
name: Authorization
|
||||
type: header
|
||||
|
||||
# Collection configuration
|
||||
collection:
|
||||
pagination:
|
||||
enabled: true
|
||||
items_per_page: 30
|
||||
maximum_items_per_page: 100
|
||||
client_items_per_page: true
|
||||
client_enabled: true
|
||||
|
||||
# Format configuration
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
xml: ['application/xml', 'text/xml']
|
||||
csv: ['text/csv']
|
||||
```
|
||||
|
||||
## Creating API Resources
|
||||
|
||||
### Basic Resource
|
||||
|
||||
```php
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ApiResource(
|
||||
description: 'Product resource',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/products',
|
||||
normalizationContext: ['groups' => ['product:list']]
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/products/{id}',
|
||||
normalizationContext: ['groups' => ['product:read']]
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/products',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/products/{id}',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN') or object.owner == user"
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/products/{id}',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN') or object.owner == user"
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/products/{id}',
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)
|
||||
],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
paginationEnabled: true,
|
||||
paginationItemsPerPage: 20
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read', 'product:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['product:read', 'product:list', 'product:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 3, max: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
|
||||
#[Groups(['product:read', 'product:list', 'product:write'])]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Positive]
|
||||
private ?string $price = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[Groups(['product:read'])]
|
||||
public ?User $owner = null;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Operations
|
||||
|
||||
```php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Product;
|
||||
use App\Service\ProductService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
|
||||
#[AsController]
|
||||
class ProductPublishController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private ProductService $productService
|
||||
) {}
|
||||
|
||||
public function __invoke(Product $data): Product
|
||||
{
|
||||
$this->productService->publish($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
// In Product entity
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// ... other operations
|
||||
new Post(
|
||||
uriTemplate: '/products/{id}/publish',
|
||||
controller: ProductPublishController::class,
|
||||
openapiContext: [
|
||||
'summary' => 'Publish a product',
|
||||
'description' => 'Changes the product status to published',
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Product published',
|
||||
],
|
||||
],
|
||||
],
|
||||
read: false,
|
||||
name: 'product_publish'
|
||||
)
|
||||
]
|
||||
)]
|
||||
```
|
||||
|
||||
## Filters & Search
|
||||
|
||||
### Built-in Filters
|
||||
|
||||
```php
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
|
||||
#[ApiResource]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'partial',
|
||||
'description' => 'partial',
|
||||
'category.name' => 'exact',
|
||||
'sku' => 'exact'
|
||||
])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['createdAt'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['active', 'featured'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: [
|
||||
'name' => 'ASC',
|
||||
'price' => 'DESC',
|
||||
'createdAt' => 'DESC'
|
||||
])]
|
||||
class Product
|
||||
{
|
||||
// Entity properties
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
|
||||
```php
|
||||
namespace App\Filter;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class CustomSearchFilter extends AbstractFilter
|
||||
{
|
||||
protected function filterProperty(
|
||||
string $property,
|
||||
$value,
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
if ($property !== 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.name LIKE :search OR %s.description LIKE :search', $alias, $alias))
|
||||
->setParameter('search', '%' . $value . '%');
|
||||
}
|
||||
|
||||
public function getDescription(string $resourceClass): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'property' => null,
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => 'Search in name and description',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Serialization & Validation
|
||||
|
||||
### Serialization Groups
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['read']],
|
||||
denormalizationContext: ['groups' => ['write']],
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['collection']]
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['item', 'read']]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Order
|
||||
{
|
||||
#[Groups(['read', 'collection', 'item'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[Groups(['read', 'collection', 'write'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[Groups(['item', 'write'])]
|
||||
private ?string $customerEmail = null;
|
||||
|
||||
#[Groups(['item'])]
|
||||
private ?array $items = [];
|
||||
|
||||
#[Groups(['read'])]
|
||||
#[SerializedName('total_amount')]
|
||||
private ?float $totalAmount = null;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Normalizer
|
||||
|
||||
```php
|
||||
namespace App\Serializer;
|
||||
|
||||
use App\Entity\Product;
|
||||
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||
|
||||
class ProductNormalizer implements ContextAwareNormalizerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ObjectNormalizer $normalizer
|
||||
) {}
|
||||
|
||||
public function normalize($object, string $format = null, array $context = []): array
|
||||
{
|
||||
$data = $this->normalizer->normalize($object, $format, $context);
|
||||
|
||||
// Add computed fields
|
||||
$data['formatted_price'] = number_format($object->getPrice(), 2, '.', ',') . ' €';
|
||||
$data['availability'] = $object->getStock() > 0 ? 'in_stock' : 'out_of_stock';
|
||||
|
||||
// Add related data conditionally
|
||||
if (in_array('product:detailed', $context['groups'] ?? [])) {
|
||||
$data['reviews_count'] = count($object->getReviews());
|
||||
$data['average_rating'] = $this->calculateAverageRating($object);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null, array $context = []): bool
|
||||
{
|
||||
return $data instanceof Product;
|
||||
}
|
||||
|
||||
private function calculateAverageRating(Product $product): ?float
|
||||
{
|
||||
$reviews = $product->getReviews();
|
||||
|
||||
if (count($reviews) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = array_reduce($reviews->toArray(), function ($sum, $review) {
|
||||
return $sum + $review->getRating();
|
||||
}, 0);
|
||||
|
||||
return round($total / count($reviews), 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```php
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource]
|
||||
class Product
|
||||
{
|
||||
#[Assert\NotBlank(groups: ['create'])]
|
||||
#[Assert\Length(
|
||||
min: 3,
|
||||
max: 255,
|
||||
groups: ['create', 'update']
|
||||
)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[Assert\Positive]
|
||||
#[Assert\LessThan(10000)]
|
||||
private ?float $price = null;
|
||||
|
||||
#[Assert\Email]
|
||||
#[Assert\NotBlank(groups: ['create'])]
|
||||
private ?string $contactEmail = null;
|
||||
|
||||
#[Assert\Valid]
|
||||
private ?Category $category = null;
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validate(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->startDate && $this->endDate && $this->startDate > $this->endDate) {
|
||||
$context->buildViolation('Start date must be before end date')
|
||||
->atPath('startDate')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```php
|
||||
// config/packages/security.yaml
|
||||
security:
|
||||
firewalls:
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
jwt: ~
|
||||
|
||||
access_control:
|
||||
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
// Login endpoint
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
|
||||
public function login(#[CurrentUser] ?User $user): JsonResponse
|
||||
{
|
||||
if (null === $user) {
|
||||
return $this->json(['message' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
$token = $this->jwtManager->create($user);
|
||||
|
||||
return $this->json([
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Security
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('VIEW', object)"
|
||||
),
|
||||
new Put(
|
||||
security: "is_granted('EDIT', object)",
|
||||
securityMessage: "Only the owner can edit this resource"
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
securityPostDenormalize: "is_granted('DELETE', object)"
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Document
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination & Performance
|
||||
|
||||
### Custom Pagination
|
||||
|
||||
```php
|
||||
namespace App\Pagination;
|
||||
|
||||
use ApiPlatform\State\Pagination\PaginatorInterface;
|
||||
|
||||
class CustomPaginator implements PaginatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private iterable $items,
|
||||
private float $currentPage,
|
||||
private float $itemsPerPage,
|
||||
private float $totalItems
|
||||
) {}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->totalItems;
|
||||
}
|
||||
|
||||
public function getLastPage(): float
|
||||
{
|
||||
return ceil($this->totalItems / $this->itemsPerPage);
|
||||
}
|
||||
|
||||
public function getTotalItems(): float
|
||||
{
|
||||
return $this->totalItems;
|
||||
}
|
||||
|
||||
public function getCurrentPage(): float
|
||||
{
|
||||
return $this->currentPage;
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): float
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function getIterator(): \Traversable
|
||||
{
|
||||
return new \ArrayIterator($this->items);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Provider with Optimization
|
||||
|
||||
```php
|
||||
namespace App\DataProvider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class ProductCollectionDataProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
|
||||
{
|
||||
$queryBuilder = $this->entityManager
|
||||
->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'c')
|
||||
->addSelect('c')
|
||||
->leftJoin('p.images', 'i')
|
||||
->addSelect('i');
|
||||
|
||||
// Apply filters
|
||||
if (isset($context['filters']['category'])) {
|
||||
$queryBuilder
|
||||
->andWhere('c.id = :category')
|
||||
->setParameter('category', $context['filters']['category']);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
$page = $context['filters']['page'] ?? 1;
|
||||
$itemsPerPage = $context['filters']['itemsPerPage'] ?? 30;
|
||||
|
||||
$queryBuilder
|
||||
->setFirstResult(($page - 1) * $itemsPerPage)
|
||||
->setMaxResults($itemsPerPage);
|
||||
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subresources
|
||||
|
||||
```php
|
||||
#[ApiResource]
|
||||
class User
|
||||
{
|
||||
#[ORM\OneToMany(targetEntity: Order::class, mappedBy: 'customer')]
|
||||
#[ApiSubresource]
|
||||
private Collection $orders;
|
||||
}
|
||||
|
||||
// Access: GET /api/users/{id}/orders
|
||||
|
||||
// Custom subresource
|
||||
#[ApiResource(
|
||||
uriTemplate: '/users/{id}/orders.{_format}',
|
||||
uriVariables: [
|
||||
'id' => new Link(fromClass: User::class, identifiers: ['id'])
|
||||
],
|
||||
operations: [new GetCollection()]
|
||||
)]
|
||||
class Order
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
|
||||
## GraphQL Support
|
||||
|
||||
```php
|
||||
// Install GraphQL
|
||||
// composer require webonyx/graphql-php
|
||||
|
||||
#[ApiResource(
|
||||
graphQlOperations: [
|
||||
new Query(),
|
||||
new QueryCollection(
|
||||
paginationType: 'page'
|
||||
),
|
||||
new Mutation(
|
||||
name: 'create'
|
||||
),
|
||||
new Mutation(
|
||||
name: 'update'
|
||||
),
|
||||
new DeleteMutation(
|
||||
name: 'delete'
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
|
||||
// Custom GraphQL resolver
|
||||
#[ApiResource]
|
||||
class Order
|
||||
{
|
||||
#[GraphQL\Field]
|
||||
public function totalWithTax(): float
|
||||
{
|
||||
return $this->total * 1.2;
|
||||
}
|
||||
|
||||
#[GraphQL\Query]
|
||||
public static function ordersByStatus(string $status): array
|
||||
{
|
||||
// Custom query logic
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Exception
|
||||
|
||||
```php
|
||||
namespace App\Exception;
|
||||
|
||||
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
class CustomApiException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
private string $detail,
|
||||
private int $statusCode = 400,
|
||||
private ?array $additionalData = null
|
||||
) {
|
||||
parent::__construct($detail, $statusCode);
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function getDetail(): string
|
||||
{
|
||||
return $this->detail;
|
||||
}
|
||||
|
||||
public function getAdditionalData(): ?array
|
||||
{
|
||||
return $this->additionalData;
|
||||
}
|
||||
}
|
||||
|
||||
// Exception normalizer
|
||||
class CustomExceptionNormalizer implements NormalizerInterface
|
||||
{
|
||||
public function normalize($exception, string $format = null, array $context = []): array
|
||||
{
|
||||
$data = [
|
||||
'type' => 'https://tools.ietf.org/html/rfc7231#section-6.5.1',
|
||||
'title' => 'An error occurred',
|
||||
'detail' => $exception->getDetail(),
|
||||
'status' => $exception->getStatusCode(),
|
||||
];
|
||||
|
||||
if ($additionalData = $exception->getAdditionalData()) {
|
||||
$data['additionalData'] = $additionalData;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null): bool
|
||||
{
|
||||
return $data instanceof CustomApiException;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing API
|
||||
|
||||
```php
|
||||
namespace App\Tests\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Entity\Product;
|
||||
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
|
||||
|
||||
class ProductApiTest extends ApiTestCase
|
||||
{
|
||||
use RefreshDatabaseTrait;
|
||||
|
||||
public function testGetCollection(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', '/api/products');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains([
|
||||
'@context' => '/api/contexts/Product',
|
||||
'@id' => '/api/products',
|
||||
'@type' => 'hydra:Collection',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCreateProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Test Product',
|
||||
'price' => 99.99,
|
||||
'description' => 'Test description',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains([
|
||||
'@type' => 'Product',
|
||||
'name' => 'Test Product',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI/Swagger Customization
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
openapiContext: [
|
||||
'tags' => ['Products'],
|
||||
'summary' => 'Product management',
|
||||
'description' => 'Endpoints for managing products',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'X-API-VERSION',
|
||||
'in' => 'header',
|
||||
'required' => false,
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'default' => '1.0',
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user