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

OpenAPI schema references feedback #56318

Open
martincostello opened this issue Jun 19, 2024 · 32 comments
Open

OpenAPI schema references feedback #56318

martincostello opened this issue Jun 19, 2024 · 32 comments

Comments

@martincostello
Copy link
Member

Hot off the presses, the app I'm testing daily builds and OpenAPI with has picked up the changes from #56175 and there's a few things in the new schema that seem a bit odd to me. Some might be intentional, some might be known and just not gotten to yet, so sorry if any of this is just noise.

The observations are also based on comparison to NSwag's and Swashbuckle.AspNetCore's current behaviour, though I appreciate one-to-one parity isn't a goal.

The points referenced below are all from commit martincostello/api@f72f01b using Microsoft.AspNetCore.OpenApi 9.0.0-preview.6.24318.18. The schema generated is at the bottom of this issue.

Overly inlined schemas?

One of the components in the schema is this:

"string": {
  "type": "string"
}

This seems maybe a bit too normalized compared to the relevant properties in an associated model just being declared as of type string directly?

Question marks in schema names

One of the schema names is generated thus:

"int?": {
  "type": "integer",
  "format": "int32",
  "nullable": true
}

I wonder if some client generation tooling might have issues with trying to generate models with a ? in the name? It's at least not something I've seen before.

Inconsistent schema names for nullable types?

In the generated OpenAPI document is the following schema:

"string2": {
  "type": "string",
  "nullable": true
}

This seems to be inconsistent with the int? schema - assuming the question marks as intentional in the names. I would have thought string? would be the more appropriate name for the sake of comparison with another nullable type.

Inline objects still rendered for operations

The following schema is present inline for the response of one of the operations:

"schema": {
  "type": "object",
  "properties": {
    "decryptionKey": {
      "$ref": "#/components/schemas/string"
    },
    "validationKey": {
      "$ref": "#/components/schemas/string"
    },
    "machineKeyXml": {
      "$ref": "#/components/schemas/string"
    }
  }
}

I would have expected the response to be a reference to a schema for the type as a whole, and then the model emitted as a component which is itself composed of three string properties (notwithstanding the point above about maybe too much inlining).

/cc @captainsafia

