Skip to content

Bug: OpenAPI 3.1 $ref siblings ($defs, $dynamicAnchor, $id) are silently dropped when parsing #2895

@aqeelat

Description

@aqeelat

Describe the bug

When parsing an OpenAPI 3.1+ document, OpenApiV31Deserializer.LoadSchema short-circuits as soon as it sees a $ref: it returns an OpenApiSchemaReference and never runs ParseMap over the remaining sibling keywords. The only sibling data captured on that path is whatever Reference.SetMetadataFromJsonObject extracts, which (via JsonSchemaReference.SetAdditional31MetadataFromMapNode) is a hard-coded allow-list of annotations — title, deprecated, readOnly, writeOnly, default, examples, extensions. Every other sibling keyword — $defs, $dynamicAnchor, $dynamicRef, $id, $anchor, $vocabulary, $comment, and the rest of the JSON Schema 2020-12 vocabulary — is never parsed into the object model at all. The OpenApiSchemaReference accessors then delegate to Target, so callers observe Target's values (or null) instead of the sibling declarations present in the source document.

The keywords in question are JSON Schema 2020-12 keywords. OpenAPI 3.0 does not recognize them, and — independently — the 3.0 spec requires $ref siblings to be ignored, so dropping them on the 3.0 path is correct. The bug is specific to the 3.1+ deserializer: 3.1 / JSON Schema 2020-12 makes $ref a regular keyword whose siblings are fully valid, yet the 3.1 parser still skips them when $ref is present.

Related prior issues:

This issue is about JSON Schema 2020-12 keyword siblings being dropped when they should be preserved, on 3.1+ documents.

OpenApi File To Reproduce

openapi: 3.1.0
info:
  title: Sibling preservation repro
  version: 1.0.0
paths: {}
components:
  schemas:
    Target:
      type: object
      properties:
        name:
          type: string
    Referencing:
      $ref: '#/components/schemas/Target'
      description: Sibling description
      $dynamicAnchor: anchor
      $defs:
        sibling:
          $dynamicAnchor: inner
          $ref: '#/components/schemas/Target'

Steps to reproduce

using Microsoft.OpenApi;
using Microsoft.OpenApi.Reader;

var settings = new OpenApiReaderSettings();
settings.AddYamlReader();

var yaml = """
openapi: 3.1.0
info:
  title: Sibling preservation repro
  version: 1.0.0
paths: {}
components:
  schemas:
    Target:
      type: object
      properties:
        name:
          type: string
    Referencing:
      $ref: '#/components/schemas/Target'
      description: Sibling description
      $dynamicAnchor: anchor
      $defs:
        sibling:
          $dynamicAnchor: inner
          $ref: '#/components/schemas/Target'
""";

using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml));
var result = await OpenApiDocument.LoadAsync(stream, "yaml", settings);
var referencing = result.Document.Components!.Schemas["Referencing"];

Console.WriteLine($"Type:                  {referencing.GetType().Name}");
Console.WriteLine($"Is OpenApiSchemaReference: {referencing is OpenApiSchemaReference}");
Console.WriteLine($"Description (sibling): {referencing.Description ?? "(null)"}");
Console.WriteLine($"DynamicAnchor (sibling): {referencing.DynamicAnchor ?? "(null)"}");
Console.WriteLine($"Definitions count:     {referencing.Definitions?.Count ?? 0}");
if (referencing.Definitions != null)
    foreach (var kvp in referencing.Definitions)
        Console.WriteLine($"  $defs.{kvp.Key}: anchor={kvp.Value.DynamicAnchor ?? "(null)"}");

Verified against Microsoft.OpenApi 3.7.0 and Microsoft.OpenApi.YamlReader 3.7.0.

Expected behavior

Per OpenAPI 3.1 / JSON Schema 2020-12, $ref is no longer a special keyword that overrides siblings. All sibling keywords MUST be preserved on the referencing schema and accessible through the object model:

