1

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;

    ...
}
3
  • OpenAPI does not consider properties marked readOnly as required by default, even if they are guaranteed to exist at runtime (like id fields). Your Swagger schema says: "required": ["date"], but not "id" or "@id" because both are marked as "readOnly": true. check this link: spec.openapis.org/oas/v3.0.3#fixed-fields-20 Commented Sep 3 at 12:46
  • Best quick fix is to use the transformers option in @rtk-query/codegen-openapi to force some fields to be required, like id? Commented Sep 3 at 12:48
  • According to the bounty help center: "the highest voted answer created after the bounty started with a minimum score of 2 will be awarded half the bounty amount (or the full amount, if the answer is also accepted).". Does the accepted answer have to get two upvotes, even though it was already accepted? Will it be automatically awarded by the system? Commented Sep 10 at 1:25

1 Answer 1

2
+200

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.

You could try several thing here:

1. Custom OpenAPI Decorator

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']

2. Use OpenAPI Property Attributes

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
}

3. Custom Normalizer Approach

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);
    }
}

4. RTK Query Codegen Configuration

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;
      }
    }
  }
};

5. Post-processing Script

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);
  }
});

Obviously option 1 is the cleanest approach.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.