symfonyswaggerapi-platform.comswagger-codegen

Api Platform generate non-required fields for id (and others)


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;

    ...
}

Solution

  • 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.