OpenAPI schema
{
  "openapi": "3.0.1",
  "info": {
    "title": "api.martincostello.com",
    "description": "Martin Costello's API",
    "contact": {
      "name": "Martin Costello",
      "url": "https://martincostello.com/"
    },
    "license": {
      "name": "This API is licensed under the MIT License.",
      "url": "https://github.com/martincostello/api/blob/main/LICENSE"
    },
    "version": ""
  },
  "paths": {
    "/time": {
      "get": {
        "tags": [
          "API"
        ],
        "summary": "Gets the current UTC time.",
        "description": "Gets the current date and time in UTC.",
        "operationId": "Time",
        "responses": {
          "200": {
            "description": "The current UTC date and time.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "rfc1123": {
                      "$ref": "#/components/schemas/string"
                    },
                    "unix": {
                      "type": "integer",
                      "format": "int64"
                    },
                    "universalSortable": {
                      "$ref": "#/components/schemas/string"
                    },
                    "universalFull": {
                      "$ref": "#/components/schemas/string"
                    }
                  }
                },
                "example": {
                  "timestamp": "2016-06-03T18:44:14+00:00",
                  "rfc1123": "Fri, 03 Jun 2016 18:44:14 GMT",
                  "unix": 1464979454,
                  "universalSortable": "2016-06-03 18:44:14Z",
                  "universalFull": "Friday, 03 June 2016 18:44:14"
                }
              }
            }
          }
        }
      }
    },
    "/tools/guid": {
      "get": {
        "tags": [
          "API"
        ],
        "summary": "Generates a GUID.",
        "description": "Generates a new GUID in the specified format.",
        "operationId": "Guid",
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "description": "The format for which to generate a GUID.",
              "nullable": true
            },
            "example": "D"
          },
          {
            "name": "uppercase",
            "in": "query",
            "schema": {
              "type": "boolean",
              "description": "Whether to return the GUID in uppercase.",
              "nullable": true
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A GUID was generated successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "guid": {
                      "$ref": "#/components/schemas/string"
                    }
                  }
                },
                "example": {
                  "guid": "6bc55a07-3d3e-4d52-8701-362a1187772d"
                }
              }
            }
          },
          "400": {
            "description": "The specified format is invalid.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                  "title": "Bad Request",
                  "status": 400,
                  "detail": "The specified value is invalid."
                }
              }
            }
          }
        }
      }
    },
    "/tools/hash": {
      "post": {
        "tags": [
          "API"
        ],
        "summary": "Hashes a string.",
        "description": "Generates a hash of some plaintext for a specified hash algorithm and returns it in the required format.",
        "operationId": "Hash",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "required": [
                  "algorithm",
                  "format",
                  "plaintext"
                ],
                "type": "object",
                "properties": {
                  "algorithm": {
                    "$ref": "#/components/schemas/string"
                  },
                  "format": {
                    "$ref": "#/components/schemas/string"
                  },
                  "plaintext": {
                    "$ref": "#/components/schemas/string"
                  }
                },
                "nullable": true
              },
              "example": {
                "algorithm": "sha256",
                "format": "base64",
                "plaintext": "The quick brown fox jumped over the lazy dog"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The hash was generated successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "hash": {
                      "$ref": "#/components/schemas/string"
                    }
                  }
                },
                "example": {
                  "hash": "fTi1zSWiuvha07tbkxE4PmcaihQuswKzJNSl+6h0jGk="
                }
              }
            }
          },
          "400": {
            "description": "The specified hash algorithm or output format is invalid.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                  "title": "Bad Request",
                  "status": 400,
                  "detail": "The specified value is invalid."
                }
              }
            }
          }
        }
      }
    },
    "/tools/machinekey": {
      "get": {
        "tags": [
          "API"
        ],
        "summary": "Generates a machine key.",
        "description": "Generates a machine key for a Web.config configuration file for ASP.NET.",
        "operationId": "MachineKey",
        "parameters": [
          {
            "name": "decryptionAlgorithm",
            "in": "query",
            "schema": {
              "type": "string",
              "description": "The name of the decryption algorithm.",
              "nullable": true
            },
            "example": "AES-256"
          },
          {
            "name": "validationAlgorithm",
            "in": "query",
            "schema": {
              "type": "string",
              "description": "The name of the validation algorithm.",
              "nullable": true
            },
            "example": "SHA1"
          }
        ],
        "responses": {
          "200": {
            "description": "The machine key was generated successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "decryptionKey": {
                      "$ref": "#/components/schemas/string"
                    },
                    "validationKey": {
                      "$ref": "#/components/schemas/string"
                    },
                    "machineKeyXml": {
                      "$ref": "#/components/schemas/string"
                    }
                  }
                },
                "example": {
                  "decryptionKey": "2EA72C07DEEF522B4686C39BDF83E70A96BA92EE1D960029821FCA2E4CD9FB72",
                  "validationKey": "0A7A92827A74B9B4D2A21918814D8E4A9150BB5ADDB284533BDB50E44ADA6A4BCCFF637A5CB692816EE304121A1BCAA5A6D96BE31A213DEE0BAAEF102A391E8F",
                  "machineKeyXml": "<machineKey validationKey=\"0A7A92827A74B9B4D2A21918814D8E4A9150BB5ADDB284533BDB50E44ADA6A4BCCFF637A5CB692816EE304121A1BCAA5A6D96BE31A213DEE0BAAEF102A391E8F\" decryptionKey=\"2EA72C07DEEF522B4686C39BDF83E70A96BA92EE1D960029821FCA2E4CD9FB72\" validation=\"SHA1\" decryption=\"AES\" />"
                }
              }
            }
          },
          "400": {
            "description": "The specified decryption or validation algorithm is invalid.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                },
                "example": {
                  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                  "title": "Bad Request",
                  "status": 400,
                  "detail": "The specified value is invalid."
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "int?": {
        "type": "integer",
        "format": "int32",
        "nullable": true
      },
      "ProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "$ref": "#/components/schemas/string2"
          },
          "title": {
            "$ref": "#/components/schemas/string2"
          },
          "status": {
            "$ref": "#/components/schemas/int?"
          },
          "detail": {
            "$ref": "#/components/schemas/string2"
          },
          "instance": {
            "$ref": "#/components/schemas/string2"
          }
        }
      },
      "string": {
        "type": "string"
      },
      "string2": {
        "type": "string",
        "nullable": true
      }
    }
  },
  "tags": [
    {
      "name": "API"
    }
  ]
}
@captainsafia
Copy link
Member

@martincostello Wow! I think you've won the award for the fastest commit merged -> feedback given time. 😸

Your feedback aligns with some of the things I expected to hear so I appreciate you chiming in and affirming some of my hunches here.

Overly inlined schemas?

The implementation creates reference schemas for any schema that appears more than once in the document. This has a particularly sharp impact for schemas associated with primitive types. I'm open to adjusting this although we'd have to go through the exercise of figuring out what primitives should be inline and which shouldn't.

Inconsistent schema names for nullable types?

I suspect the issue here is in the nullability checks below. These need to account for nullable reference types like string.

if (type.GetGenericTypeDefinition() == typeof(Nullable<>)
&& Nullable.GetUnderlyingType(type) is { } underlyingType)
{
return $"{underlyingType.GetSchemaReferenceId(options)}?";
}

Inline objects still rendered for operations

It looks like the MachineKeyResponse type returned here is only used once in the entire application? If so, this is matches the expected behavior of this implementation.

We did discuss the possibility of this particular problem occuring. I had concluded that it would be ✨ rare ✨ given that it seems like most REST APIs use the same DTO in multiple places, although your sample app has provided a good counter point here. I'll noodle more on how we might want to approach this.

@pinkfloydx33
Copy link

pinkfloydx33 commented Jun 21, 2024

Having primitives as full schemas is very... unusual and I think plainly incorrect. I wouldn't expect a schema for anything that isn't a class with properties, as all the primitives, etc. are definable directly within the openapi spec otherwise. OpenAPI has a well defined specification for data types and formats of which "string" is an integral part. I think anything they define with formats/data types ought not be a whole schema which is likely in violation of the spec.

A seperate schema for the nullable and non-nullable versions of a type also feels incorrect in prescense of the nullable attribute/property. Schemas with symbols/numbers in the name are very odd and I'd not expect that unless the user designated them that way via some customizations.

I suspect this would break a lot of tools that read the document (code generators for one). If this was the finalized schema I would simply opt out altogether and continue using other libraries. Please reconsider.

@martincostello
Copy link
Member Author

martincostello commented Jun 21, 2024

Overly inlined schemas?

I think for this one it's more that it's different to what I'm used to in .NET. If it's more technically correct (the best kind of correct) to inline things as much as possible, then it's probably better to go in that direction even if it feels "weird" for us now.

On balance if it's not technically difficult if it were me I'd still be inclined to go with the not inlining "primitives" as noted above based on the commonly known built-in OpenAPI data types, at least for the "obvious" types like strings, "numbers" (int, long, float, double, decimal), booleans etc. There'd probably need to be a discussion like you mention that draws a line between primitive and not that doesn't get too wrapped up in the .NET type system but doesn't seem too arbitrary.

Inline objects still rendered for operations

Here I think I'd prefer the consistency (though it kinda contradicts my point above 😄) of having all the object-like types being defined in the components consistently, rather than them only not being inlined if used more than once.

Question marks in schema names

Having just tried to generate a client for the OpenAPI specification generated by the latest bits (9.0.0-preview.6.24320.8) by following this tutorial, I think this needs rethinking and the ? removing.

Running Kiota, it outputs this failure message (the warnings are #56188):

❯ kiota generate -l CSharp -c ApiClient -n KiotaExperiment.Client -d ./openapi.json -o ./Client
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/ - A servers entry (v3) or host + basePath + schemes properties (v2) was not present in the OpenAPI description. The root URL will need to be set manually with the request adapter.
- fail: Kiota.Builder.KiotaBuilder[0]
-     OpenAPI error: #/components - The key 'int?' in 'schemas' of components MUST match the regular expression '^[a-zA-Z0-9\.\-_]+$'.
warn: Kiota.Builder.KiotaBuilder[0]
      No server url found in the OpenAPI document. The base url will need to be set when using the client.
Generation completed successfully

Admittedly this is a sample size of 1 (one) code generator, but given its one of the ones being promoted around this initiative, the schemas shouldn't be producing errors in client-side tooling. As noted above, this would likely be a big barrier (or blocker) to adoption within the community. The generated code does build though (I haven't actually pointed it at the API to see if it works yet).

@martincostello
Copy link
Member Author

I haven't actually pointed it at the API to see if it works yet

It does work - I've pushed the code up here.

@captainsafia
Copy link
Member

@martincostello @pinkfloydx33 Thanks for all your feedback here. I've been iterating on it in the PRs linked above. Can you try the changes in Microsoft.AspNetCore.OpenApi 9.0.0-preview.7.24353.6 and see how they work for you?

