I am using API Platform / Symfony (latest version) to manage my API.
Then, I use the @rtk-query/codegen-openapi library to generate my TypeScript typings. The problem is that many fields are marked as non-required even though they are supposed to be required, especially the IDs :
export type UserMeasurementJsonldUserMeasurementRead = {
"@context"?:
| string
| {
"@vocab": string;
hydra: "http://www.w3.org/ns/hydra/core#";
[key: string]: any;
};
"@id"?: string;
"@type"?: string;
id?: string;
date: string;
userDescription?: string | null;
createdAt?: string;
createdBy?: UserJsonldUserMeasurementRead;
};
I understand that only the 'date' field is required, since I added a NotNull assertion, but it seems odd that such important fields like 'id' are optional. Below is the schema from Swagger:
"UserMeasurement.jsonld-UserMeasurement.read":{
"type":"object",
"description":"",
"deprecated":false,
"properties":{
"@id":{
"readOnly":true,
"type":"string"
},
"id":{
"readOnly":true,
"type":"string",
"format":"uuid"
},
"date":{
"type":"string",
"format":"date-time"
},
"userDescription":{
"type":[
"string",
"null"
]
},
"createdAt":{
"type":"string",
"format":"date-time"
},
"createdBy":{
"$ref":"#\/components\/schemas\/User.jsonld-UserMeasurement.read"
}
},
"required":[
"date"
]
}
Is it possible to handle this without having to put NonNull everywhere, especially on the IDs? Here is my PHP entity, even though it’s very basic:
class UserMeasurement
{
#[ORM\Id]
#[ORM\Column(type: "uuid", unique: true)]
#[ORM\GeneratedValue(strategy: "CUSTOM")]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
#[Groups(groups: ['UserMeasurement:read'])]
protected UuidInterface $id;
#[ORM\Column(type: Types::DATE_MUTABLE, name: '_date')]
//#[Assert\DateTime] // dump(assert non fonctionnel)
#[Assert\NotNull]
#[Groups(groups: ['UserMeasurement:read', 'UserMeasurement:write'])]
private ?\DateTimeInterface $date = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Localizable]
#[Assert\NotBlank(allowNull: true)]
#[Groups(groups: ['UserMeasurement:read', 'UserMeasurement:write'])]
private ?string $userDescription = null;
#[ORM\Column]
#[Groups(groups: ['UserMeasurement:read'])]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[Groups(groups: ['UserMeasurement:read'])]
private ?User $createdBy = null;
...
}
OpenAPI specification doesn't consider readOnly fields as required by default, even if they're non-nullable in your PHP entity. Your id field is marked as readOnly: true in the schema but isn't included in the required array, which is why the TypeScript generator treats it as optional.
Create a custom decorator to modify the OpenAPI schema generation:
// src/OpenApi/RequiredReadOnlyDecorator.php
namespace App\OpenApi;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\OpenApi;
use ApiPlatform\OpenApi\Model;
final class RequiredReadOnlyDecorator implements OpenApiFactoryInterface
{
public function __construct(
private OpenApiFactoryInterface $decorated
) {}
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$schemas = $openApi->getComponents()->getSchemas();
foreach ($schemas as $key => $schema) {
$schemaArray = $schema->getArrayCopy();
// Add readOnly fields to required array
if (isset($schemaArray['properties'])) {
$required = $schemaArray['required'] ?? [];
foreach ($schemaArray['properties'] as $propName => $propSchema) {
// Add id fields and other critical readOnly fields to required
if (isset($propSchema['readOnly']) && $propSchema['readOnly'] === true) {
if ($propName === 'id' || $propName === '@id' || $propName === 'createdAt') {
if (!in_array($propName, $required)) {
$required[] = $propName;
}
}
}
}
if (!empty($required)) {
$schemaArray['required'] = array_values(array_unique($required));
$schemas[$key] = new \ArrayObject($schemaArray);
}
}
}
return $openApi;
}
}
Register it in your services:
# config/services.yaml
services:
App\OpenApi\RequiredReadOnlyDecorator:
decorates: 'api_platform.openapi.factory'
arguments: ['@.inner']
Add OpenAPI schema attributes directly to your entity properties:
use ApiPlatform\Metadata\ApiProperty;
class UserMeasurement
{
#[ORM\Id]
#[ORM\Column(type: "uuid", unique: true)]
#[ORM\GeneratedValue(strategy: "CUSTOM")]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
#[Groups(groups: ['UserMeasurement:read'])]
#[ApiProperty(
openapiContext: ['required' => true],
schema: ['type' => 'string', 'format' => 'uuid'],
required: true
)]
protected UuidInterface $id;
// ... rest of your properties
}
Create a custom normalizer to ensure these fields are always marked as required:
// src/Serializer/RequiredFieldsNormalizer.php
namespace App\Serializer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use ApiPlatform\Api\IriConverterInterface;
class RequiredFieldsNormalizer implements NormalizerInterface
{
public function __construct(
private NormalizerInterface $decorated,
private IriConverterInterface $iriConverter
) {}
public function normalize($object, string $format = null, array $context = []): array
{
$data = $this->decorated->normalize($object, $format, $context);
// Ensure id fields are always present
if (is_array($data) && method_exists($object, 'getId')) {
$data['id'] = $data['id'] ?? $object->getId()->toString();
}
return $data;
}
public function supportsNormalization($data, string $format = null, array $context = []): bool
{
return $this->decorated->supportsNormalization($data, $format, $context);
}
}
If you prefer to handle this on the TypeScript generation side, use the hooks configuration in your codegen config:
// rtk-query-codegen.config.ts
module.exports = {
schemaFile: './openapi.json',
apiFile: './src/api/baseApi.ts',
hooks: {
queries: {
overrideResultType: (resultType, { operationDefinition }) => {
// Force certain fields to be required
if (resultType.includes('UserMeasurement')) {
return resultType
.replace('id?:', 'id:')
.replace('"@id"?:', '"@id":')
.replace('createdAt?:', 'createdAt:');
}
return resultType;
}
}
}
};
Create a script to post-process the generated TypeScript files:
// scripts/fix-required-fields.ts
import * as fs from 'fs';
import * as path from 'path';
const generatedDir = './src/api/generated';
const files = fs.readdirSync(generatedDir);
const fieldsToMakeRequired = ['id', '@id', 'createdAt', 'createdBy'];
files.forEach(file => {
if (file.endsWith('.ts')) {
let content = fs.readFileSync(path.join(generatedDir, file), 'utf8');
fieldsToMakeRequired.forEach(field => {
const pattern = new RegExp(`"${field}"\\?:`, 'g');
content = content.replace(pattern, `"${field}":`);
});
fs.writeFileSync(path.join(generatedDir, file), content);
}
});