RicoSuter / NSwag

The Swagger/OpenAPI toolchain for .NET, ASP.NET Core and TypeScript.
http://NSwag.org
MIT License
6.7k stars 1.24k forks source link

Allow Custom Value Object Type Mapping in C# -> Swagger -> C# #2404

Open lundmikkel opened 5 years ago

lundmikkel commented 5 years ago

I have an API that uses Noda Time types in both input and output. The types are serialized to strings in the JSON using the default Noda Time serialization format (which basically is the ISO-8601 format).

I have an object looking something like this:

public class NodaTimeDataStructure
{
    public System.DateTime DateTime { get; set; }
    public DateInterval DateInterval { get; set; }
    public DateTimeZone DateTimeZone { get; set; }
    public Duration Duration { get; set; }
    public Instant Instant { get; set; }
    public Interval Interval { get; set; }
    public IsoDayOfWeek IsoDayOfWeek { get; set; }
    public LocalDate LocalDate { get; set; }
    public LocalDateTime LocalDateTime { get; set; }
    public LocalTime LocalTime { get; set; }
    public Offset Offset { get; set; }
    public OffsetDate OffsetDate { get; set; }
    public OffsetDateTime OffsetDateTime { get; set; }
    public OffsetTime OffsetTime { get; set; }
    public Period Period { get; set; }
    public ZonedDateTime ZonedDateTime { get; set; }
}

This will normally result in the following Swagger JSON:

"NodaTimeDataStructure": {
  "type": "object",
  "additionalProperties": false,
  "required": [
    "dateTime", "duration", "instant", "interval", "isoDayOfWeek", "localDate", "localDateTime",
    "localTime", "offset", "offsetDate", "offsetDateTime", "offsetTime", "zonedDateTime"
  ],
  "properties": {
    "dateTime":       { "type": "string", "format": "date-time" },
    "instant":        { "type": "string", "format": "date-time" },
    "zonedDateTime":  { "type": "string", "format": "date-time" },
    "offsetDateTime": { "type": "string", "format": "date-time" },
    "localDateTime":  { "type": "string", "format": "date-time" },
    "localDate":      { "type": "string", "format": "date" },
    "localTime":      { "type": "string", "format": "time" },
    "duration":       { "type": "string", "format": "time-span" },
    "dateInterval":   { "type": "array", "items": { "type": "string", "format": "date" } },
    "dateTimeZone":   { "$ref": "#/definitions/DateTimeZone" },
    "interval":       { "$ref": "#/definitions/Interval" },
    "isoDayOfWeek":   { "$ref": "#/definitions/IsoDayOfWeek" },
    "offset":         { "$ref": "#/definitions/Offset" },
    "offsetDate":     { "$ref": "#/definitions/OffsetDate" },
    "offsetTime":     { "$ref": "#/definitions/OffsetTime" },
    "period":         { "$ref": "#/definitions/Period" }
  }
}

This makes it impossible to convert back to the right Noda Time types in a C# client. Apart from the many different types having the exact same format ("date-time") making a mapping impossible, certain types have unfortunate definitions. A DateInterval results in an array of "date", since it's an enumerable of LocalDate, but a simple start/end date format would work much better. Other methods are created with a $ref to very elaborate objects containing fields of absolutely no interest. Be aware that all of these should be serialized as simple strings (arguably not the intervals).

I am able to create my own Type Mappers and adding them to a AspNetCoreToSwaggerGeneratorSettings like this:

var nodaTimeTypeMappers = new[]
{
    CreateTypeMapper(typeof(DateInterval), "date-interval"),
    CreateTypeMapper(typeof(DateTimeZone), "date-time-zone"),
    CreateTypeMapper(typeof(Duration), "duration"),
    CreateTypeMapper(typeof(Instant), "instant"),
    CreateTypeMapper(typeof(Interval), "interval"),
    CreateTypeMapper(typeof(IsoDayOfWeek), "iso-day-of-week"),
    CreateTypeMapper(typeof(LocalDate), "local-date"),
    CreateTypeMapper(typeof(LocalDateTime), "local-date-time"),
    CreateTypeMapper(typeof(LocalTime), "local-time"),
    CreateTypeMapper(typeof(Offset), "offset"),
    CreateTypeMapper(typeof(OffsetDate), "offset-date"),
    CreateTypeMapper(typeof(OffsetDateTime), "offset-date-time"),
    CreateTypeMapper(typeof(OffsetTime), "offset-time"),
    CreateTypeMapper(typeof(Period), "period"),
    CreateTypeMapper(typeof(ZonedDateTime), "zoned-date-time"),
};

foreach (var typeMapper in nodaTimeTypeMappers)
{
    settings.TypeMappers.Add(typeMapper);
}

PrimitiveTypeMapper CreateTypeMapper(Type type, string name)
{
    return new PrimitiveTypeMapper(type, s =>
    {
        s.Type = JsonObjectType.String;
        s.Format = "noda-time-" + name;
    });
}

to get something like this:

"NodaTimeRequest": {
  "type": "object",
  "additionalProperties": false,
  "required": [
    "dateTime", "duration", "instant", "interval", "isoDayOfWeek", "localDate", "localDateTime",
    "localTime", "offset", "offsetDate", "offsetDateTime", "offsetTime", "zonedDateTime"
  ],
  "properties": {
    "dateTime":       { "type": "string", "format": "date-time" },
    "dateInterval":   { "type": "string", "format": "noda-time-date-interval" },
    "dateTimeZone":   { "type": "string", "format": "noda-time-date-time-zone" },
    "duration":       { "type": "string", "format": "noda-time-duration" },
    "instant":        { "type": "string", "format": "noda-time-instant" },
    "interval":       { "type": "string", "format": "noda-time-interval" },
    "isoDayOfWeek":   { "type": "string", "format": "noda-time-iso-day-of-week" },
    "localDate":      { "type": "string", "format": "noda-time-local-date" },
    "localDateTime":  { "type": "string", "format": "noda-time-local-date-time" },
    "localTime":      { "type": "string", "format": "noda-time-local-time" },
    "offset":         { "type": "string", "format": "noda-time-offset" },
    "offsetDate":     { "type": "string", "format": "noda-time-offset-date" },
    "offsetDateTime": { "type": "string", "format": "noda-time-offset-date-time" },
    "offsetTime":     { "type": "string", "format": "noda-time-offset-time" },
    "period":         { "type": "string", "format": "noda-time-period" },
    "zonedDateTime":  { "type": "string", "format": "noda-time-zoned-date-time" }
  }
}

This allows the formats to be used just like the existing formats ("date-time", "date", "time", "time-span"), but I can't for the love of God figure out how to make the swagger2csclient use those formats to properly convert back to the corresponding Noda Time types. Am I generally missing something or is this currently not possible?

lundmikkel commented 4 years ago

@RicoSuter Any hinters as how to proceed with this?

KostaMadorsky commented 4 years ago

@lundmikkel have you managed to solve this?

@RicoSuter could you suggest the right way to handle that? Setting Nodatime structs in the Primitive type mapping in NSwagStudio kinda works, however, it also breaks other mapping (e.g. if you set Date Time Type to Instant then all the System.DateTime will be generated as Instant).

lundmikkel commented 4 years ago

@KostaMadorsky No, I never found a proper solution, and @RicoSuter doesn't seem to reply :(

RicoSuter commented 3 years ago

Sorry for my late reply, had > 300 notifications etc. :-)

God figure out how to make the swagger2csclient use those formats to properly convert back to the corresponding Noda Time types

This is currently not supported via CLI.

Via NSwag/NJsonSchema packages and your own CLI in C# you should be able to implement and register your own MyCSharpTypeResolver : CSharpTypeResolver with extensions and handling for these new formats...

https://github.com/RicoSuter/NJsonSchema/blob/master/src/NJsonSchema.CodeGeneration.CSharp/CSharpTypeResolver.cs

However it's questionable whether this makes sense as it's a custom solution and would only work with your tooling... OpenAPI/JSON Schema is just not flexible enough so that you can fully describe all these types.

Pzixel commented 3 years ago

I'm currently have exactly same problem with my API. I've written a custom IOperationProcessor that registers custom formats. I've registered it like settings.OperationProcessors.Insert(0, new CustomTypesProcessor()); because I need it to work with any handler that receives any parameter of LocalDateTime.

But instead of replacing type processor it get populated to existing list so it looks like:

image

I would like it to work just like AnythingConverter from any library that does mapping/conversions/... like Json.Net/Automapper/EF/Mongo/...

Pzixel commented 3 years ago

After some investigation I found that Microsoft.AspNetCore.Mvc.ApiExplorer.IApiDescriptionProvider.DefaultApiDescriptionProvider.GetParameters is a culprit for this behavior. I'm debugging to see it if can be altered otherwise I think I will have to replace OperationParameterProcessor to work it around


The only viable way I see is implement TypeConverter to pass IsComplexType check as this comment suggests: https://github.com/dotnet/aspnetcore/blob/c272436a9e5dd42662d5f7eb5451c0eb0fc805c9/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs#L433-L441

Pzixel commented 3 years ago

I've ended up with following converter:

public class NodaLocalDateTimeTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
    {
        return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
    }

    public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
    {
        if (value is string s)
        {
            return LocalDateTime.FromDateTime(DateTime.Parse(s, culture));
        }
        return base.ConvertFrom(context, culture, value);
    }

    public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
    {
        return destinationType == typeof(string) || base.CanConvertTo(context, destinationType);
    }

    public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
    {
        if (destinationType == typeof(string) && value is LocalDateTime v)
        {
            return LocalDateTimePattern.GeneralIso.Format(v);
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

Which I registered globally at Startup.cs:

TypeDescriptor.AddAttributes(typeof(LocalDateTime), new TypeConverterAttribute(
                typeof(NodaLocalDateTimeTypeConverter)));

Now it passes the check IsPrimitiveType and doesn't get deconstructed by ApiExplorer so it does pass correctly no NSwag so I see a nice UI in my browser.

Thanks everybody for attention :)

schnerring commented 1 year ago

I've ended up with following converter:

No need for a custom converter. Noda Time includes default converters.

All you have to do is call the ConfigureForNodaTime extension method as described in Noda Times' serialization docs on the respective serializer options object:

Here's a sample for ASP.NET

builder
    .Services
    .AddControllers()
    .AddJsonOptions(config => config.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));