@captainsafia captainsafia added the Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. label Jul 3, 2024
@martincostello
Copy link
Member Author

@captainsafia Comparing things to NSwag, things look like they're getting a lot more similar now: martincostello/api@684485c

Remaining differences that are potentially interesting:

  • Descriptions are missing for models parameters - I know this is in the epic for supporting /// docs to populate them at some point.
  • [Required] appears to not being honoured on properties from request body models.
  • ProblemDetails' Extensions property with [JsonExtensionData] isn't generating "additionalProperties": {}.

Otherwise things seem to be coming out functionally the same in terms of schemas/references.

@dotnet-policy-service dotnet-policy-service bot added Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. and removed Needs: Author Feedback The author of this issue needs to respond in order for us to continue investigating this issue. labels Jul 3, 2024
@martincostello
Copy link
Member Author

Also, I just regenerated a Kiota client from the new OpenAPI document and I get these warnings:

.\generate.ps1 -Regenerate
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/rfc1123 - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/unix - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalSortable - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalFull - Data and type mismatch found.

@captainsafia
Copy link
Member

Descriptions are missing for models parameters - I know this is in the epic for supporting /// docs to populate them at some point.

Yep, for now, we only support setting descriptions via the [Description] attribute on properties and parameters. I haven't forgotten about XML support though! I've been tinkering in the space and hope to share an update soon.

[Required] appears to not being honoured on properties from request body models.

Hmmm....we have test coverage for this here. Can you clue me in on which type isn't handling this correctly in your sample app and I can look further?

ProblemDetails' Extensions property with [JsonExtensionData] isn't generating "additionalProperties": {}.

Ooooooh! Good find. I think this one falls into the realm of things the JsonSchemaExporter should be handling instead of us at the ASP.NET Core layer. First, let me verify that JsonSchemaExporter on its own prodcues the correct schema for this. We might be mangling things incorrectly at the OpenAPI-layer. Assuming that's the case, I can apply a fix. If not, we can file an issue on the runtime repo to discuss whether or not JsonExtensionData should be mapped to additionalProperties in that layer.

Also, I just regenerated a Kiota client from the new OpenAPI document and I get these warnings:

Interesting...I'll try to take a look at this next week.

@captainsafia captainsafia removed the Needs: Attention 👋 This issue needs the attention of a contributor, typically because the OP has provided an update. label Jul 10, 2024
@martincostello
Copy link
Member Author

Can you clue me in on which type isn't handling this correctly in your sample app and I can look further?

Comparing the two documents, it looks like it's HashRequest (OpenAPI vs. NSwag).

@captainsafia
Copy link
Member

Comparing the two documents, it looks like it's HashRequest (OpenAPI vs. NSwag).

Hm....I think I might be missing something here. I see the required property on the schema is the same between the two.

openapi.json has three items in its required list and so does swagger.json. Are you referring to the difference in nullable here? openapi.json seems to be doing the right thing considering that the the handler defines an optional HashRequest in the request body.

@martincostello
Copy link
Member Author

I'll look at it again in BeyondCompare when I'm next at my laptop and see where I've gotten confused...

@martincostello
Copy link
Member Author

It was this.

If I understand correctly, because [Required] is set on the properties and they aren't nullable, it's implying a minimum length of 1. On reflection that's probably not actually correct as you could set it to the empty string...

@captainsafia
Copy link
Member

Ooooooh! Good find. I think this one falls into the realm of things the JsonSchemaExporter should be handling instead of us at the ASP.NET Core layer. First, let me verify that JsonSchemaExporter on its own prodcues the correct schema for this. We might be mangling things incorrectly at the OpenAPI-layer. Assuming that's the case, I can apply a fix. If not, we can file an issue on the runtime repo to discuss whether or not JsonExtensionData should be mapped to additionalProperties in that layer.

I went on a journey with this one but as you can see from the comments in the associated PR it looks like we actually are spec-compliant here. OpenAPI, like JSON Schema, assumes that additionalProperties: true so there is no need to set it for when [JsonExtensionData] is used. There are questions as to whether the opposite is necessary (setting additionalProperties: false for schemas without extension data) but trying to get more clarity on whether that will cause more problems than it solves.

If I understand correctly, because [Required] is set on the properties and they aren't nullable, it's implying a minimum length of 1. On reflection that's probably not actually correct as you could set it to the empty string...

