Merge pull request #534 from anhthidao/OpenApi

Implement code to generate route handler code from a JSON file
This commit is contained in:
Safia Abdalla 2022-08-10 14:12:37 -05:00 коммит произвёл GitHub
Родитель 615dd2adc4 13c958f6ad
Коммит 21cbde6673
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 691 добавлений и 58 удалений

Просмотреть файл

@ -0,0 +1,318 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
namespace CodeGenerator;
public class App
{
public static void Main(string[] args)
{
if (args.Length != 2)
{
Console.Error.WriteLine("Please enter two arguments: an input file path and an output file path.");
Environment.Exit(1); // Code 1 is for problems with passed in file paths
}
var document = ReadJson(args[0]);
var paths = document?.Paths;
if (paths is null || paths.Count == 0)
{
Console.Error.WriteLine("No path were found in the schema.");
Environment.Exit(2); // Code 2 is for problems with paths in schema
}
var fileProperties = new Dictionary<string, Dictionary<string, Dictionary<string, string?>>>();
foreach (var path in paths)
{
var operations = path.Value.Operations;
if (operations is null || operations.Count == 0)
{
Console.Error.WriteLine("No operation was found in path.");
Environment.Exit(3); // Code 3 is for problems with operations
}
var pathString = path.Key.ToString();
fileProperties.Add(pathString, new Dictionary<string, Dictionary<string, string?>> ());
var dict = new Dictionary<string, Dictionary<string, string>> ();
foreach (var operation in operations)
{
var method = operation.Key.ToString().ToLower();
method = GetHttpMethod(method);
if (method == string.Empty)
{
Console.Error.WriteLine($"Unsupported HTTP method found: '{operation.Key}'");
Environment.Exit(3);
}
fileProperties[pathString].Add(method, new Dictionary<string, string?> ());
var parameters = operation.Value.Parameters;
string parametersList = string.Empty;
for (int i = 0; i < parameters.Count; i++)
{
var parameter = parameters[i];
if (parameter.Schema.Type.ToLower() == "array")
{
parametersList += GetArrayKeyword(parameter.Schema) + " " + parameter.Name;
}
else if (parameter.Schema.Type.ToLower() == "object")
{
parametersList += parameter.Schema.Reference?.Id + $" user{parameter.Schema.Reference?.Id}";
}
else
{
parametersList += GetDataTypeKeyword(parameter.Schema) + " " + parameter.Name;
}
if (i < parameters.Count - 1)
{
parametersList += ", ";
}
}
fileProperties[pathString][method].Add("parameters", parametersList);
var responses = operation.Value.Responses;
foreach (var response in responses)
{
string returnValue;
// some responses doesn't have "content" property
// so these would later return a default value
if (response.Value.Content == null || response.Value.Content.Count == 0)
{
fileProperties[pathString][method].Add(response.Key, null);
continue;
}
var content = response.Value.Content.First().Value;
var schema = content.Schema;
if (schema == null)
{
Console.Error.WriteLine("No schema was found for the response.");
Environment.Exit(3);
}
if (schema.Type.ToLower() == "array")
{
returnValue = "new " + GetArrayKeyword(schema) + " {}";
}
else if (schema.Type.ToLower() == "object")
{
returnValue = "new " + schema?.Reference?.Id + "()";
}
else
{
returnValue = GetPrimitiveValue(schema);
}
// this code below is for parsing sample values
// this is used for demoing the project
if (content.Example != null)
{
returnValue = GetSampleValue(content.Example, schema);
}
if (content.Examples != null && content.Examples.Count > 0)
{
returnValue = GetSampleValue(content.Examples.First().Value.Value, schema);
}
fileProperties[pathString][method].Add(response.Key, returnValue);
}
}
}
var schemas = document?.Components?.Schemas;
Dictionary<string, Dictionary<string, string>> schemaDict = new Dictionary<string, Dictionary<string, string>> ();
if (schemas != null && schemas.Count > 0)
{
foreach (var schema in schemas)
{
schemaDict.Add(schema.Key, new Dictionary<string, string>());
foreach (var property in schema.Value.Properties)
{
string propertyType;
if (property.Value.Type.ToLower() == "array")
{
propertyType = GetArrayKeyword(property.Value);
}
else if (property.Value.Reference?.Id != null)
{
propertyType = property.Value.Reference.Id;
}
else
{
propertyType = GetDataTypeKeyword(property.Value);
}
schemaDict[schema.Key].Add(property.Key, propertyType);
}
}
}
var page = new MinimalApiTemplate
{
FileProperties = fileProperties,
Schemas = schemaDict
};
var pageContent = page.TransformText();
File.WriteAllText(args[1], pageContent);
}
private static string GetSampleValue(IOpenApiAny example, OpenApiSchema? schema) => example switch
{
OpenApiString castedExample => $"\"{castedExample.Value}\"",
OpenApiInteger castedExample => castedExample.Value.ToString(),
OpenApiBoolean castedExample => castedExample.Value.ToString(),
OpenApiFloat castedExample => castedExample.Value.ToString(),
OpenApiDouble castedExample => castedExample.Value.ToString(),
OpenApiArray castedExample => "new " + GetDataTypeKeyword(schema) + $"[] {{{GetArrayValues(castedExample)}}}",
OpenApiObject castedExample => GetObjectArguments(castedExample, schema),
OpenApiNull castedExample => "null",
_ => string.Empty
};
private static string GetArrayValues(OpenApiArray example)
{
int count = example.Count;
string returnValue = string.Empty;
foreach (var value in example)
{
returnValue += GetSampleValue(value,null);
if (count > 1)
{
returnValue += ", ";
}
count--;
}
return returnValue;
}
private static string GetObjectArguments(OpenApiObject example, OpenApiSchema? schema)
{
string arguments = $"new {schema?.Reference?.Id}(";
for (int i = 0; i < example.Values.Count; i++)
{
if (schema?.Properties?.Values.Count > i)
{
arguments += $"{GetSampleValue(example.Values.ElementAt(i), schema?.Properties?.Values?.ElementAt(i))}, ";
}
else
{
arguments += $"{GetSampleValue(example.Values.ElementAt(i), null)}, ";
}
}
return arguments.Substring(0, arguments.Length - 2) + ")";
}
private static string GetHttpMethod(string method) => method switch
{
"get" => "MapGet",
"post" => "MapPost",
"put" => "MapPut",
"delete" => "MapDelete",
_ => string.Empty
};
private static string GetDataTypeKeyword(OpenApiSchema? schema) => schema?.Type switch
{
"string" => "string",
"integer" => "int",
"float" => "float",
"boolean" => "bool",
"double" => "double",
_ => string.Empty
};
private static string GetArrayKeyword(OpenApiSchema? schema)
{
if (schema == null)
{
return string.Empty;
}
string returnValue = "[";
while (schema.Items.Type == "array")
{
returnValue += ",";
schema = schema.Items;
}
if (schema.Items.Type.ToLower() == "object")
{
returnValue = schema?.Items.Reference?.Id + returnValue + "]";
}
else
{
returnValue = GetDataTypeKeyword(schema.Items) + returnValue + "]";
}
return returnValue;
}
private static string GetPrimitiveValue(OpenApiSchema? schema) => schema?.Type switch
{
"string" => "\"\"",
"integer" => "0",
"boolean" => "false",
"float" => "0.0f",
"double" => "0.0d",
_ => string.Empty,
};
private static OpenApiDocument? ReadJson(string args)
{
if (!Path.IsPathRooted(args))
{
Console.Error.WriteLine("The file path you entered does not have a root");
return null;
}
OpenApiStreamReader reader = new OpenApiStreamReader();
var diagnostic = new OpenApiDiagnostic();
try
{
string path = Path.GetFullPath(args);
Stream stream = File.OpenRead(path);
OpenApiDocument newDocument = reader.Read(stream, out diagnostic);
return newDocument;
}
catch (FileNotFoundException e)
{
Console.WriteLine("Check to make sure you entered a correct file path because the file was not found.");
Console.Error.WriteLine(e.Message);
Environment.Exit(1);
return null;
}
catch (Exception e)
{
Console.WriteLine("Check the file path you entered for errors.");
Console.Error.WriteLine(e.Message);
Environment.Exit(1);
return null;
}
finally
{
if (diagnostic.Errors.Count == 0)
{
Console.WriteLine("Read File Successfully");
}
else
{
foreach (var error in diagnostic.Errors)
{
Console.WriteLine($"There was an error reading in the file: {error.Pointer}");
Console.Error.WriteLine(error.Message);
Environment.Exit(1);
}
}
}
}
}

Просмотреть файл

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@ -7,9 +7,30 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include="MinimalApiTemplate.tt">
<Generator>TextTemplatingFilePreprocessor</Generator>
<LastGenOutput>MinimalApiTemplate.cs</LastGenOutput>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.OpenApi" Version="1.3.2" />
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.3.2" />
<PackageReference Include="Microsoft.VisualStudio.TextTemplating.14.0" Version="14.3.25407" />
<PackageReference Include="System.CodeDom" Version="6.0.2-mauipre.1.22102.15" />
</ItemGroup>
<ItemGroup>
<Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
</ItemGroup>
<ItemGroup>
<Compile Update="MinimalApiTemplate.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>MinimalApiTemplate.tt</DependentUpon>
</Compile>
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,117 @@
<#@ template language="C#" #>
<#@ assembly name="mscorlib" #>
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
<#
foreach (var path in FileProperties) {
var pathValue = path.Key;
foreach (var operation in FileProperties[pathValue]) {
var method = operation.Key;
var parameterList = operation.Value["parameters"];
if (parameterList != String.Empty) {
parameterList = ", " + parameterList;
}
#>
app.<#=method #>("<#=pathValue #>", (HttpContext context<#=parameterList #>) =>
{
<#
foreach (var response in operation.Value) {
string statusCode;
string returnValue;
if (response.Key != "parameters") {
statusCode = response.Key;
if (response.Value == null) {
var statusMethod = statusCode switch {
"202" => "Accepted()",
"400" => "BadRequest()",
"409" => "Conflict()",
"204" => "NoContent()",
"404" => "NotFound()",
"200" => "Ok()",
"401" => "Unauthorized()",
"422" => "UnprocessableEntity()",
_ => $"StatusCode({response.Key})"
};
returnValue = $"Results.{statusMethod}";
}
else {
var statusMethod = statusCode switch {
"202" => $"Accepted(_, {response.Value})",
"400" => $"BadRequest({response.Value})",
"409" => $"Conflict({response.Value})",
"204" => "NoContent()",
"404" => $"NotFound({response.Value})",
"200" => $"Ok({response.Value})",
"401" => "Unauthorized()",
"422" => $"UnprocessableEntity({response.Value})",
_ => $"StatusCode({response.Key})"
};
returnValue = $"Results.{statusMethod}";
}
}
else {
continue;
}
#>
if (context.Request.Headers["AcceptStatusCode"] == "<#=statusCode #>")
{
return <#=returnValue #>;
}
<#
}
#>
return null;
});
<#
}
}
#>
<#
foreach (var schema in Schemas) {
var customObject = schema.Key;
#>
public class <#=customObject #> {
<#
string constructorParameters = string.Empty;
string constructorBody = string.Empty;
foreach (var property in schema.Value) {
var propertyName = property.Key;
var propertyType = property.Value;
constructorParameters += propertyType + "? " + propertyName + ", ";
constructorBody += $"this.{propertyName} = {propertyName}\n";
#>
public <#=propertyType #>? <#=propertyName #> { get; set; }
<#
}
constructorParameters = constructorParameters.Substring(0, constructorParameters.Length - 2);
constructorBody = constructorBody.Substring(0, constructorBody.Length - 1);
#>
public <#=customObject #>(<#=constructorParameters #>) {
<#
var statements = constructorBody.Split("\n");
foreach (var statement in statements) {
#>
<#=statement #>;
<#
}
#>
}
public <#=customObject #>() {}
}
<#
}
#>
<#+
#nullable enable
public Dictionary<string, Dictionary<string, Dictionary<string, string?>>>? FileProperties { get; set; }
#nullable disable
public Dictionary<string, Dictionary<string, string>> Schemas { get; set; }
#>

