Skip to content

fix(ModelRegistry): schemas deduplication now compare generated schemas to reduce automatically named schemas #2461

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 5.x
Choose a base branch
from

Conversation

xavierleune
Copy link
Contributor

@xavierleune xavierleune commented Mar 19, 2025

Description

This pull requests implements a better deduplication of schemas. It now compares the generated schemas, so even with different hashes (because of 2 different serializationContexts), we can have only 1 component if the output is identical.

The common use case for me is the following:

<?php

use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Attribute\Groups;

class FirstLevelEntity
{
    #[Groups(['all'])]
    public string $title;

    #[Groups(['default'])]
    public SecondLevelEntity $subEntity;
}

class SecondLevelEntity {
    #[Groups(['default'])]
    public string $title;

    #[Groups(['default'])]
    public string $description;
}

class Controller {
    #[Route('/all', methods: ['GET'])]
    #[OA\Response(response: 200, description: 'all', content: new OA\JsonContent(ref: new Model(type: FirstLevelEntity::class, groups: ['all', 'default'])))]
    public function all(FirstLevelEntity $entity) {

    }

    #[Route('/default', methods: ['GET'])]
    #[OA\Response(response: 200, description: 'default', content: new OA\JsonContent(ref: new Model(type: FirstLevelEntity::class, groups: ['default'])))]
    public function default(FirstLevelEntity $entity) {

    }
}

You can notice that SecondLevelEntity is always included in the response, because of the group "default".
The current output is something like this:

{
    "openapi": "3.0.0",
    "info": {
        "title": "ANS",
        "description": "Another Notification System - CRM & notifications @CCMBG",
        "version": "1.0.0"
    },
    "servers": [
        {
            "url": "https://api.ans.local.ccmbg.com"
        }
    ],
    "paths": {
        "/api/all": {
            "get": {
                "tags": [
                    "Service accounts"
                ],
                "operationId": "get_app_api_private_serviceaccounts_all",
                "responses": {
                    "200": {
                        "description": "all",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/FirstLevelEntity"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/api/default": {
            "get": {
                "tags": [
                    "Service accounts"
                ],
                "operationId": "get_app_api_private_serviceaccounts_default",
                "responses": {
                    "200": {
                        "description": "default",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/FirstLevelEntity2"
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "schemas": {
            "FirstLevelEntity": {
                "required": [
                    "title",
                    "subEntity"
                ],
                "properties": {
                    "title": {
                        "type": "string"
                    },
                    "subEntity": {
                        "$ref": "#/components/schemas/SecondLevelEntity"
                    }
                },
                "type": "object"
            },
            "FirstLevelEntity2": {
                "required": [
                    "subEntity"
                ],
                "properties": {
                    "subEntity": {
                        "$ref": "#/components/schemas/SecondLevelEntity2"
                    }
                },
                "type": "object"
            },
            "SecondLevelEntity": {
                "required": [
                    "title",
                    "description"
                ],
                "properties": {
                    "title": {
                        "type": "string"
                    },
                    "description": {
                        "type": "string"
                    }
                },
                "type": "object"
            },
            "SecondLevelEntity2": {
                "required": [
                    "title",
                    "description"
                ],
                "properties": {
                    "title": {
                        "type": "string"
                    },
                    "description": {
                        "type": "string"
                    }
                },
                "type": "object"
            }
        }
    }
}

Descriptions for SecondLevelEntity and SecondLevelEntity2 are the same (makes sense). With that pull request we get rid of SecondLevelEntity2 and the reference in FirstLevelEntity2 will be SecondLevelEntity.

You may notice a few changes in other tests, the current change alters the order of the Schemas, so the generated names may vary (like User becoming User2 and vice-versa).

What type of PR is this? (check all applicable)

  • Bug Fix
  • Feature ?
  • Refactor
  • Deprecation
  • Breaking Change
  • Documentation Update
  • CI

Checklist

  • I have made corresponding changes to the documentation (docs/)
  • I have made corresponding changes to the changelog (CHANGELOG.md)

@xavierleune xavierleune changed the base branch from 5.x to 4.x March 19, 2025 21:48
@xavierleune xavierleune changed the title Schemas deduplication now compare generated schemas to reduce automatically named schemas fix(ModelRegistry): Schemas deduplication now compare generated schemas to reduce automatically named schemas Mar 19, 2025
@xavierleune xavierleune changed the title fix(ModelRegistry): Schemas deduplication now compare generated schemas to reduce automatically named schemas fix(modelRegistry): Schemas deduplication now compare generated schemas to reduce automatically named schemas Mar 19, 2025
@xavierleune xavierleune changed the title fix(modelRegistry): Schemas deduplication now compare generated schemas to reduce automatically named schemas fix(ModelRegistry): schemas deduplication now compare generated schemas to reduce automatically named schemas Mar 19, 2025
@xavierleune
Copy link
Contributor Author

I missed the release of the version 5.0, congrats to everyone involved 🎉 !
I'm not sure about your policy regarding the version 4.x, for that kind of changes (and for #2435), should I target 4.x or 5.x ?

@xavierleune xavierleune force-pushed the feature/better-schema-deduplication branch 3 times, most recently from ebbb047 to eec12f7 Compare March 20, 2025 14:06
@xavierleune xavierleune changed the base branch from 4.x to 5.x March 20, 2025 14:06
@xavierleune
Copy link
Contributor Author

I decided to target 5.x as tests were failing for SF 5.4 ^^

@xavierleune xavierleune force-pushed the feature/better-schema-deduplication branch from eec12f7 to 83b716a Compare March 20, 2025 14:09
@DjordyKoert
Copy link
Collaborator

I'm not sure about your policy regarding the version 4.x, for that kind of changes (and for #2435), should I target 4.x or 5.x ?

Either is fine really 😄
Any PR merged in 4.x will also be commited to 5.x by myself, but I would personally recommend 5.x simply because the codebase is easier to work with (php 8.1, no more annotations, e.t.c.)

@xavierleune
Copy link
Contributor Author

Ok I'll stick with 5.x so ^^ Thanks !

@xavierleune xavierleune force-pushed the feature/better-schema-deduplication branch from 83b716a to 8fcd95e Compare March 25, 2025 10:23
@xavierleune
Copy link
Contributor Author

@DjordyKoert branch rebased on 5.x. Do you have any thoughts to share on this pull request ? Thks !

self::assertEquals('#/components/schemas/User', $response->content['application/json']->schema->ref);
self::assertEquals('#/components/schemas/User2', $response->content['application/json']->schema->ref);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is correct, is it?

The order of the generated schema's has been swapped.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it's been swapped because now when you register a Model, the whole model is described and names are reserved for all dependencies. So in this example when the first route in ApiController is parsed (fetchArticleAction), the model new Model(type: Article::class, groups: ['light']) is registered and the name User is reserved for the empty User model (because User has no property with the serialization group light).
When it comes to /swagger/implicit, the name User has already been taken, so the full entity User with all properties (because no serialization group has been applied) is described as User2.
It may be pretty hard to avoid that, however I think that this behavior is more predictable. WDYT ?

@@ -97,6 +125,23 @@ public function register(Model $model): string
return OA\Components::SCHEMA_REF.$this->names[$hash];
}

private function getSchemaWithoutRegistration(Model $model): ?OA\Schema
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we re-use this new method in the registerSchemas method aswell?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I renamed this method describeSchema and used it in registerSchemas and register

$this->unregistered = array_unique($this->unregistered);
// Only store schema if it was successfully generated
if (null !== $schema) {
$this->schemasPerType[$type][] = ['schema' => json_encode($schema), 'model' => $model];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not store the Schema object directly instead of json encoding it?

Suggested change
$this->schemasPerType[$type][] = ['schema' => json_encode($schema), 'model' => $model];
$this->schemasPerType[$type][] = ['schema' => $schema, 'model' => $model];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do that yes, but we have to use json_encode for comparison. Because:

  • We cannot use strict equality here as instances are different
  • We cannot use lose equality either, because of a cyclic dependency we go into an infinite loop

So if we decide to store the schema and not it's json representation, we would have to run json_encode and there is a lot of ops in OpenApi\Annotations\AbstractAnnotation::jsonSerialize, so I assumed it would be better to json_encode only once.
WDYT ?

@xavierleune xavierleune force-pushed the feature/better-schema-deduplication branch 2 times, most recently from dbc4508 to 8c44c1f Compare April 10, 2025 07:11
…ically named schemas (Entity / Entity2 / Entity3 / ...).
@xavierleune xavierleune force-pushed the feature/better-schema-deduplication branch from 8c44c1f to 830fd2b Compare April 10, 2025 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants