RicoSuter / NSwag

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

Fully Qualified Type Names #2728

Open hounddog22030 opened 4 years ago

hounddog22030 commented 4 years ago

I am generating a single client and might have collisions on Type with too many using statements. Is there a way to generate using fully qualified type names?

grantph commented 4 years ago

When fully qualified names are present in the Swagger, NSwag only uses the last segment for Types.

Example:

ServiceA.Model.MyModel ===> MyModel ServiceB.Model.MyModel ===> MyModel

And a conflict is created.

To generate fully qualified names with Swashbuckle and replicate this behavior, add a call to UseFullTypeNameInSchemaIds().

config.EnableSwagger(c =>
{ 
    // Enable Fully Qualified Names in Swagger
    c.UseFullTypeNameInSchemaIds();
});

This behavior is caused by the DefaultTypeNameGenerator in the NJsonSchema project, and not the NSwag project.

We were able to workaround this limitation by making 2 changes to the NJsonSchema project.

Modify Generate(JsonSchema schema, string typeNameHint) to return fully qualified type

From

        protected virtual string Generate(JsonSchema schema, string typeNameHint)
        {
            if (string.IsNullOrEmpty(typeNameHint) && schema.HasTypeNameTitle)
            {
                typeNameHint = schema.Title;
            }

            var lastSegment = typeNameHint?.Split('.').Last();
            return ConversionUtilities.ConvertToUpperCamelCase(lastSegment ?? "Anonymous", true);
        }

To

        protected virtual string Generate(JsonSchema schema, string typeNameHint)
        {
            if (string.IsNullOrEmpty(typeNameHint) && schema.HasTypeNameTitle)
            {
                typeNameHint = schema.Title;
            }

            return typeNameHint;
            //var lastSegment = typeNameHint?.Split('.').Last();
            //return ConversionUtilities.ConvertToUpperCamelCase(lastSegment ?? "Anonymous", true);
        }

This change returns fully qualified names, however, it doesn't handle complex types such as ValueTuple<>. There will also be an impact on the use of "Anonymous" which we haven't had time to explore.

Before change

public async System.Threading.Tasks.Task<MyModel> GetAsync(...

After change

public async System.Threading.Tasks.Task<ServiceA.Model.MyModel> GetAsync(...

I find it interesting that a lot of NSwag code generation is fully qualified (like System.Threading.Tasks.Task above), yet the swagger types are not. This seems inconsistent.

Modify Generate(JsonSchema schema, string typeNameHint, IEnumerable reservedTypeNames) to handle complex types

From

            typeNameHint = (typeNameHint ?? "")
                .Replace("[", " Of ")
                .Replace("]", " ")
                .Replace("<", " Of ")
                .Replace(">", " ")
                .Replace(",", " And ")
                .Replace("  ", " ");

To

            typeNameHint = (typeNameHint ?? "")
                .Replace("[", "<")
                .Replace("]", ">")
                //.Replace("<", " Of ")
                //.Replace(">", " ")
                //.Replace(",", " And ")
                .Replace("  ", " ");

Example Tuple

System.ValueTuple<ServiceA.Model.MyModel,ServiceB.Model.MyModel>

or short-hand

(ServiceA.Model.MyModel, ServiceB.Model.MyModel)

Before change

public async System.Threading.Tasks.Task<ValueTupleOfMyModelAndMyModel> GetAsync(...

After change

public async System.Threading.Tasks.Task<System.ValueTuple<ServiceA.Model.MyModel, ServiceB.Model.MyModel>> GetAsync(...

I would image that switching from "Last Segment" to "Fully Qualified" is a major change that will require full regression testing of NSwag, NJsonSchema, and other related projects, SO - we haven't created any official submission to the NJsonSchema project yet.

Perhaps @RicoSuter can comment on this?

RicoSuter commented 3 years ago

Namespaces should not be exposed in OpenAPI as it is a leaky abstraction - ie some consumers do not know namespaces, eg TypeScript... You can change this behavior with a custom TypeNameGenerator but you'd need to write your own CLI based on the NSwag pkgs.

jdimmerman commented 3 years ago

@RicoSuter Perhaps another option is to enable the application of schema processors via attributes, for generating code in a Client project at build time that references the API project, and therefore piggybacks off of its NSwag attributes.

Climax85 commented 2 years ago

I ran in the same problem so I don't want to open a new issue. I order my code by features and each feature has it's own dto's. So the same dto-name may appear in different features. Thus I need to have the fullname of the dto's generated in the c# client. By the way I share the dto's with my blazor client so I disabled the generation of the dto's.

Following your advice, @RicoSuter, I created a custom TypeNameGenerator. To have the namespace available I also needed to create a custom SchemaNameGenerator:

public class CustomSchemaNameGenerator : ISchemaNameGenerator
{
    public string Generate(Type type)
    {
        return type.FullName?.Replace(".", "_");
    }
}

public class CustomTypeNameGenerator : DefaultTypeNameGenerator
{
    public override string Generate(JsonSchema schema, string typeNameHint, IEnumerable<string> reservedTypeNames)
    {
        var typeName = Generate(schema, typeNameHint).Replace("_", ".");
        Console.WriteLine($"typeName: {typeName}");
        return typeName;
    }
}

After generation with those custom classes I get a specification.json with the namespace (example):

"schema": {
    "$ref": "#/components/schemas/Application.Shared.UseCases.Employee.CreateEmployeeCommand"
}

By looking at the CustomSchemaNameGenerator I would have expected to see the . replaced by _? Why is the CustomTypeNameGenerator affecting the schema?

The generated client on the other hand looks like this:

[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.15.7.0 (NJsonSchema v10.6.7.0 (Newtonsoft.Json v12.0.0.0))")]
public partial interface IEmployeeClient
{
    System.Threading.Tasks.Task<int> CreateAsync(CreateEmployeeCommand command);
}

why isn't the namespace prefixed to the CreateEmployeeCommand?

The Console.WriteLine($"typeName: {typeName}"); in the CustomTypeNameGenerator outputs:

typeName: Application.Shared.UseCases.Employee.CreateEmployeeCommand

If I remove the .Replace("_", "."); in the CustomTypeNameGenerator the generated client looks like this:

System.Threading.Tasks.Task<int> CreateAsync(Application_Shared_UseCases_Employee_CreateEmployeeCommand command);

So it seems to be working fine. Why is the output changed by the generation of the client? How can I fix this?

shainegordon commented 2 years ago

same issue here, we use "wrapper" classes for our features, so all our responses are called "Model"

Interestingly enough, I can get NSwag.MSBuild to generate my specification.json with a FQN, e.g. - "$ref": "#/components/schemas/VerticalSliceArchitecture.Features.Customers.GetAllCustomers.Model" "$ref": "#/components/schemas/VerticalSliceArchitecture.Features.Customers.GetCustomerById.Model"

But the C# client that is generated (That I want to use in Blazor WASM), proceeds to use Model and Model2.

What is very interesting is that if I use NSwag.CodeGeneration.CSharp, and point it to the specification.json built with NSwag.MSBuild, then it DOES use the FQN

services.AddOpenApiDocument(configure =>
{
     configure.Title = "Vertical Slice Architecture API";
     configure.SchemaNameGenerator = new CustomSchemaNameGenerator();
     configure.TypeNameGenerator = new CustomTypeNameGenerator();
});

config.nswag

{
  "runtime": "Net50",
  "defaultVariables": null,
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "project": "VerticalSliceArchitecture.Api.csproj",
      "msBuildProjectExtensionsPath": null,
      "configuration": null,
      "runtime": null,
      "targetFramework": null,
      "noBuild": true,
      "msBuildOutputPath": null,
      "verbose": true,
      "workingDirectory": null,
      "requireParametersWithoutDefault": false,
      "apiGroupNames": null,
      "defaultPropertyNameHandling": "Default",
      "defaultReferenceTypeNullHandling": "Null",
      "defaultDictionaryValueReferenceTypeNullHandling": "NotNull",
      "defaultResponseReferenceTypeNullHandling": "NotNull",
      "generateOriginalParameterNames": true,
      "defaultEnumHandling": "Integer",
      "flattenInheritanceHierarchy": false,
      "generateKnownTypes": true,
      "generateEnumMappingDescription": false,
      "generateXmlObjects": false,
      "generateAbstractProperties": false,
      "generateAbstractSchemas": true,
      "ignoreObsoleteProperties": false,
      "allowReferencesWithProperties": false,
      "excludedTypeNames": [],
      "serviceHost": null,
      "serviceBasePath": null,
      "serviceSchemes": [],
      "infoTitle": "My Title",
      "infoDescription": null,
      "infoVersion": "1.0.0",
      "documentTemplate": null,
      "documentProcessorTypes": [],
      "operationProcessorTypes": [],
      "typeNameGeneratorType": null,
      "schemaNameGeneratorType": "CustomSchemaNameGenerator",
      "contractResolverType": null,
      "serializerSettingsType": null,
      "useDocumentProvider": true,
      "documentName": "v1",
      "aspNetCoreEnvironment": null,
      "allowNullableBodyParameters": true,
      "useHttpAttributeNameAsOperationId": false,
      "output": "wwwroot/api/v1/specification.json",
      "outputType": "OpenApi3",
      "newLineBehavior": "Auto",
      "assemblyPaths": [],
      "assemblyConfig": null,
      "referencePaths": [],
      "useNuGetCache": false
    }
  },
  "codeGenerators": {
    "openApiToCSharpClient": {
      "clientBaseClass": null,
      "configurationClass": null,
      "generateClientClasses": true,
      "generateClientInterfaces": true,
      "clientBaseInterface": null,
      "injectHttpClient": true,
      "disposeHttpClient": true,
      "protectedMethods": [],
      "generateExceptionClasses": true,
      "exceptionClass": "ApiException",
      "wrapDtoExceptions": true,
      "useHttpClientCreationMethod": false,
      "httpClientType": "System.Net.Http.HttpClient",
      "useHttpRequestMessageCreationMethod": false,
      "useBaseUrl": false,
      "generateBaseUrlProperty": true,
      "generateSyncMethods": false,
      "generatePrepareRequestAndProcessResponseAsAsyncMethods": false,
      "exposeJsonSerializerSettings": false,
      "clientClassAccessModifier": "public",
      "typeAccessModifier": "public",
      "generateContractsOutput": false,
      "contractsNamespace": null,
      "contractsOutputFilePath": null,
      "parameterDateTimeFormat": "s",
      "parameterDateFormat": "yyyy-MM-dd",
      "generateUpdateJsonSerializerSettingsMethod": true,
      "useRequestAndResponseSerializationSettings": false,
      "serializeTypeInformation": true,
      "queryNullValue": "",
      "className": "{controller}Client",
      "operationGenerationMode": "MultipleClientsFromOperationId",
      "additionalContractNamespaceUsages": [],
      "generateOptionalParameters": false,
      "generateJsonMethods": false,
      "enforceFlagEnums": false,
      "parameterArrayType": "System.Collections.Generic.IEnumerable",
      "parameterDictionaryType": "System.Collections.Generic.IDictionary",
      "responseArrayType": "System.Collections.Generic.ICollection",
      "responseDictionaryType": "System.Collections.Generic.IDictionary",
      "wrapResponses": false,
      "wrapResponseMethods": [],
      "generateResponseClasses": true,
      "responseClass": "SwaggerResponse",
      "namespace": "VerticalSliceArchitecture.Frontend",
      "requiredPropertiesMustBeDefined": true,
      "dateType": "System.DateTimeOffset",
      "jsonConverters": null,
      "anyType": "object",
      "dateTimeType": "System.DateTimeOffset",
      "timeType": "System.TimeSpan",
      "timeSpanType": "System.TimeSpan",
      "arrayType": "System.Collections.Generic.ICollection",
      "arrayInstanceType": "System.Collections.ObjectModel.Collection",
      "dictionaryType": "System.Collections.Generic.IDictionary",
      "dictionaryInstanceType": "System.Collections.Generic.Dictionary",
      "arrayBaseType": "System.Collections.ObjectModel.Collection",
      "dictionaryBaseType": "System.Collections.Generic.Dictionary",
      "classStyle": "Poco",
      "jsonLibrary": "NewtonsoftJson",
      "generateDefaultValues": true,
      "generateDataAnnotations": true,
      "excludedTypeNames": [],
      "excludedParameterNames": [],
      "handleReferences": false,
      "generateImmutableArrayProperties": false,
      "generateImmutableDictionaryProperties": false,
      "jsonSerializerSettingsTransformationMethod": null,
      "inlineNamedArrays": false,
      "inlineNamedDictionaries": false,
      "inlineNamedTuples": true,
      "inlineNamedAny": false,
      "generateDtoTypes": true,
      "generateOptionalPropertiesAsNullable": false,
      "generateNullableReferenceTypes": false,
      "templateDirectory": null,
      "typeNameGeneratorType": null,
      "propertyNameGeneratorType": null,
      "enumNameGeneratorType": null,
      "checksumCacheEnabled": false,
      "serviceHost": null,
      "serviceSchemes": null,
      "output": "../VerticalSliceArchitecture.Frontend/Services/VerticalSliceArchitectureApiClients.g.cs",
      "newLineBehavior": "Auto"
    }
  }
}

Generating code using C#:

System.Net.WebClient wclient = new System.Net.WebClient();

var document =
    await OpenApiDocument.FromJsonAsync(
        wclient.DownloadString("http://localhost:5136/api/v1/specification.json"));

wclient.Dispose();

var settings = new CSharpClientGeneratorSettings
{
    GenerateDtoTypes = false,
    GenerateClientInterfaces = true,
    UseBaseUrl = false,
    CSharpGeneratorSettings =
    {
        Namespace = "VerticalSliceArchitecture.Frontend.Services.generated",
        TypeNameGenerator = new CustomTypeNameGenerator()
    }
};

var generator = new CSharpClientGenerator(document, settings);
var code = generator.GenerateFile();
Console.WriteLine(code);

Custom converters used by both proceses:

public class CustomSchemaNameGenerator : ISchemaNameGenerator
{
    public string Generate(Type type)
    {
        return type.FullName?.Replace("+", ".") ?? "";
    }
}
public class CustomTypeNameGenerator : ITypeNameGenerator
{
    public string Generate(JsonSchema schema, string typeNameHint, IEnumerable<string> reservedTypeNames)
    {
        return typeNameHint.Replace("+", ".");
    }
}
Climax85 commented 2 years ago

@shainegordon So you got the desired beaviour by using code instead of msbuild? Where did you put the shown code snippet? Do you still run it on every build? This is however just a workaround. A fix would be helpful anyway.

shainegordon commented 2 years ago

@Climax85 I just dumped that code into one of my API endpoints so I could test the concept. I did then try to extract this into a console application, but the issue there is then where the generated code is written to. When you are executing/debugging code, it is not running from your source directory, it actually runs from the running application's obj/Debug/net6.0/ folder

The only practical way that I can see this working in a way that will work on every developer's machine, transparently, is to create a dotnet cli tool, and then call that tool as part of the build process

shainegordon commented 2 years ago

@Climax85 I actually ended up doing a console application, works perfectly for my scenario

using Microsoft.Extensions.Configuration;
using NSwag;
using NSwag.CodeGeneration.CSharp;
using VerticalSliceArchitecture.CodeGen.Console;
using VerticalSliceArchitecture.CodeGen.NSwag;

IConfiguration config = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json", false)
    .AddJsonFile("appsettings.Development.json", false)
    .AddEnvironmentVariables()
    .Build();

var settings = config.GetRequiredSection("Settings").Get<Settings>();

var document =
    await OpenApiDocument.FromJsonAsync(File.ReadAllText(settings.OpenApiFilePath));

var clientGeneratorSettings = new CSharpClientGeneratorSettings
{
    GenerateDtoTypes = false,
    GenerateClientInterfaces = true,
    UseBaseUrl = false,
    CSharpGeneratorSettings =
    {
        Namespace = "VerticalSliceArchitecture.Frontend.Services",
        TypeNameGenerator = new CustomTypeNameGenerator()
    }
};

var generator = new CSharpClientGenerator(document, clientGeneratorSettings);
var code = generator.GenerateFile();

File.WriteAllText(Path.Combine(settings.CSharpClientOutputPath, "Client.cs"), code);

Output

namespace VerticalSliceArchitecture.Frontend.Services.generated
{
    using System = global::System;

    [System.CodeDom.Compiler.GeneratedCode("NSwag", "13.15.10.0 (NJsonSchema v10.6.10.0 (Newtonsoft.Json v13.0.0.0))")]
    public partial interface ICustomerClient
    {
        /// <exception cref="ApiException">A server side error occurred.</exception>
        System.Threading.Tasks.Task<VerticalSliceArchitecture.Features.Customers.GetCustomerById.Model> GetByIdAsync(int id);

        /// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
        /// <exception cref="ApiException">A server side error occurred.</exception>
        System.Threading.Tasks.Task<VerticalSliceArchitecture.Features.Customers.GetCustomerById.Model> GetByIdAsync(int id, System.Threading.CancellationToken cancellationToken);

    }
//snipped
tiagopsantos commented 1 year ago

It would be nice to have a configuration for this in the Studio

bvkeersop commented 1 year ago

Also have an issue with this as we have an object that is named the same as a namespace. So when the client get's generated.

e.g.

project: MyCompany.Booking.Domain

Booking GetBooking(Guid id);

Gives the error that Booking is a namespace but is used like a type. Normally would use a using alias, or use the fullnamespace, but according to this issue, this cannot be auto-generated.