Ah, I see. I assume this is a constraint that you can only apply on required string values. I agree that it's not clear how helpful minLength might be here...

@captainsafia
Copy link
Member

Also, I just regenerated a Kiota client from the new OpenAPI document and I get these warnings:

.\generate.ps1 -Regenerate
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/rfc1123 - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/unix - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalSortable - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalFull - Data and type mismatch found.

OK, I took a look at this and here's what I understand.

The warnings that you are seeing here come from a set of validation rules in the OpenAPI library. It appears that these rules attempt to validate that you are using the correct types in your examples based on the schemas for properties in a document. For example, if a schema is { type: "string", format: "datetime" } it will attempt to validate that it is represented via an OpenApiDateTime object.

It seems like the types it's buggy about are ones where a string is encoded as a DateTime. Assuming my analysis here is correct, I'd expect you to get the same warnings if you used the same example strings in a document generated by NSwag. I see the following when I run Kiota against the OpenAPI document generated from NSwag (I think that file is generated by NSwag but if not let me know).

$ kiota generate --openapi src/API/wwwroot/swagger/api/swagger.json --language csharp 
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/rfc1123 - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/unix - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalSortable - Data and type mismatch found.
warn: Kiota.Builder.KiotaBuilder[0]
      OpenAPI warning: #/paths/~1time/get/responses/200/content/application~1json/example/universalFull - Data and type mismatch found.

@martincostello
Copy link
Member Author

Thanks for looking - I haven't used Kiota with my NSwag schemas, I've only started playing with it as part of testing the new OpenAPI stuff. I'll dig into this further today as I'm reworking some of my customisation on top of #56395.

Separately, I haven't worked out why yet, but trying to apply examples to the schemas they don't seem, to be coming out in the rendered document. Not sure if that's my bug or something in the library yet.

Essentially I'm doing this:

public Task TransformAsync(
    OpenApiSchema schema,
    OpenApiSchemaTransformerContext context,
    CancellationToken cancellationToken)
{
    var metadata = context.JsonTypeInfo.Type.GetCustomAttributes(false)
        .OfType<IOpenApiExampleMetadata>()
        .FirstOrDefault();

    if (metadata?.GenerateExample(_options) is { } value)
    {
        schema.Example = value;
    }

    return Task.CompletedTask;
}

image

but it's not rendered in the schema component:

image

@martincostello
Copy link
Member Author

Not sure if that's my bug or something in the library yet.

Still digging, but I think it's an issue in Microsoft.OpenApi - the OpenApiSchema is cloned here, but the clone doesn't seem to come back with the right Example for arrays or objects...

ResolveReferenceForSchema(new OpenApiSchema(schema), schemasByReference, isTopLevel: true));

@martincostello
Copy link
Member Author

I think it's an issue in Microsoft.OpenApi

microsoft/OpenAPI.NET#1736

@martincostello
Copy link
Member Author

Checked, and I get the behaviour I was originally expecting with that change applied locally.

@martincostello
Copy link
Member Author

Something else I'm just trying to hack together until #39927 is ready is to populate the descriptions for my schema models from my XML comments.

However, I'm stuck because I can't see how to get the original name of the property associated with a schema so I can build the right xpath.

(Ignore how hacky and unperformant this is, just trying to get something basic working first)

public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
    var thisAssembly = GetType().Assembly;

    if (context.JsonPropertyInfo?.DeclaringType.Assembly != thisAssembly)
    {
        return Task.CompletedTask;
    }

    var path = Path.GetDirectoryName(thisAssembly.Location) ?? Environment.CurrentDirectory;
    var xml = Path.Combine(path, "API.xml");
    using var reader = XmlReader.Create(xml);
    var xpath = new XPathDocument(reader);
    var navigator = xpath.CreateNavigator();

    var description = navigator.SelectSingleNode(
        $"/doc/members/member[@name='P:{context.JsonPropertyInfo.DeclaringType.FullName}.{context.JsonPropertyInfo.Name}']/summary");

    if (!string.IsNullOrEmpty(description?.InnerXml))
    {
        schema.Description = description.Value.Trim();
    }

    return Task.CompletedTask;
}

OpenApiSchemaTransformerContext.JsonPropertyInfo.Name is the serialized name (e.g. foo), but to build up the xpath I need the property's real name (e.g. Foo). I thought I'd found it in JsonPropertyInfo.MemberName in the debugger, but it turns out that's internal.

Similarly, there doesn't seem to be a way to get the name of the property a schema component is being written as.

Am I missing something on where this information is kept, or do I need to use a document transformer to try and piggy-back some additional data through to read later in the schema transformer?

@captainsafia
Copy link
Member

Still digging, but I think it's an issue in Microsoft.OpenApi - the OpenApiSchema is cloned here, but the clone doesn't seem to come back with the right Example for arrays or objects...

Thanks for digging! There have been other bugs related to the copy constructors in OpenAPI.NET in the past. Perhaps this is an area where we can contribute improved tests in the library...I'll chime in in the issue that you opened.

OpenApiSchemaTransformerContext.JsonPropertyInfo.Name is the serialized name (e.g. foo), but to build up the xpath I need the property's real name (e.g. Foo). I thought I'd found it in JsonPropertyInfo.MemberName in the debugger, but it turns out that's internal.

I ran into this issue as well in my implementation and ended up applying a workaround to do lookups by property name in the transformer. Perhaps we should open an issue on the runtime repo about making MemberName public for these kinds of lookups? FWIW, I don't think it'll make it into .NET 9 at this point in the release but we might be able to learn of better alternatives as well.

Similarly, there doesn't seem to be a way to get the name of the property a schema component is being written as.

I think you're referring to the schema ID here? If so, you can use the CreateSchemaReferenceId API we introduced into OpenApiOptions to get the desired result.

Out of curiousity, what scenario requires that you know the schema ID (assuming I understood that part correctly)?

@martincostello
Copy link
Member Author

Perhaps this is an area where we can contribute improved tests in the library

I added a few related to the specific bug, but feel free to suggest any other areas that might be lacking.

Perhaps we should open an issue on the runtime repo about making MemberName public for these kinds of lookups?

Yep, I'll open something shortly.

I think you're referring to the schema ID here

Yep, my thinking was to use it to get the class name backing the schema so I could get the summary text for the class out of the XML documentation to set the description. I'll see if CreateSchemaReferenceId() gets me there, rather than having to reverse engineer it from the declaring type of the properties, though I guess I'd still need that for the XML case to be able to get the type name too?

@captainsafia
Copy link
Member

Yep, my thinking was to use it to get the class name backing the schema so I could get the summary text for the class out of the XML documentation to set the description. I'll see if CreateSchemaReferenceId() gets me there, rather than having to reverse engineer it from the declaring type of the properties, though I guess I'd still need that for the XML case to be able to get the type name too?

Oh, I see. In that case, isn't JsonPropertyInfo.PropertyType.Name or JsonTypeInfo.Type.Name sufficient for figuring out the class name?

@martincostello
Copy link
Member Author

I may have gotten confused somewhere so I'll need to check again, but in this example:

"Foo" : {
  "bar": { ... } <- an OpenAPI string definition
}

I was getting calls into the schema about the properties for Foo's schema, and the information about what type it was, but I wasn't explicitly getting the key value for "Foo".