Просмотреть файл

@ -1,57 +0,0 @@
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
public class App
{
public static void Main(string[] args)
{
new App().ReadJson(args);
}
private void ReadJson(string[] args)
{
string inputPath = args[0];
if (!Path.IsPathRooted(inputPath))
{
Console.WriteLine("The file path you entered does not have a root");
return;
}
OpenApiStreamReader reader = new OpenApiStreamReader();
var diagnostic = new OpenApiDiagnostic();
try
{
string path = Path.GetFullPath(inputPath);
Stream stream = File.OpenRead(path);
OpenApiDocument newDocument = reader.Read(stream, out diagnostic);
}
catch (FileNotFoundException e)
{
Console.WriteLine("Check to make sure you entered a correct file path because the file was not found.");
Console.Error.WriteLine(e.Message);
return;
}
catch(Exception e)
{
Console.WriteLine("Check the file path you entered for errors.");
Console.Error.WriteLine(e.Message);
return;
}
if (diagnostic.Errors.Count == 0)
{
Console.WriteLine("Read File Successfully");
}
else
{
foreach (OpenApiError error in diagnostic.Errors)
{
Console.WriteLine($"There was an error reading in the file at {error.Pointer}");
Console.Error.WriteLine(error.Message);
}
}
}
}

Просмотреть файл

@ -0,0 +1,234 @@
# Code Generator doc
Created: August 9, 2022 10:23 AM
Last Edited Time: August 10, 2022 10:30 AM
# Overview 🖥️
This is a CLI tool that generates the server side API route handler code in Minimal API from an Open API spec.
Some possible use cases for this tool are testing and validating API contracts, including mocking for a single service and performing UI integration tests with the generated mock server. This tool can also be used to facilitate decoupled development.
# How to use the tool 🔨
In order for the tool to work, there are a couple of steps we must take to properly set up the environment.
We must create the `MinimalApiTemplate.cs` file which will be created in design time by the `[MinimalApiTemplate.tt](http://MinimalApiTemplate.tt)` file. In order to do this:
1. Open the `OpenApi.sln` file on VS.
2. Right click on the `[MinimalApiTemplate.tt](http://MinimalApiTemplate.tt)` and select `Properties` .
3. Change the `Build Action` property to `Content` and the `Custom Tool` property to `TextTemplatingFilePreprocessor` .
4. After doing this you should be able to right click the `[MinimalApiTemplate.tt](http://MinimalApiTemplate.tt)` file and select `Run Custom Tool` .
5. This will generate the `MinimalApiTemplate.cs` file which will be responsible for writing the code to the output file.
At this point, the tool is ready for use, and can be executed with the following command:
`dotnet run <open_api_spec_path> <output_file_path>`
### Arguments
`<open_api_spec_path>` corresponds to the file path of the json file containing the Open API documentation.
`<output_file_path>` is the path where the output file will be created.
## Constraints
`<open_api_spec_path>` has to be a valid path to .json file.
`<output_file_path>` has to be a valid path to .cs file. If a file path for an existing file is passed, the existing file will be overwritten.
# Implementation ⚙️
## Logic
Inside of the `CodeGenerator.cs` file, the `OpenApi.Reader` package is used to read the Open Api spec into an `OpenApiDocument` .
Then , parse the properties of `OpenApiDocument` (paths, HTTP methods, arguments, schemas, etc) into two dictionaries- `fileProperties` and `schemaDict` .
Both of these dictionaries are passed onto `[MinimalApiTemplate.tt](http://MinimalApiTemplate.tt)` , which is the T4 template with the code generation pattern.
The T4 template iterates through the dictionaries and uses the values to write the code pattern for the route handlers. The `MinimalApiTemplate.cs` file generated by
the T4 template during design time contains the method `TranformText()` that is used to produce the output code as a string value.
## Dependencies
- Microsoft.OpenApi (1.3.2)
- Microsoft.OpenApi.Reader (1.3.2)
- System.CodeDom (6.0.0)
- Microsoft.VisualStudio.TextTemplating.15.0
# Sample
## Input
```json
"openapi": "3.0.2",
"info": {
"title": "Swagger Petstore - OpenAPI 3.0",
"description": "This is a sample server",
"version": "1.0.11"
},
"externalDocs": {
"description": "Find out more about Swagger",
"url": "http://swagger.io"
},
"servers": [
{
"url": "/api/v3"
}
],
"tags": [
{
"name": "pet",
"description": "Everything about your Pets",
"externalDocs": {
"description": "Find out more",
"url": "http://swagger.io"
}
},
{
"name": "store",
"description": "Access to Petstore orders",
"externalDocs": {
"description": "Find out more about our store",
"url": "http://swagger.io"
}
},
{
"name": "user",
"description": "Operations about user"
}
],
"paths": {
"/": {
"get": {
"tags": [
"store"
],
"summary": "Welcome message",
"description": "Write the welcome message",
"operationId": "welcomeUser",
"responses": {
"200": {
"description": "Successful operation",
"content": {
"text/plain": {
"schema": {
"type": "string"
},
"example": "Welcome to the store!"
}
}
}
}
}
},
"/pet": {
"put": {
"tags": [
"pet"
],
"summary": "Update an existing pet",
"description": "Update an existing pet by Id",
"operationId": "updatePet",
"requestBody": {
"description": "Update an existent pet in the store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Pet"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful operation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Pet"
},
"example": {
"id": 56,
"name": "Max",
"category": {
"id": 56,
"name": "Max"
},
"photoUrls": [
"http://samplelink.com/image1",
"http://samplelink.com/image2"
],
"tags": null,
"status": "available"
}
}
}
},
"400": {
"description": "Invalid ID supplied"
},
"404": {
"description": "Pet not found"
},
"405": {
"description": "Validation exception"
}
},
"security": [
{
"petstore_auth": [
"write:pets",
"read:pets"
]
}
]
}
```
## Output
```csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", (HttpContext context) =>
{
if (context.Request.Headers["AcceptStatusCode"] == "200")
{
return Results.Ok("Welcome to the store!");
}
return null;
});
app.MapPut("/pet", (HttpContext context) =>
{
if (context.Request.Headers["AcceptStatusCode"] == "200")
{
return Results.Ok(new Pet(56, "Max", new Category(56, "Max"), new [] {"http://samplelink.com/image1", "http://samplelink.com/image2"}, null, "available"));
}
if (context.Request.Headers["AcceptStatusCode"] == "400")
{
return Results.BadRequest();
}
if (context.Request.Headers["AcceptStatusCode"] == "404")
{
return Results.NotFound();
}
if (context.Request.Headers["AcceptStatusCode"] == "405")
{
return Results.StatusCode(405);
}
return null;
});
```
# Additional Notes 📒