RicoSuter / NJsonSchema

JSON Schema reader, generator and validator for .NET
http://NJsonSchema.org
MIT License
1.38k stars 535 forks source link

Type mapping a generic class to the wrapped type #1046

Open piaste opened 5 years ago

piaste commented 5 years ago

(Originally posted in https://github.com/RicoSuter/NSwag/issues/2362)

What I'm using:

The API Explorer generator middleware, version 13.0.4

What I'm trying to do:

My classes make extensive use of the Option<T> class, which through a JSON.NET custom converter de/serializes to either T or null.

I would like the generated OpenAPI spec to match this expectation, that is, the type:

type MyRecord = {
    A : string
    B : Option<string>
}

should be represented as

type: object
properties:
    a: 
        type: string
    b: 
        type: string
        nullable: true

(By default, NSwag represents the class as FSharpOptionOfString, FSharpOptionOfBoolean, etc.)

What I've done:

Added a custom type mapper for the generic type (I've seen in the code that NSwag does check for generics):

settings.TypeMappers.Add {

        new TypeMappers.ITypeMapper with 

            // Maps the generic type definition of Option<T>
            member this.MappedType = typedefof<Option<_>>

            // Should *not* be added to a reference in the OpenApi file
            member this.UseReference = false

            member this.GenerateSchema(schema, context) = 

                // Get the first and only type argument to Option<T>
                let wrappedType = context.Type.GetGenericArguments().[0]

                // Generate the ordinary schema for T
                context.JsonSchemaGenerator.Generate(schema, wrappedType, context.JsonSchemaResolver)

                // Add the "nullable: true" property to the schema
                schema.IsNullableRaw <- Nullable true
    }

What happens:

The GenerateSchema method above executes without error, and inspecting the schema object it looks exactly as I expected (Type: string, IsNullable: true).

However, the following error happens immediately after, and I've been unable to catch it in debug or pinpoint the problem:

System.InvalidOperationException: Could not find the JSON path of a referenced schema: Manually referenced schemas must be added to the 'Definitions' of a parent schema.
   at IReadOnlyDictionary<object, string> NJsonSchema.JsonPathUtilities.GetJsonPaths(object rootObject, IEnumerable<object> searchedObjects, IContractResolver contractResolver)
   at void NJsonSchema.JsonSchemaReferenceUtilities.UpdateSchemaReferencePaths(object rootObject, bool removeExternalReferences, IContractResolver contractResolver)
   at string NJsonSchema.Infrastructure.JsonSchemaSerialization.ToJson(object obj, SchemaType schemaType, IContractResolver contractResolver, Formatting formatting)
   at string NSwag.OpenApiDocument.ToJson(SchemaType schemaType, Formatting formatting)
   at async Task<string> NSwag.AspNetCore.Middlewares.OpenApiDocumentMiddleware.GetDocumentAsync(HttpContext context)
   at async Task NSwag.AspNetCore.Middlewares.OpenApiDocumentMiddleware.Invoke(HttpContext context)
   at async Task Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke(HttpContext httpContext)
   at async Task Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.Invoke(HttpContext httpContext)
   at async Task Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequests<TContext>(IHttpApplication<TContext> application)

What I've tried:

piaste commented 5 years ago

I've found an alternative approach that seems to work: instead of adding a TypeMapper, I can provide a custom ReflectionService that returns the type description for T with IsNullable = true when asked to describe Option<T>.

Hacking into the reflection seems a bit overkill though, the TypeMapper should be the right approach - it shouldn't be doing anything that PrimitiveTypeMapper isn't already doing.

baleeds commented 4 years ago

@piaste I'm trying to do this exact thing. Do you mind sharing your ReflectionService solution, if available?

piaste commented 4 years ago

@baleeds

Sure, it's just a few lines of code:

type OptionReflectionService internal () = 
    inherit DefaultReflectionService()

    override __.GetDescription (contextualType, defaultReferenceTypeNullHandling, settings) = 

        if contextualType.OriginalType.IsGenericType && 
           contextualType.OriginalType.GetGenericTypeDefinition() = typedefof<Option<_>> then

            let wrappedType = contextualType.OriginalType.GetGenericArguments().[0]
            let wrappedContextualType = Namotion.Reflection.ContextualTypeExtensions.ToContextualType(wrappedType)
            let wrappedDesc = base.GetDescription(wrappedContextualType, defaultReferenceTypeNullHandling, settings)
            wrappedDesc.IsNullable <- true
            wrappedDesc

        else

            base.GetDescription(contextualType, defaultReferenceTypeNullHandling, settings)
baleeds commented 4 years ago

@piaste Thank you for that! It was very helpful. 🥇

reinux commented 2 years ago

I came across this problem too, but I'm beginning to think reflection might be the correct approach here, if you need to deal with discriminated unions in general.

Only issue is that I don't know how extensible this approach is, since you can't really "chain" reflection services without inheritance.

kemsky commented 7 months ago

Custom ReflectionService does not solve #1302

vzam commented 4 days ago

I had the same issue but the suggestion solution did not work for me because I had Ts which were reference types, which led to #1302. My solution was to create a custom generator:

public class MySchemaGenerator : OpenApiSchemaGenerator
{
    /// <inheritdoc />
    public MyJsonSchemaGenerator(OpenApiDocumentGeneratorSettings settings) : base(settings)
    {
    }

    /// <inheritdoc />
    public override TSchemaType GenerateWithReferenceAndNullability<TSchemaType>(
        ContextualType contextualType,
        bool isNullable,
        JsonSchemaResolver schemaResolver,
        Action<TSchemaType, JsonSchema>? transformation = null)
    {
        while (IsWrapperType(contextualType.Type))
        {
            contextualType = contextualType.OriginalGenericArguments[0];
        }

        return base.GenerateWithReferenceAndNullability(contextualType, isNullable: true, schemaResolver, transformation);

        static bool IsWrapperType(Type type)
        {
            return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Option<>);
        }
    }
}