Skip to main content

API Development

Introduction

“Resource” is the basic concept of API Platform; it is something that is available via the API. You can mark any php object as a resource. The marking can be done via annotations or via configuration files. Our choice is via annotations; for example:

use ApiPlatform\Core\Annotation as ApiPlatform;

/**
* @ApiPlatform\ApiResource()
*/
class Product
{
protected int $id;
protected string $name;
}

Here we have marked a simple object (POPO). A positive side to this object is that it is simple (scalar fields, no behavior) and API Platform can easily serialize/deserialize it (https://api-platform.com/docs/core/serialization/#the-serialization-process), so it can be sent or received over the API, for example, in json format. There are, however, two problems:

  1. API Platform does not know where to get the data to be sent (GET item/collection) and does not know what to do with data when it is received (POST / PUT / DELETE).
  2. In the real world (meaning to say, in X-Cart) objects are a little bit more complex than in the example above.

To overcome the first problem, API Platform uses a data provider (further will be referred to simply as “provider”) and a data persister ("persister"). For our object from the example above we will need to write our own provider and persister. Usually, we do not want to do that. Instead, we want to use what API Platform provides out of the box, specifically Doctrine ORM’s data provider and persister. So that it all works automatically, we need to mark Doctrine Entities as resources:

use ApiPlatform\Core\Annotation as ApiPlatform;
use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Table (name="products")
* @ApiPlatform\ApiResource()
*/
class Product
{
/**
* @ORM\Id
* @ORM\GeneratedValue (strategy="AUTO")
* @ORM\Column (type="integer", options={ "unsigned": true })
*/
protected int $id;

/**
* @ORM\Column (type="string", length=255)
*/
protected string $name;
}

The object is still simple, and everything should work. So, at this stage, we have a fully functional /api/products endpoint with the following operations: get a specific product (GET /api/products/{id}); get a list of products (GET /api/products) with functional pagination; create a product (POST /api/products); replace a product (PUT /api/products/{id}); delete a product (DELETE /api/products/{id}).

But usually (in the case of X-Cart at this time - always) we come to the second problem. If we mark an actual model of a product in X-Cart that way, we will get an error. API Platform will attempt to scan through the model, to recursively go through the links and the behavior, and will ultimately fail. In addition to that we still want to have control of which fields specifically to set via the API. For that purpose we could combine model fields into serialization/deserialization groups, but we use a different approach: Input/Output DTO (https://api-platform.com/docs/core/dto/).

Input/Output DTO + transformers

So the idea is to go back to simple objects like in the initial example, to describe input and output explicitly and then to transform those objects to/from a model object.

In the example with a product, we have two classes:

class Input
{
public string $name;
}
class Output
{
public int $id;

public string $name;
}

We also have two transformers (more information on this matter will be provided further below). In the product class, we will replace the resource description to the one below:

/**
* @ApiPlatform\ApiResource(
* input=Input::class,
* output=Output::class
* )
*/
class Product
{
...

Thus, the simplified scheme of how API works - based on the example of creating a product - will be as follows:

  • At the /api/products endpoint, we receive a POST request in json format with body {”name”: “Example product”}
  • API Platform deserializes it into an Input object.
  • Our input transformer transforms it into a Product object.
  • The default Doctrine data persister saves the model to the database.
  • Our output transformer transforms the Product object into an Output object.
  • Platform API serializes the Output object and returns json {"id": "{new id}", "name": "Example product"}

Key points:

  • Use annotations to mark up resources.
  • Use Doctrine models as resources + input/output dto for them + transformers.
  • Avoid custom data providers/persisters

Details

Directory structure

XLite/API/
Resource/
[Object].php # virtual model
Endpoint/
[Object]/
DTO/
Input.php
Output.php
Transformer/ # If Resource is present: [DTO] <=> Resource, otherwise DTO <=> Entity
InputTransformer.php # InputDTO => Resource (InputDTO => Entity)
OutputTransformer.php # Resource => OutputDTO (Entity => OutputDTO)
ResourceTransformer/ # Resource <=> Entity
InputTransformer.php # Resource => Entity (use the interface ApiPlatform\Core\DataTransformer\DataTransformerInterface)
OutputTransformer.php # Entity => Resource (use the interface ApiPlatform\Core\DataTransformer\DataTransformerInterface)
Extension/
[SomeSpecific]Extension.php # requires the mechanism of supports, etc.
Filter/
[SomeSpecific]Filter.php
DataProvider.php
DataPersister.php
Extension/
[SomeCommon]Extension.php
Filter/
[SomeCommon]Filter.php

Some important considerations:

  • The structure needs to be reproduced in modules.
  • Resource directory and virtual model should be used if for some reason we need to mark as a resource not a Doctrine Entity, but a simple object (DTO). In this case, we use an additional pair of transformers in ResourceTransformer that transform a Doctrine model into a virtual DTO and a virtual DTO into a Doctrine model (the regular ones transform an input/output DTO into a virtual DTO and a virtual DTO into an input/output DTO). In general, using this approach is highly discouraged: you need to be dealing with a very awry model to have reasons not to mark the model with the ApiResource annotation directly; in the absolute majority of cases you can do it in a different way.
  • It is best to avoid using DataProvider.php and DataPersister.php.
  • Important: The names of DTO classes in API/Endpoint/[Object]/DTO/ need to be unique if they are used as "links” in other DTOs. For example, an output DTO of a product looks as follows:
    class Ouput
    {
    public int $id;

    public string $sku;

    /**
    * @var MembershipOutput[]
    */
    public array $memberships;
    }
    If membership is also a resource (for a product it represents a directory), then its output DTO needs to be named MembershipOutput, not just Output. Otherwise if we have more than one such directory our documentation will be a total mess, although everything will be working as expected.

Transformers

Here is an example of an input transformer:

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;

class InputTransformer implements DataTransformerInterface
{
/**
* @param InputDTO $object
*/
public function transform($object, string $to, array $context = []): Product
{
/**
* API Platform will find our product automatically if we make a request
* to the /api/products/{id} endpoint, or will respond with a
* not found error.
* This allows us to distinguish between creating a new product and
* updating an already existing one.
*/
$model = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new Product();

$model->setName($object->name);

return $model;
}

/**
* Api Platform does not know which transformer needs to be used to
* transform a specific object; for that reason it calls on them one by one
* until it finds one that will be good for the purpose. It is in this method
* that we specify what the transformer transforms and what it transforms
* it into.
*/
public function supportsTransformation($data, string $to, array $context = []): bool
{
if ($data instanceof Product) {
return false;
}

return $to === Product::class && ($context['input']['class'] ?? null) === InputDTO::class;
}
}

Transformers need to be registered as Symfony! services.

services:
_defaults:
autowire: true
autoconfigure: true

XLite\API\Endpoint\Product\Transformer\InputTransformerInterface: '@XLite\API\Endpoint\Product\Transformer\InputTransformer'
XLite\API\Endpoint\Product\Transformer\InputTransformer: ~
XLite\API\Endpoint\Product\Transformer\OutputTransformerInterface: '@XLite\API\Endpoint\Product\Transformer\OutputTransformer'
XLite\API\Endpoint\Product\Transformer\OutputTransformer: ~

For core entities we need to create a separate config file in config/services/api; transformers of module entities need to be registered in the module's own service.yaml config file (the module needs to be a bundle). Using an interface for a transformer is good practice. Accordingly, we need to add it to implements:

class InputTransformer implements DataTransformerInterface, InputTransformerInterface
{
...

!!! Failing to register a transformer or describing the logics incorrectly in supportsTransformation is a frequent source of unknown errors.

Regarding PUT and partial update. Let us assume that input DTO of a product now contains two fields:

class Input
{
public string $sku;

public string $name;
}

and we want to update just the name of the product; for example (pseudocode):

PUT /api/products/35 --body {"name": "Test"}

As a result, we will get an error in the input transformer in the vicinity of the line $model->setSku($object->sku);

The thing is that canonical PUT does not update a product but replaces it and awaits input object as a whole. To fix that (and to get a PATCH) we need to modify the input transformer:

use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface;

// implementing a different interface!
class InputTransformer implements DataTransformerInitializerInterface
{
// transform and supportsTransformation are the same
...

// Adding a new method
/**
* @return InputDTO
*/
public function initialize(string $inputClass, array $context = [])
{
/** @var Product $product */
$product = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null;

if (!$product) {
return new InputDTO();
}

$input = new InputDTO();
$input->sku = $product->getSku();
$input->name = $product->getName();

return $input;
}
}

Now the partial update is going to work. For details, see https://api-platform.com/docs/core/dto/#initialize-the-input-dto-for-partial-update

Validation

Works out of the box; the rules need to be described in input DTOs:

use ApiPlatform\Core\Annotation\ApiProperty;
use Symfony\Component\Validator\Constraints as Assert;

class Input
{
/**
* @Assert\NotBlank()
* @Assert\Length(min=1, max=32)
* @ApiProperty(
* attributes={
* "openapi_context"={"example"="0001"}
* }
* )
* @var string
*/
public string $sku = '';

/**
* @Assert\NotBlank()
* @Assert\Length(min=1, max=255)
* @ApiProperty(
* attributes={
* "openapi_context"={"example"="Product name"}
* }
* )
* @var string
*/
public string $name = '';

...
}

You can (and should) create your own constraints (for details, see https://api-platform.com/docs/core/validation/#validating-submitted-data). For example, you can check whether a membership exists based on the name/id when creating a product. The closer your constraints are to the real ones (the stricter they are), the better. Implementing checks for uniqueness/dependencies/required/format is a must.

Documentation

In the end, based on our API description, a scheme is generated; this scheme can be viewed by accessing your store via a URL ending with /api (The scheme can be downloaded there as well). To make it more or less usable and understandable, you should describe the context as much as possible both in the DTO fields (openapi_context in the example above) and in the ApiResource annotations. Here is an example from XLite\Model\Image\Product\Image:

/**
* Product image
*
* @ORM\Entity
* @ORM\Table (name="product_images")
* @ApiPlatform\ApiResource(
* shortName="ProductImage",
* input=Input::class,
* output=Output::class,
* itemOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Retrieves an image from a product",
* "description"="Retrieves an image from a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "put"={
* "method"="PUT",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Updates product image's properties",
* "description"="Updates product image's properties",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "delete"={
* "method"="DELETE",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Removes an image from a product",
* "description"="Removes an image from a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* },
* collectionOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images",
* "requirements"={"product_id"="\d+"},
* "openapi_context"={
* "summary"="Retrieves the collection of images belonging to a product",
* "description"="Retrieves the collection of images belonging to a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "post"={
* "method"="POST",
* "path"="/products/{product_id}/images",
* "requirements"={"product_id"="\d+"},
* "openapi_context"={
* "summary"="Adds an image to a product",
* "description"="Adds an image to a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* "requestBody"={
* "content"={
* "application/json"={
* "schema"={
* "type"="object",
* "properties"={
* "position"={
* "type"="integer"
* },
* "alt"={
* "type"="string",
* "description"="Alt text"
* },
* "externalUrl"={
* "type"="string",
* "description"="URL to the image file which will be downloaded from there"
* },
* "attachment"={
* "type"="string",
* "description"="base64-encoded image"
* },
* "filename"={
* "type"="string",
* "description"="Image name with correct extension. Required for 'attachement' field."
* },
* },
* },
* },
* },
* },
* },
* },
* }
* )
*/
class Image extends \XLite\Model\Base\Image
{
...

Example: Adding support for product images

Let us begin by declaring a new resource:

namespace XLite\Model\Image\Product;

use ApiPlatform\Core\Annotation as ApiPlatform;

/**
* @ApiPlatform\ApiResource(
* shortName="ProductImage",
* input=Input::class,
* output=Output::class
* )
*/
class Image extends \XLite\Model\Base\Image
{
...

We add typical input / output DTO with transformers (omitted for brevity). Looks like that is all. Correct? Not quite. It will work, but the problem is that our image endpoints now look like this:

GET/POST /api/product_images

GET/PUT/DELETE /api/product_images/{id}

Such endpoints can be used, but it is much more interesting and overall preferable to work with images in the context of a product. That is, we want to have endpoints that look like these: GET/POST /api/products/{product_id}/images

GET/PUT/DELETE /api/products/{product_id}/images/{image_id}

OK, let's put it this way:

namespace XLite\Model\Image\Product;

use ApiPlatform\Core\Annotation as ApiPlatform;

/**
* @ApiPlatform\ApiResource(
* shortName="ProductImage",
* input=Input::class,
* output=Output::class,
* itemOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images/{image_id}",
* },
* "put"={
* "method"="PUT",
* "path"="/products/{product_id}/images/{image_id}",
* },
* "delete"={
* "method"="DELETE",
* "path"="/products/{product_id}/images/{image_id}",
* },
* },
* collectionOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images",
* },
* "post"={
* "method"="POST",
* "path"="/products/{product_id}/images",
* }
* }
* )
*/
class Image extends \XLite\Model\Base\Image
{

We just described itemOperations and collectionOperations with custom paths. None of the operations is now working. Let us sort it out in order.

GET /api/products/35/images instead of returning only the images of product #35 returns all the images. That is, under the hood, Api Platform recognizes that we want to get a collection of images, but does not take into account our product_id. Therefore, we need to wedge into the QueryBuilder of the Doctrine data provider and add a condition based on product_id. This is done using so-called extensions (for more details, see https://api-platform.com/docs/core/extensions/#extensions):

namespace XLite\API\Endpoint\ProductImage\SubExtension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use XLite\API\Extension\ItemSubExtension\ItemSubExtensionInterface;
use XLite\API\Extension\CollectionSubExtension\CollectionSubExtensionInterface;
use XLite\Model\Image\Product\Image;
use XLite\Model\Product;

class SubExtension implements ItemSubExtensionInterface, CollectionSubExtensionInterface
{
protected EntityManagerInterface $entityManager;

public function __construct(
EntityManagerInterface $entityManager
) {
$this->entityManager = $entityManager;
}

/**
* @var string[]
*/
protected array $operationNames = ['get', 'put', 'delete', 'post'];

public function support(string $className, string $operationName): bool
{
return $className === Image::class && in_array($operationName, $this->operationNames, true);
}

public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = []
): void {
$productId = $this->getProductId($context);
if (!$productId) {
throw new InvalidArgumentException('Cannot detect product ID');
}

$product = $this->entityManager->getRepository(Product::class)->find($productId);
if (!$product) {
throw new InvalidArgumentException("Cannot find product with #$productId");
}

$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.product = :product', $rootAlias))
->setParameter('product', $product);
}

public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
string $operationName = null,
array $context = []
): void {
$imageId = $this->getImageId($context);
if (!$imageId) {
throw new InvalidArgumentException('Cannot detect image ID');
}

$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->where(sprintf('%s.id = :image_id', $rootAlias))
->setParameters(['image_id' => $imageId]);
}

protected function getImageId(array $context): ?int
{
if (preg_match('/products\/\d+\/images\/(\d+)/Ss', $context['request_uri'], $match)) {
return (int) $match[1];
}

return null;
}

protected function getProductId(array $context): ?int
{
if (preg_match('/products\/(\d+)\/images/Ss', $context['request_uri'], $match)) {
return (int) $match[1];
}

return null;
}
}

Let us register the service:

...

XLite\API\Endpoint\ProductImage\SubExtension\SubExtension:
tags: [!php/const XLite\API\Extension\ItemSubExtension\ItemSubExtensionInterface::ITEM_SUB_EXTENSION_TAG, !php/const XLite\API\Extension\CollectionSubExtension\CollectionSubExtensionInterface::COLLECTION_SUB_EXTENSION_TAG]

...

The tags in service registration are basically some magic by @max; it is needed so (sub)extensions can be registered in all the right places without the need to do it manually. GET /api/products/35/images is now working correctly.

In theory, GET /api/products/35/images/42 should now be working as well as above we have also described the conditions for item operations (applyToItem method). Let us check it. Unfortunately, we get an error: “Invalid identifier value or configuration.” In short, the situation is as follows: when we work with item operations, Api Platform expects from us the ID(s) of that same item. In this case, the ID of the product image model is “id”, and that is what Api Platform is waiting for by default. Instead, we are providing product_id and image_id. Let us specify them explicitly:


/**
* @ApiPlatform\ApiResource(
* shortName="ProductImage",
* input=Input::class,
* output=Output::class,
* itemOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},**
* },
* "put"={
* "method"="PUT",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},**
* },
* "delete"={
* "method"="DELETE",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* },
* },
* collectionOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images",
* },
* "post"={
* "method"="POST",
* "path"="/products/{product_id}/images",
* }
* }
* )
*/
class Image extends \XLite\Model\Base\Image
{
...

Now everything works, except for POST /api/products/35/images, and the image is perfectly created and added to the product, but the response is: “Unable to generate an IRI for XLite\Model\Image\Product\Image” with status code 400. The bottom line is that we have created an Image object, and Api Platform is not able to generate an IRI for this object as /products/35/images/71. At the same time, if we had a standard get (/product_images/71), Api Platform would have coped. We can help Api Platform as follows:

namespace XLite\API\Endpoint\ProductImage\SubIriConverter;

use Symfony\Component\Routing\RouterInterface;
use XCart\Framework\ApiPlatform\Core\Bridge\Symfony\Routing\SubIriConverter\SubIriFromItemConverterInterface;
use XLite\Model\Image\Product\Image;

class SubIriConverter implements SubIriFromItemConverterInterface
{
protected RouterInterface $router;

public function __construct(RouterInterface $router)
{
$this->router = $router;
}

public function supportIriFromItem(object $item, int $referenceType): bool
{
return $item instanceof Image;
}

/**
* @param Image $item
*/
public function getIriFromItem(object $item, int $referenceType): string
{
return $this->router->generate(
'api_product_images_get_item',
[
'product_id' => $item->getProduct()->getProductId(),
'image_id' => $item->getId(),
],
$referenceType
);
}
}

Don't forget to register the service:

...

XLite\API\Endpoint\ProductImage\SubIriConverter\SubIriConverter:
tags: [ !php/const XCart\Framework\ApiPlatform\Core\Bridge\Symfony\Routing\SubIriConverter\SubIriFromItemConverterInterface::SUB_IRI_FROM_ITEM_CONVERTER_TAG ]

...

Now everything is working as expected.

A few finishing touches. Let's see what the documentation looks like; for example, the operation of creating an image:

Creates a ProductImage resource

First, we can see that the required product_id parameter is missing from the description. Second, the information for the scheme could be a little more detailed. Let us put things in order.

/**
* Product image
*
* @ORM\Entity
* @ORM\Table (name="product_images")
* @ApiPlatform\ApiResource(
* shortName="ProductImage",
* input=Input::class,
* output=Output::class,
* itemOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Retrieves an image from a product",
* "description"="Retrieves an image from a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "put"={
* "method"="PUT",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Updates product image's properties",
* "description"="Updates product image's properties",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "delete"={
* "method"="DELETE",
* "path"="/products/{product_id}/images/{image_id}",
* "identifiers"={"product_id", "image_id"},
* "requirements"={"product_id"="\d+", "image_id"="\d+"},
* "openapi_context"={
* "summary"="Removes an image from a product",
* "description"="Removes an image from a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* {"name"="image_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* },
* collectionOperations={
* "get"={
* "method"="GET",
* "path"="/products/{product_id}/images",
* "requirements"={"product_id"="\d+"},
* "openapi_context"={
* "summary"="Retrieves the collection of images belonging to a product",
* "description"="Retrieves the collection of images belonging to a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* },
* },
* "post"={
* "method"="POST",
* "path"="/products/{product_id}/images",
* "requirements"={"product_id"="\d+"},
* "openapi_context"={
* "summary"="Adds an image to a product",
* "description"="Adds an image to a product",
* "parameters"={
* {"name"="product_id", "in"="path", "required"=true, "schema"={"type"="integer"}},
* },
* "requestBody"={
* "content"={
* "application/json"={
* "schema"={
* "type"="object",
* "properties"={
* "position"={
* "type"="integer"
* },
* "alt"={
* "type"="string",
* "description"="Alt text"
* },
* "externalUrl"={
* "type"="string",
* "description"="URL to the image file which will be downloaded from there"
* },
* "attachment"={
* "type"="string",
* "description"="base64-encoded image"
* },
* "filename"={
* "type"="string",
* "description"="Image name with correct extension. Required for 'attachement' field."
* },
* },
* },
* },
* },
* },
* },
* },
* }
* )
*/
class Image extends \XLite\Model\Base\Image
{
...

The result is as follows:

Adds an image to a product

One more thing: The resources in the documentation are divided into groups, and if a resource has not been added to a group explicitly, it will not be shown in the documentation. Groups are described in XLite\API\OpenApiFactory:

...
protected function getTagGroups(): array
{
return [
'Catalog' => [
'AttributeGroup',
'Attribute',
'AttributeOption',
'AttributeProperty',
'AttributeValueCheckbox',
'AttributeValueHidden',
'AttributeValueSelect',
'AttributeValueText',
'Category',
'CategoryBanner',
'CategoryIcon',
'ProductClass',
'Product',
'ProductImage',
'TaxClass',
],
'Orders' => [
'Order',
'OrderDetail',
'OrderHistoryEvents',
'OrderItem',
'Payment',
'Transaction',
'Shipping',
'OrderTrackingNumber',
],
'Profiles' => [
'Membership',
],
];
}
...