If options.CreateSchemaReferenceId(schema) is the way to get that value (instead of assuming it's the name of the .NET type), then that's fine 👍

@martincostello
Copy link
Member Author

Yep, I'll open something shortly.

Raised dotnet/runtime#105155.

@captainsafia
Copy link
Member

If options.CreateSchemaReferenceId(schema) is the way to get that value (instead of assuming it's the name of the .NET type), then that's fine 👍

Ah, I see. You should be able to access this via options.CreateSchemaReferenceId(Type) where Type is the declaring Type. But I will say, one of the goals with the implementation was to avoid you having to rely on schema reference IDs in the transformer. The goal being that when you interact with schema transformers you are looking at the "resolved" schema.

How are you using the schema reference ID in your scenario?

@martincostello
Copy link
Member Author

I probably just got confused and should have just been using JsonTypeInfo.Type all along.

I'll try out my hacky code again and see where I went wrong, but it's good to know that if there's a reason someone really needs to know the key that goes with the schema for the component they can get hold of it.

@martincostello
Copy link
Member Author

martincostello commented Jul 19, 2024

So I think I've found the source of my confusion - either I'm being stupid, or this is a bug.

I've pushed the code up here if you want to take a look: martincostello/api@12237af

It seems like when I get the first call into my transformer, which is the schema for a the full containing type TimeResponse, instead of getting the type info for that, I instead get the property info for the first property universalFull which is a string. This is then returned 5 calls later as the schema for that property itself.

This then leads me to incorrectly assign the summary for the last property in the schema for the object as the summary for the schema object itself.

Feels like there's a bug and the context for the top-level schema objects is incorrect.

This is the TimeResponse schema I get from that commit:

      "TimeResponse": {
        "type": "object",
        "properties": {
          "timestamp": {
            "type": "string",
            "description": "The timestamp for the response for which the times are generated.",
            "format": "date-time"
          },
          "rfc1123": {
            "type": "string",
            "description": "The current UTC date and time in RFC1123 format."
          },
          "unix": {
            "type": "integer",
            "description": "The number of seconds since the UNIX epoch.",
            "format": "int64"
          },
          "universalSortable": {
            "type": "string",
            "description": "The current UTC date and time in universal sortable format."
          },
          "universalFull": {
            "type": "string",
            "description": "The current UTC date and time in universal full format."
          }
        },
        "description": "The current UTC date and time in universal full format."
      }

@captainsafia
Copy link
Member

Feels like there's a bug and the context for the top-level schema objects is incorrect.

Hmmm....I'm getting vibes here too...

Mind filling a new issue for this and I can take a look?

@martincostello
Copy link
Member Author

#56899

@martincostello
Copy link
Member Author

With the fix for #56899 I think everything in the feedback is addressed now. I just need to resolve the Kiota-related warnings my spec creates.

Excluding the changes I need from microsoft/OpenAPI.NET#1736 to make functionality equivalent, every difference between my OpenAPI schema and the NSwag equivalent has a justification for now.

@martincostello
Copy link
Member Author

For reference, this is what I came up with to populate the description for OpenAPI schemas from the XML comments until there's something in-box:

using System.Collections.Concurrent;
using System.Reflection;
using System.Text.Json.Serialization.Metadata;
using System.Xml;
using System.Xml.XPath;
using Microsoft.OpenApi.Models;

namespace Microsoft.AspNetCore.OpenApi;

internal sealed class AddSchemaDescriptionsTransformer : IOpenApiSchemaTransformer
{
    private readonly Assembly _thisAssembly = typeof(AddSchemaDescriptionsTransformer).Assembly;
    private readonly ConcurrentDictionary<string, string?> _descriptions = [];
    private XPathNavigator? _navigator;

    public Task TransformAsync(
        OpenApiSchema schema,
        OpenApiSchemaTransformerContext context,
        CancellationToken cancellationToken)
    {
        if (schema.Description is null &&
            GetMemberName(context.JsonTypeInfo, context.JsonPropertyInfo) is { Length: > 0 } memberName &&
            GetDescription(memberName) is { Length: > 0 } description)
        {
            schema.Description = description;
        }

        return Task.CompletedTask;
    }

    private string? GetDescription(string memberName)
    {
        if (_descriptions.TryGetValue(memberName, out string? description))
        {
            return description;
        }

        var navigator = CreateNavigator();
        var node = navigator.SelectSingleNode($"/doc/members/member[@name='{memberName}']/summary");

        if (node is not null)
        {
            description = node.Value.Trim();
        }

        _descriptions[memberName] = description;

        return description;
    }

    private string? GetMemberName(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo)
    {
        if (typeInfo.Type.Assembly != _thisAssembly &&
            propertyInfo?.DeclaringType.Assembly != _thisAssembly)
        {
            return null;
        }
        else if (propertyInfo is not null)
        {
            string? typeName = propertyInfo.DeclaringType.FullName;
            string propertyName =
                propertyInfo.AttributeProvider is PropertyInfo property ?
                property.Name :
                $"{char.ToUpperInvariant(propertyInfo.Name[0])}{propertyInfo.Name[1..]}";

            return $"P:{typeName}{Type.Delimiter}{propertyName}";
        }
        else
        {
            return $"T:{typeInfo.Type.FullName}";
        }
    }

    private XPathNavigator CreateNavigator()
    {
        if (_navigator is null)
        {
            string path = Path.Combine(AppContext.BaseDirectory, $"{_thisAssembly.GetName().Name}.xml");
            using var reader = XmlReader.Create(path);
            _navigator = new XPathDocument(reader).CreateNavigator();
        }

        return _navigator;
    }
}

@martincostello
Copy link
Member Author

I just need to resolve the Kiota-related warnings my spec creates.

microsoft/OpenAPI.NET#1738

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants