Skip to content

Complex Nullable types do not transfer/copy when using a SchemaTransformer #60223

Open
@KaydenMiller

Description

@KaydenMiller

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

When creating a complex type mapping using a schema transformer the values that are transformed do not copy to a nullable version of that type.

This is the extension method I am using to test this

    public static void MapType(this OpenApiOptions options, Type typeToMap, Func<OpenApiSchema> schemaFunc)
    {
        options.AddSchemaTransformer((schema, context, cancellationToken) =>
        {
            if (context.JsonTypeInfo.Type == typeToMap)
            {
                var targetSchema = schemaFunc();

                schema.Type = targetSchema.Type;
                schema.Pattern = targetSchema.Pattern;
            }

            return Task.CompletedTask;
        });
    }
      "NullableOfUserId": {
        "type": "object",
        "nullable": true
      },
      "UserId": {
        "pattern": "^guid-pattern$",
        "type": "string",
      },

Notice in this example that the value was transformed from type: object to type: string as well as the pattern: "^guid-pattern$" (yes I'm aware that's not a valid regex for a guid) are not copied to the NullabeOfUserId.

NOTE: Normally there would also be a properties property on these objects however, I omitted them for brevity and in an example like this you would likely convert the the string using a JsonConverter.

Expected Behavior

When adding a schema transformer I would expect the properties to copy to the nullable equivalent of the value.

    "NullableOfUserId": {
        "type": "object",
        "nullable": true
      },
      "UserId": {
        "pattern": "^guid-pattern$",
        "type": "string",
      },

should be

    "NullableOfUserId": {
        "pattern": "^guid-pattern$",
        "type": "string",
        "nullable": true
      },
      "UserId": {
        "pattern": "^guid-pattern$",
        "type": "string",
      },

Steps To Reproduce

Program.cs

using AspNetOpenApiRepro;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(options =>
{
    options.MapType(typeof(UserId), () =>
    {
        return new OpenApiSchema()
        {
            Type = "string",
            Pattern = "^guid-pattern$"
        };
    });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();

var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", Ok<WeatherForecast?> () =>
    {
        var forecast = Enumerable.Range(1, 5).Select(index =>
                new WeatherForecast
                (
                    DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                    Random.Shared.Next(-20, 55),
                    summaries[Random.Shared.Next(summaries.Length)],
                    null,
                    new UserId(Guid.NewGuid())
                ))
           .ToArray()
           .FirstOrDefault();
        return TypedResults.Ok<WeatherForecast?>(forecast);
    })
   .WithName("GetWeatherForecast");

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary, UserId? Id, UserId OtherId)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

public readonly record struct UserId(Guid Value);

Some extension class for making testing easier

using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi.Models;

namespace AspNetOpenApiRepro;

public static class OpenApiExtensions
{
    public static void MapType(this OpenApiOptions options, Type typeToMap, Func<OpenApiSchema> schemaFunc)
    {
        options.AddSchemaTransformer((schema, context, cancellationToken) =>
        {
            if (context.JsonTypeInfo.Type == typeToMap)
            {
                var targetSchema = schemaFunc();

                schema.Type = targetSchema.Type;
                schema.Pattern = targetSchema.Pattern;
            }

            return Task.CompletedTask;
        });
    }
}

Exceptions (if any)

N/A

.NET Version

9.0.102

Anything else?

Closest Issue

The most similar issue that I found was #59976 but that seemed like it was likely a different problem.

Full Open API Spec

{
  "openapi": "3.0.1",
  "info": {
    "title": "AspNetOpenApiRepro | v1",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://localhost:7164"
    },
    {
      "url": "http://localhost:5090"
    }
  ],
  "paths": {
    "/weatherforecast": {
      "get": {
        "tags": [
          "AspNetOpenApiRepro"
        ],
        "operationId": "GetWeatherForecast",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeatherForecast"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "NullableOfUserId": {
        "type": "object",
        "properties": {
          "value": {
            "type": "string",
            "format": "uuid"
          }
        },
        "nullable": true
      },
      "UserId": {
        "pattern": "^guid-pattern$",
        "type": "string",
        "properties": {
          "value": {
            "type": "string",
            "format": "uuid"
          }
        }
      },
      "WeatherForecast": {
        "required": [
          "date",
          "temperatureC",
          "summary",
          "id",
          "otherId"
        ],
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "temperatureC": {
            "type": "integer",
            "format": "int32"
          },
          "summary": {
            "type": "string",
            "nullable": true
          },
          "id": {
            "$ref": "#/components/schemas/NullableOfUserId"
          },
          "otherId": {
            "$ref": "#/components/schemas/UserId"
          },
          "temperatureF": {
            "type": "integer",
            "format": "int32"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "AspNetOpenApiRepro"
    }
  ]
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-openapi

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions