Skip to content
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

Fix self-referential schema handling in collection schemas #60410

Open
wants to merge 4 commits into
base: release/9.0
Choose a base branch
from

Conversation

captainsafia
Copy link
Member

@captainsafia captainsafia commented Feb 15, 2025

Description

This PR expands relatives references in the schema generated by System.Text.Json since the Microsoft.OpenApi doesn't support resolving them correctly in v1.6. It also updates the comparison operators to handle relative references that are required for recursive types.

Fixes #60339

Customer Impact

Customer workarounds for this issue require post-processing on the document or changing the property order on types. These workarounds are hard to discover.

Regression?

  • Yes
  • No

Risk

  • High
  • Medium
  • Low

Low risk becase:

  • Change localized to M.A.OpenApi package.
  • Tests written from multiple user reports.
  • Changes introduced in this delta are easier to workaround for end-users.

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically labels Mar 5, 2025
@@ -112,6 +112,13 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC
return new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = schemaId?.ToString() } };
}

// Handle relative schemas that don't point to the parent document but to another property in the same type.
// In this case, remove the reference and rely on the properties that have been resolved and copied by the OpenApiSchemaService.
if (schema.Reference is { Type: ReferenceType.Schema, Id: var id } && id.StartsWith("#/", StringComparison.Ordinal))
Copy link
Member Author

Choose a reason for hiding this comment

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

We resolve the JSON pointer that this ref points to in the new code OpenApiSchemaService. We retain the $ref so that we can use it as a comparison shorthand in OpenApiSchemaComparer. Here, before we resolve all the schemas in the document, we remove the relative reference so that the schema can be inlined using the properties that were copied over.

{
// Check if this node has a $ref property with a relative reference and no schemaId to
// resolve to
if (jsonObj.TryGetPropertyValue(OpenApiSchemaKeywords.RefKeyword, out var refNode) &&
Copy link
Member Author

Choose a reason for hiding this comment

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

Not having a schema ID indicates that the relative reference is to something that isn't a complex type, for example a List<string>. For things that have a schema ID, we create a ref to the schema in the OpenApiComponents via the OpenApiSchemaReferenceTransformer.

x.Reference.ReferenceV3 is string xFullReferencePath &&
y.Reference.ReferenceV3 is string yFullReferencePath)
{
// Compare the last segments of the reference paths
Copy link
Member Author

Choose a reason for hiding this comment

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

STJ will use different relative references for the same type depending on its entrypoint. This comparison helps us evaluate relative references with different structure that point to the same underlying property. For example, the schemas for ParentObject and List<ParentObject> differ like this:

using System.Text.Json.Nodes;
using System.Text.Json;
using System.Text.Json.Schema;
using System.Collections.Generic;
using System;

var schema1 = JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions.Web, typeof(ParentObject));
var schema2 = JsonSchemaExporter.GetJsonSchemaAsNode(JsonSerializerOptions.Web, typeof(List<ParentObject>));
Console.WriteLine(schema1.ToJsonString());
Console.WriteLine(schema2.ToJsonString());


public class ParentObject
{
	public int Id { get; set; }
	public List<ChildObject> Children { get; set; } = [];
}

public class ChildObject
{
	public int Id { get; set; }
	public required ParentObject Parent { get; set; }
}

ParentObject:

{
    "type": [
        "object",
        "null"
    ],
    "properties": {
        "id": {
            "type": [
                "string",
                "integer"
            ],
            "pattern": "^-?(?:0|[1-9]\\d*)$"
        },
        "children": {
            "type": [
                "array",
                "null"
            ],
            "items": {
                "type": [
                    "object",
                    "null"
                ],
                "properties": {
                    "id": {
                        "type": [
                            "string",
                            "integer"
                        ],
                        "pattern": "^-?(?:0|[1-9]\\d*)$"
                    },
                    "parent": {
                        "type": [
                            "object",
                            "null"
                        ],
                        "properties": {
                            "id": {
                                "type": [
                                    "string",
                                    "integer"
                                ],
                                "pattern": "^-?(?:0|[1-9]\\d*)$"
                            },
                            "children": {
                                "$ref": "#/properties/children" // Same as below
                            }
                        }
                    }
                },
                "required": [
                    "parent"
                ]
            }
        }
    }
} 

List

{
    "type": [
        "array",
        "null"
    ],
    "items": {
        "type": [
            "object",
            "null"
        ],
        "properties": {
            "id": {
                "type": [
                    "string",
                    "integer"
                ],
                "pattern": "^-?(?:0|[1-9]\\d*)$"
            },
            "children": {
                "type": [
                    "array",
                    "null"
                ],
                "items": {
                    "type": [
                        "object",
                        "null"
                    ],
                    "properties": {
                        "id": {
                            "type": [
                                "string",
                                "integer"
                            ],
                            "pattern": "^-?(?:0|[1-9]\\d*)$"
                        },
                        "parent": {
                            "type": [
                                "object",
                                "null"
                            ],
                            "properties": {
                                "id": {
                                    "type": [
                                        "string",
                                        "integer"
                                    ],
                                    "pattern": "^-?(?:0|[1-9]\\d*)$"
                                },
                                "children": {
                                    "$ref": "#/items/properties/children" // This is the same as children not in a list
                                }
                            }
                        }
                    },
                    "required": [
                        "parent"
                    ]
                }
            }
        }
    }
}

@@ -102,7 +102,7 @@ internal sealed class OpenApiSchemaService(
// "nested": "#/properties/nested" becomes "nested": "#/components/schemas/NestedType"
if (jsonPropertyInfo.PropertyType == jsonPropertyInfo.DeclaringType)
{
return new JsonObject { [OpenApiSchemaKeywords.RefKeyword] = createSchemaReferenceId(context.TypeInfo) };
schema[OpenApiSchemaKeywords.RefKeyword] = createSchemaReferenceId(context.TypeInfo);
Copy link
Member Author

Choose a reason for hiding this comment

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

We need to override just the ref keyword so we can keep the other transformations that happened before this LoC intact (like setting the x-schema-id property).

@captainsafia captainsafia marked this pull request as ready for review March 7, 2025 17:45
@captainsafia captainsafia requested a review from a team as a code owner March 7, 2025 17:45
@captainsafia
Copy link
Member Author

@BrennanConroy Added snapshot tests and put in explainers next to each code delta. Ready for review!

@captainsafia captainsafia added the Servicing-consider Shiproom approval is required for the issue label Mar 7, 2025
Copy link
Contributor

Hi @captainsafia. Please make sure you've updated the PR description to use the Shiproom Template. Also, make sure this PR is not marked as a draft and is ready-to-merge.

To learn more about how to prepare a servicing PR click here.

@@ -505,6 +505,58 @@ await VerifyOpenApiDocument(builder, document =>
// Assert that $ref is used for related LocationDto
var addressSchema = locationSchema.Properties["address"].GetEffective(document);
Assert.Equal("LocationDto", addressSchema.Properties["relatedLocation"].Reference.Id);

// Assert that only expected schemas are generated at the top-level
Assert.Equal(["AddressDto", "LocationContainer", "LocationDto"], document.Components.Schemas.Keys);
Copy link
Member

Choose a reason for hiding this comment

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

Are there relevant/similar tests for all these in main?


if (xLastIndexOf != -1 && yLastIndexOf != -1)
{
return xFullReferencePath[xLastIndexOf..] == yFullReferencePath[yLastIndexOf..];;
Copy link
Member

Choose a reason for hiding this comment

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

This compiles into 2 substring calls, should use span comparison.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi Servicing-consider Shiproom approval is required for the issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants