Part 2: GraphQL API

Expose product 3D models through the GraphQL API

At this point, the admin can upload and manage 3D models for each product, but these models are not yet visible to the frontend. However, before we can start implementing the frontend logic for product 3D models, we need to expose this data through an API.

GraphQL is an API language has several advantages over the conventional REST APIs – greater flexibility, better API documentation, type checking, and a simple query structure.

​Scandi uses GraphQL to fetch data from the backend. For example, to fetch product data, Scandi uses the products query:

Example GraphQL query:
{
products(search:"bag") {
items {
id
name
}
}
}
​
Corresponding response:
{
"data": {
"products": {
"items": [
{
"id": 1,
"name": "Joust Duffle Bag"
},
{
"id": 8,
"name": "Voyage Yoga Bag"
},
{
"id": 4,
"name": "Wayfarer Messenger Bag"
}
]
}
}
}

This query already has multiple useful fields – we can query the product name, sku, price and other properties if we want. But now we want to add a brand-new queryable field: model_3d_urls. This field will return an array of strings, each representing the URL of a 3D model associated with the product.

You can use a GraphQL client such as Altair GraphQL Client to make GraphQL queries easily. This can be useful when exploring an existing API as we did above, or to debug your own API, as we are about to do.

Make sure you set the API URL to http://localhost/graphql (assuming your M2 server is running at localhost) and you're good to go – you can try out the example query from above!

Extending the GraphQL Schema

A GraphQL Schema is a document that describes the fields available in a GraphQL API. For example, there is a schema that specifies that the items field of the products query is an array of ProductInterface. There is another schema file that describes the fields that are part of ProductInterface:

vendor/magento/module-catalog-graph-ql/etc/schema.graphqls (snippet)
interface ProductInterface
{
id: Int
name: String
sku: String @doc(description: "A code assigned to a product")
description: ComplexTextValue
# [...]
}

Each field consists of a name for the field (like "id"), and a corresponding type (Int/String, etc.). This describes the values that the API is expected to have.

Because of the way GraphQL is setup in Magento, all of the schema files are merged together. This means that we can easily "extend" the ProductInterface type to include other fields as well. All we need to do is create a new GraphQL schema file in our module (in etc/schema.graphqls), and declare the additional ProductInterface fields there:

etc/schema.graphqls
interface ProductInterface {
model_3d_urls: [String!]!
}

And we have updated the GraphQL schema! Run magento setup:upgrade for Magento to update it.

The exclamation mark (!) means that the field cannot be null. This means that values such as model_3d_urls: null and model_3d_urls: [null, null] are not valid. It is a good idea to tell GraphQL that we expect these values to be present on any product if that is the case, because this will result in an error if the field is not set, helping catch bugs early.

Implementing the Field

Now, our ProductInterface type provides an additional field. We have made a "promise" to API users that they can query a model_3d_urls field on any product. Indeed, if we refresh the schema in our GraphQL client, we are now able to make a query such as this one:

{
products(search:"bag") {
items {
id
name
model_3d_urls
}
}
}

However, if we send the query, we will get an error – because there is nothing in the current resolver that would populate model_3d_urls with a value, and GraphQL is complaining that the data returned by the resolver does not match the schema we defined.

A GraphQL Resolver is an implementation of the schema. It is the PHP code that actually handles PHP requests and returns a data object of the response.

The products query already has a resolver – that's the bit of code that returns the items so that we can see the id, name and other fields. Now, we want to "plug in" to the resolver so that it returns the additional data we want.

The best way to "plug in" depends on how the resolver is implemented. Sometimes, we have to use Magento plugins (interceptors) to "wrap around" the resolver and add additional data before it returns. In this case, however, there is a specific class whose purpose is to process the product data after it is generated, but before it is returned through GraphQL.

The product DataPostProcessor accepts an array of objects that implement the ProductsDataPostProcessorInterface. Then, whenever the product collection is processed, it passes the data through each of the processor objects, allowing them to modify the data.

We can use the dependency injection configuration file to inject an additional processor:

etc/di.xmletc/di.xml
<?xml version="1.0"?>
<config
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"
>
<!-- [...] -->
​
<type name="ScandiPWA\Performance\Model\Resolver\Products\DataPostProcessor">
<arguments>
<argument name="processors" xsi:type="array">
<item name="model_3d_urls" xsi:type="object">
Scandi\Product3DGraphQl\Model\Resolver\Products\CollectionPostProcessor\Model3DProcessor
</item>
</argument>
</arguments>
</type>
</config>

Then, we can define that class and implement the functionality we want:

<?php declare(strict_types=1);
namespace Scandi\Product3DGraphQl\Model\Resolver\Products\CollectionPostProcessor;
​
use Scandi\Product3DGraphQl\Helper\Model3DProvider;
use ScandiPWA\Performance\Api\ProductsDataPostProcessorInterface;
​
class Model3DProcessor implements ProductsDataPostProcessorInterface
{
const MODEL_FIELD = 'model_3d_urls';
​
/** @var Model3DProvider */
protected $modelProvider;
// [DI constructor omitted]
​
public function process(
array $products,
string $graphqlResolvePath,
$graphqlResolveInfo,
?array $processorOptions = []
): callable {
// Collect relevant product IDs:
$ids = [];
foreach ($products as $product) {
$ids[] = $product->getId();
}
​
// Get all 3D Models that belong to those IDs (1 SQL request)
$modelsByProductId = $this->modelProvider->getModelsForProductIds($ids);
​
// Return a function that adds a new field to the given product
return function (&$product) use ($modelsByProductId) {
$models = $modelsByProductId[$product['entity_id']];
​
$urls = [];
foreach ($models as $model) {
$urls[] = $model->getResourceUrl();
}
​
$product[self::MODEL_FIELD] = $urls;
};
}
}

You can now verify that we can query the 3D Model URLs of the products and they will be returned correctly. Example response:

{
"data": {
"products": {
"items": [
{
"id": 1,
"name": "Joust Duffle Bag",
"model_3d_urls": [
"http://localhost/media/scandi/product_3d_models/m/o/model1.glb",
"http://localhost/media/scandi/product_3d_models/m/o/model2.glb"
]
},
{
"id": 8,
"name": "Voyage Yoga Bag",
"model_3d_urls": []
},
{
"id": 4,
"name": "Wayfarer Messenger Bag",
"model_3d_urls": []
},
{
"id": 14,
"name": "Push It Messenger Bag",
"model_3d_urls": []
}
]
}
}
}

This works as expected – products with no 3D models get an empty array in model_3d_urls and products with 3D models have an array of valid model URLs.