Type:                  OpenApiSchemaReference
Is OpenApiSchemaReference: True
Description (sibling): Sibling description
DynamicAnchor (sibling): anchor
Definitions count:     1
  $defs.sibling: anchor=inner

Actual behavior

Type:                  OpenApiSchemaReference
Is OpenApiSchemaReference: True
Description (sibling): Sibling description   ← preserved (per #2369)
DynamicAnchor (sibling): (null)              ← LOST
Definitions count:     0                      ← LOST (delegates to Target.Definitions)

The sibling values aren't merely hidden by delegation — they were never parsed. OpenApiV31Deserializer.LoadSchema returns an OpenApiSchemaReference immediately on $ref (Reader/V31/OpenApiSchemaDeserializer.cs:452) and skips ParseMap entirely, so the fixed-field entries that recognize $defs / $dynamicAnchor / $id / $anchor / $vocabulary / $comment (lines 24-50) never execute. OpenApiSchemaReference's accessors then delegate to Target, which is why Target's (absent) values surface instead.

Screenshots/Code Snippets

The deeper impact is observable on the JSON Schema generics pattern (Pattern B from the $dynamicRef ecosystem). The fixture below is the canonical "reusable paginated response" pattern, validator-backed by Hyperjump:

components:
  schemas:
    User: { type: object, properties: { id: { type: string } } }
    PaginatedTemplate:
      $defs:
        itemType:
          $dynamicAnchor: itemType
          not: {}                                  # placeholder slot
      type: object
      properties:
        items:
          type: array
          items:
            $dynamicRef: '#itemType'              # template slot
    PaginatedUserResponse:
      $ref: '#/components/schemas/PaginatedTemplate'
      $defs:
        itemType:
          $dynamicAnchor: itemType
          $ref: '#/components/schemas/User'       # binding for User

After parsing:

  • PaginatedUserResponse is an OpenApiSchemaReference with Target = PaginatedTemplate.
  • PaginatedUserResponse.Definitions returns PaginatedTemplate.Definitions (the placeholder slot), not the User-bound sibling.
  • There is no API path to reach the User-bound $defs.itemType entry. The dynamic-scope binding is unreachable.

This blocks Kiota (and any other downstream tool using Microsoft.OpenApi) from emitting class PaginatedUserResponse : PaginatedTemplate<User> from this fixture.

Additional context

Spec reference

  • OpenAPI 3.1: "The OpenAPI Schema Object is a superset of the JSON Schema Specification Draft 2020-12." — https://spec.openapis.org/oas/v3.1.1#schema-object
  • JSON Schema 2020-12 §4.7: $ref is a regular applicator keyword; siblings are no longer ignored. The 3.0 "When a $ref is used, all other properties SHALL be ignored" rule does not appear in 3.1.
  • JSON Schema 2020-12 §7.7: $dynamicAnchor / $dynamicRef / $defs are first-class keywords that participate in dynamic scope resolution alongside $ref.

Why this matters

The JSON Schema generics pattern (a template schema with $dynamicRef slots bound by sibling $defs in referencing schemas) is one of two primary real-world uses of $dynamicRef. It cannot be implemented on top of Microsoft.OpenApi 3.7.0 because the binding information is dropped at parse time.

A cross-generator compatibility matrix is tracked at https://github.com/aqeelat/openapi-dynamicref-adoption-tracker — multiple tools are affected by this pattern, and Microsoft.OpenApi is the upstream blocker for all .NET-based tools (Kiota today; potentially others).

Environment

  • Microsoft.OpenApi 3.7.0
  • Microsoft.OpenApi.YamlReader 3.7.0
  • .NET SDK 10.0.301
  • Verified on macOS arm64; behavior is platform-independent

Willingness to contribute a PR

I will open a PR soon.


This issue was drafted with assistance from AI tooling. The submitter is responsible for reviewing and validating the contents before submission.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions