Overview
If you are familiar with implementing APIs, or Application Programming Interfaces, you probably know of OpenAPI or Swagger. In this post, we want to share some information on how we used NSwag, an OpenAPI .NET toolchain, to generate C# models in code and improve the maintainability of a .NET Core API client. First, we’ll introduce the primary role of an API client, touch on our project challenges, and then describe how our project used NSwag to solve those challenges.
Role of an API Client
API clients can take different forms, such as libraries, SDKs (Software Development Kits), command-line tools, or custom-built applications. In most forms, the primary role of an API client is to act as an API that abstracts the complexities of working with one or more underlying APIs. Therefore, they often play a dual role as both an API client and an API.
With this role in mind, API clients are often focused on providing a positive developer experience. In some cases, an API client may even provide a higher-level abstraction that is more aligned with the domain of the developer. In any case, there’s no escaping the fact that they are dependent on underlying APIs. In one way or another, they must preserve compatibility with relevant changes that occur with underlying APIs. As a result, an API client requires regular maintenance to keep up with the required changes.
The Challenge
How do we meet the challenge of maintaining an API client? How do we make managing change easier for the developer? We faced this challenge in our project because we needed to build a .NET Core API client, in the form of a front-end web API client, that communicated with several internal back-end APIs. In this case, the front-end web API client was an API client to each of the internal back-end APIs. However, the client was also an API to the front-end web application that consumed it. Therefore, the front-end web API client was both an API client and an API.
When given the challenge of maintaining an API client, you are off to a great start if the underlying set of backing APIs are designed well. In our case, we had full control over the underlying set of backing APIs. We also adhered to the common practice of exposing a Swagger or OpenAPI spec for each underlying API to make it easier for our front-end web API client to integrate. For guidance on API design, consider reading A Technical Journey into API Design-First: Best Practices and Lessons Learned – CSE Developer Blog (microsoft.com).
However, a well-designed underlying API is not enough. Developers maintaining the client still must decide on how tightly coupled it should be to each of it’s underlying APIs. In our case, the front-end web API required using back-end models, defined in the OpenAPI spec, for serializing data into API requests and deserializing API responses like custom errors. Therefore, it was the evolving model definitions of each back-end API that were driving integration requirements and not the full set of underlying API endpoints. Due to this, we needed help with maintaining model compatibility between the front-end web models and the back-end models defined in the OpenAPI spec. In our case, by first focusing on minimizing downtime from model changes, we could tighten the developer loop, and the front-end web API client would become properly scoped to the changes it should care about in the underlying APIs.
Fortunately, a major benefit of an OpenAPI spec is that it unlocks access to an ecosystem of tools that we could explore for maintaining model compatibility between the front-end web C# models and the back-end models defined in each OpenAPI spec. Therefore, we explored code generation tools that could take models defined in a OpenAPI spec and convert them into the C# models required by the client. Preferably, the code generation tool would also skip unnecessary code generation of HTTP client code endpoints defined in a OpenAPI spec.
Exploring Solutions
For auto-generating the front-end web C# models, we first investigated leveraging Openapi-generator-cli. Using this CLI, the following command successfully generated the C# models we needed from the OpenAPI models defined in the spec.
- Command:
openapi-generator-cli generate -g csharp-netcore -i http://localhost:000/swagger/v1/swagger.json --global-property models --additional-properties targetFramework=net6.0 -o autogenmodels
However, by default, it output an additional number of files that did not meet our needs. To make matters worse, our project used StyleCop to enforce formatting and style but the C# code that this tool generated was violating our StyleCop rules by default. Therefore, we decided to explore NSwag.
- Output:
# Openapi-generator-cli generated models src / |─── .vscode |─── ... └─── Models ├───docs # markdown per model | |─── Model1.md | └─── Model2.md ├───src | |─── Org.OpenAPITools/Model # file per model | | |─── Model1.cs | | └─── Model2.cs | |─── Org.OpenAPITools.Test | | |─── Model1Tests.cs | | └─── Model2Tests.cs └─── ...
Model Generation With NSwag
Our first go at NSwag was with NSwag Studio. It’s a GUI (Graphical User Interface) that can be configured to auto-generate client code using OpenAPI specs as input. We explored NSwag Studio and successfully generated C# models for our front-end web API. Here, all models from an OpenAPI spec were isolated to a single cs file and no additional files were generated. Essentially, the outcome of this tool, per back-end API, was an auto-generated cs file hosting C# models. This was a cleaner outcome than we experienced with Openapi-generator-cli.
- Output:
# NSwag generated C# models src / |─── .vscode |─── ... └─── Models # holds a cs models file per OpenAPI spec ├───AutoGen_API1_Models.cs ├───AutoGen_API2_Models.cs ├───AutoGen_API3_Models.cs └───..
To take it a step further, we explored using the NSwag CLI. However, we ultimately decided to use NSwag through the .NET CLI for a more streamlined .NET developer experience. Here are the steps we took to configure NSwag for generating C# models within our .NET Core front-end web API.
Configuring NSwag
- From your API client’s csproj directory, download dotnet NSwag CLI and take note of the default runtime version (ex.
Net70
). For more information on available commands and what they mean, visit the NSwag CommandLine Wiki.# Downloads dotnet's project specific tooling file dotnet new tool-manifest # Downloads dotnet's Nswag CLI tool dotnet tool install NSwag.ConsoleCore # Outputs version information. This info will be used to satisfy the NSwag $nswagRuntime argument. dotnet nswag version # Generates the `nswag.json` config file. dotnet nswag new
- Configure the
nswag.json
configuration file using the following example. Use the following parameters. Also ensure the code generator is openApiToCSharpClient. For further help configuring an NSwag Configuration Document, visit NSwag Configuration Document Wiki.Parameters:- NswagRuntime: This is the runtime that NSwag should execute under. The value (ex.
Net70
) will be passed to thenswag.json
file to satisfy the runtime prop (i.e."runtime": "$(NswagRuntime)"
). - ApiServiceName: This is the path and filename that should be autogenerated. The value (ex.
API1
) will be passed to thenswag.json
file to satisfy the output prop (i.e."output": "Models/AutoGen_$(ApiServiceName)_Models.cs"
). - ApiSwaggerUrl: This is the URL for the OpenAPI spec of a dependent service. The value (ex.
http://API1/swagger/v1/swagger.json
) will be passed to thenswag.json
file to satisfy the URL prop (i.e."url": "$(ApiSwaggerUrl)"
). - ClientNamespace: This namespace will be created for autogenerated models. The value (ex.
The.FrontEndAPI.Namespace.Models
) will be passed to thenswag.json
file to satisfy the namespace prop (i.e."namespace": "$(ClientNamespace)"
).
{ "runtime": "$(NswagRuntime)", "documentGenerator": { "fromDocument": { "url": "$(ApiSwaggerUrl)", "output": "swagger.json", "newLineBehavior": "Auto" } }, "codeGenerators": { "openApiToCSharpClient": { "generateClientClasses": false, "generateClientInterfaces": false, "injectHttpClient": false, "disposeHttpClient": false, "protectedMethods": [], "generateExceptionClasses": false, "exceptionClass": "ApiException", "wrapDtoExceptions": true, "useHttpClientCreationMethod": false, "httpClientType": "System.Net.Http.HttpClient", "useHttpRequestMessageCreationMethod": false, "useBaseUrl": false, "generateBaseUrlProperty": false, "generateSyncMethods": false, "generatePrepareRequestAndProcessResponseAsAsyncMethods": false, "exposeJsonSerializerSettings": false, "clientClassAccessModifier": "public", "typeAccessModifier": "public", "generateContractsOutput": false, "parameterDateTimeFormat": "s", "parameterDateFormat": "yyyy-MM-dd", "generateUpdateJsonSerializerSettingsMethod": false, "useRequestAndResponseSerializationSettings": false, "serializeTypeInformation": false, "queryNullValue": "", "className": "{controller}Client", "operationGenerationMode": "MultipleClientsFromOperationId", "additionalNamespaceUsages": [], "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": false, "responseClass": "SwaggerResponse", "namespace": "$(ClientNamespace)", "requiredPropertiesMustBeDefined": true, "dateType": "System.DateOnly", "anyType": "object", "dateTimeType": "System.DateTimeOffset", "timeType": "System.TimeOnly", "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": true, "generateImmutableDictionaryProperties": true, "jsonSerializerSettingsTransformationMethod": "NewtonsoftJson", "inlineNamedArrays": false, "inlineNamedDictionaries": false, "inlineNamedTuples": true, "inlineNamedAny": false, "generateDtoTypes": true, "generateOptionalPropertiesAsNullable": true, "generateNullableReferenceTypes": false, "output": "Models/AutoGen_$(ApiServiceName)_Models.cs", "newLineBehavior": "Auto" } } }
- NswagRuntime: This is the runtime that NSwag should execute under. The value (ex.
- Per dependent API service, run the dotnet NSwag CLI tool against the
nswag.json
file. The output will be in aModels/
folder with a C# file name that reflects the service name. For example, ifAPI1
is provided as a value to theApiServiceName
parameter, a file calledAutoGen_API1_Models.cs
will be generated.Commands:- Powershell Example:
### Sets variables $NswagRuntime = "Net70"; $ApiServiceName = "API1"; $ApiSwaggerUrl = "http://API1/swagger/v1/swagger.json"; $ClientNamespace = "The.FrontEndAPI.Namespace.Models" ### Auto-generates api client models by running against an http OpenApi spec dotnet nswag run nswag.json /variables:NswagRuntime=$NswagRuntime,ApiServiceName=$ApiServiceName,ApiSwaggerUrl=$ApiSwaggerUrl,ClientNamespace=$ClientNamespace
- Bash Example:
### Sets variables NswagRuntime="Net70" ApiServiceName="API1" ApiSwaggerUrl="http://API1/swagger/v1/swagger.json" ClientNamespace="The.FrontEndAPI.Namespace.Models" ### Auto-generates api client models by running against an http OpenAPI spec dotnet nswag run nswag.json /variables:NswagRuntime=$NswagRuntime,ApiServiceName=$ApiServiceName,ApiSwaggerUrl=$ApiSwaggerUrl,ClientNamespace=$ClientNamespace
- Powershell Example:
- Ensure the folders, models and namespaces are created as expected.
# NSwag generated models src / |─── .vscode |─── ... └─── Models # holds a cs models file per OpenAPI spec ├───AutoGen_API1_Models.cs ├───AutoGen_API2_Models.cs ├───AutoGen_API3_Models.cs └───..
Additional Considerations With NSwag Code Generation
Generating Client Code
If you require auto-generating the full client code and not just the models, consider NSwag’s Github tutorial titled A How-To: Generating The Service Client Proxy Code.. We did not rely on this tutorial but it might work for your project. It’s also important to note that the tutorial uses the NSwag CLI directly and not through the dotnet CLI.
Preventing C# Model Name Collisions Using Namespaces
It may be the case that auto-generating C# models results in naming collisions if the ClientNamespace is shared. To prevent this from happening when auto-generating your C# models, first, make sure that the value given to your ClientNamespace parameter will not be reused, then, alias your using statements for additional clarity in your client code base.
- Here’s an example of aliasing to prevent naming collisions.
using API1 = The.APIClient.Models.API1; using API2 = The.APIClient.Models.API2; public class Example { public void ExampleMethod() { API1.ExampleModel exampleModel1 = new API1.ExampleModel(); API2.ExampleModel exampleModel2 = new API2.ExampleModel(); } }
Troubleshooting Serialization Hurdles
It may be the case that NSwag is serializing data in a way that’s not useful. One option may be to change the serialization library defined in the configuration file. In this article, the default is NewtonsoftJson but there are alternative libraries that can be used. For example, System.Text.Json is a library that can be used instead of NewtonsoftJson. To change the serialization library, change the value of the jsonLibrary
property in the nswag.json
file.
- Here’s an example of changing the serialization library to System.Text.Json.
{ ... "codeGenerators": { "openApiToCSharpClient": { ... "jsonLibrary": "System.Text.Json", ... "jsonSerializerSettingsTransformationMethod": "System.Text.Json", ... } }}
Another option is to override the default serialization behavior of your configured serialization library. In the following example, by default, NSwag attributes a DateFormatConverter (a DateTime type converter) to DateOnly Types. This is a known NSwag DateFormatConverter Issue. Inevitably, this throws an error because it’s expecting the wrong type when serializing at runtime. Therefore, a custom serializer must be implemented to prevent the error. Here’s an example of the error and the fix.
- The Unwanted Behavior (Default Attribute)
[Newtonsoft.Json.JsonProperty("Date", Required = Newtonsoft.Json.Required.Default)] [Newtonsoft.Json.JsonConverter(typeof(DateFormatConverter))] public System.DateOnly? Date { get; set; }
- The Fix (Custom ContractResolver For Fixing DateTimeOnly)
internal class ContractResolverToFixDateOnly : DefaultContractResolver { protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { var property = base.CreateProperty(member, memberSerialization); if (property.PropertyType == typeof(DateOnly)) { property.Converter = null; } if (property.PropertyType == typeof(DateOnly?)) { property.Converter = null; } return property; } }
var settings = new JsonSerializerSettings { ContractResolver = new ContractResolverToFixDateOnly(), }; // A model with a DateOnly property. var model = JsonConvert.DeserializeObject<Model>(responseBody, settings);
Conclusion
By leveraging NSwag’s code generation capabilities, we simplified the process of maintaining the API client entities. It helped ensure compatibility with evolving API models which overall made it easier to decouple front-end functionality from back-end concerns in our front-end API client use case.
In summary, NSwag code generation is a valuable tool for maintaining API clients. It enabled easier management of change and promoted a better developer experience. By leveraging OpenAPI specifications with NSwag, developers can streamline the process of keeping API clients up to date and maintainable.
Acknowledgements
Special thanks to Peter Lasne, David Lee and the remainder of the team for findings that helped shaped this article.