diff --git a/Directory.Packages.props b/Directory.Packages.props index 0bf6e36..b04c372 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,10 +7,14 @@ + + + + @@ -25,6 +29,12 @@ + + + + + + diff --git a/OpenApi.Client.sln b/OpenApi.Client.sln index 204e21f..2c8e9fe 100644 --- a/OpenApi.Client.sln +++ b/OpenApi.Client.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApi.Client.SourceGenera EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApi.Client.Cli.UnitTests", "tests\OpenApi.Client.Cli.UnitTests\OpenApi.Client.Cli.UnitTests.csproj", "{0C407F0F-BE50-4850-B695-FE1C0910E8BB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenApi.Client.Mcp", "src\OpenApi.Client.Mcp\OpenApi.Client.Mcp.csproj", "{BA946406-F267-4996-8769-E5B607E18CB4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +55,10 @@ Global {0C407F0F-BE50-4850-B695-FE1C0910E8BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {0C407F0F-BE50-4850-B695-FE1C0910E8BB}.Release|Any CPU.ActiveCfg = Release|Any CPU {0C407F0F-BE50-4850-B695-FE1C0910E8BB}.Release|Any CPU.Build.0 = Release|Any CPU + {BA946406-F267-4996-8769-E5B607E18CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA946406-F267-4996-8769-E5B607E18CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA946406-F267-4996-8769-E5B607E18CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA946406-F267-4996-8769-E5B607E18CB4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/OpenApi.Client.Mcp/Configuration/McpMode.cs b/src/OpenApi.Client.Mcp/Configuration/McpMode.cs new file mode 100644 index 0000000..ae5d176 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Configuration/McpMode.cs @@ -0,0 +1,13 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +namespace OpenApi.Client.Mcp.Configuration; + +internal enum McpMode +{ + Stdio, + Http, + Both, +} diff --git a/src/OpenApi.Client.Mcp/Configuration/ServerOptions.cs b/src/OpenApi.Client.Mcp/Configuration/ServerOptions.cs new file mode 100644 index 0000000..3d29165 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Configuration/ServerOptions.cs @@ -0,0 +1,11 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +namespace OpenApi.Client.Mcp.Configuration; + +internal sealed class ServerOptions +{ + public McpMode Mode { get; set; } = McpMode.Stdio; +} diff --git a/src/OpenApi.Client.Mcp/Dockerfile b/src/OpenApi.Client.Mcp/Dockerfile new file mode 100644 index 0000000..b68a735 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +WORKDIR /src +COPY . . + +RUN find . -type f -name 'appsettings.*.json' ! -name 'appsettings.json' -exec rm -f {} + +RUN find . -name 'launchSettings.json' -exec rm -f {} + + +RUN dotnet publish "src/OpenApi.Client.Mcp/OpenApi.Client.Mcp.csproj" -c Release -o /app/ --nologo + +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final + +WORKDIR /app +COPY --from=build /app/ . + +ENTRYPOINT ["dotnet", "./OpenApi.Client.Mcp.dll"] diff --git a/src/OpenApi.Client.Mcp/GlobalUsings.cs b/src/OpenApi.Client.Mcp/GlobalUsings.cs new file mode 100644 index 0000000..5739ff7 --- /dev/null +++ b/src/OpenApi.Client.Mcp/GlobalUsings.cs @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +global using Microsoft.AspNetCore.Builder; +global using Microsoft.CodeAnalysis; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using OpenApi.Client.Mcp.Configuration; +global using OpenApi.Client.Mcp.Services; +global using OpenApi.Client.Mcp.Tools; +global using OpenTelemetry; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; +global using System; +global using System.Collections.Generic; +global using System.ComponentModel; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/src/OpenApi.Client.Mcp/Log.cs b/src/OpenApi.Client.Mcp/Log.cs new file mode 100644 index 0000000..3149d0d --- /dev/null +++ b/src/OpenApi.Client.Mcp/Log.cs @@ -0,0 +1,35 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +namespace OpenApi.Client.Mcp; + +internal static partial class Log +{ + private enum Event + { + FailedToFetchOpenApi = 042137, + FatalErrorWhileFetchingOpenApi, + } + + [LoggerMessage( + EventId = (int)Event.FailedToFetchOpenApi, + EventName = nameof(Event.FailedToFetchOpenApi), + Level = LogLevel.Warning, + Message = "Failed to fetch OpenAPI document from \"{Url}\". Please check the URL and your network connection." + )] + public static partial void LogFetchingFailed(this ILogger logger, string url); + + [LoggerMessage( + EventId = (int)Event.FatalErrorWhileFetchingOpenApi, + EventName = nameof(Event.FatalErrorWhileFetchingOpenApi), + Level = LogLevel.Error, + Message = "Fatal error while fetching OpenAPI document from \"{Url}\". Please check the URL and your network connection." + )] + public static partial void LogFetchingFailedWithError( + this ILogger logger, + Exception exception, + string url + ); +} diff --git a/src/OpenApi.Client.Mcp/OpenApi.Client.Mcp.csproj b/src/OpenApi.Client.Mcp/OpenApi.Client.Mcp.csproj new file mode 100644 index 0000000..05f8f68 --- /dev/null +++ b/src/OpenApi.Client.Mcp/OpenApi.Client.Mcp.csproj @@ -0,0 +1,25 @@ + + + $(CommonTargetFramework) + 47a6b027-b381-4465-8c68-1f63fc721408 + ..\.. + Fast + ..\..\.env + + + + + + + + + + + + + + + + + + diff --git a/src/OpenApi.Client.Mcp/Program.cs b/src/OpenApi.Client.Mcp/Program.cs new file mode 100644 index 0000000..1b9737f --- /dev/null +++ b/src/OpenApi.Client.Mcp/Program.cs @@ -0,0 +1,68 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Logging.AddConsole(consoleLogOptions => +{ + // Configure all logs to go to stderr + consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +#if DEBUG +builder + .Services.AddOpenTelemetry() + .WithTracing(b => b.AddSource("*").AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()) + .WithMetrics(b => b.AddMeter("*").AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()) + .WithLogging() + .UseOtlpExporter(); +#endif + +builder.Services.AddTransient(); + +builder.Services.AddHttpClient(); + +ServerOptions serverOptions = new(); +IConfigurationSection section = builder.Configuration.GetSection("Server"); +section.Bind(serverOptions); + +string? mode = Environment.GetEnvironmentVariable("mode") ?? Environment.GetEnvironmentVariable("MODE"); + +if (mode?.Contains("both", StringComparison.InvariantCultureIgnoreCase) ?? false) +{ + serverOptions.Mode = McpMode.Both; +} +else if (mode?.Contains("http", StringComparison.InvariantCultureIgnoreCase) ?? false) +{ + serverOptions.Mode = McpMode.Http; +} + +IMcpServerBuilder mcpBuilder = builder.Services.AddMcpServer(); + +if (serverOptions.Mode == McpMode.Both) +{ + _ = mcpBuilder.WithHttpTransport().WithStdioServerTransport(); +} +else if (serverOptions.Mode == McpMode.Stdio) +{ + _ = mcpBuilder.WithStdioServerTransport(); +} +else +{ + _ = mcpBuilder.WithHttpTransport(); +} + +_ = mcpBuilder.WithTools(); + +await using WebApplication app = builder.Build(); + +if (serverOptions.Mode is McpMode.Http or McpMode.Both) +{ + app.MapMcp(); +} + +await app.RunAsync(); + +return; diff --git a/src/OpenApi.Client.Mcp/Properties/launchSettings.json b/src/OpenApi.Client.Mcp/Properties/launchSettings.json new file mode 100644 index 0000000..8287424 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "ModelContextProtocol": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_SERVICE_NAME": "sse-server", + "MODE": "Both" + }, + "applicationUrl": "http://localhost:64622" + } + } +} diff --git a/src/OpenApi.Client.Mcp/Services/IOpenApiService.cs b/src/OpenApi.Client.Mcp/Services/IOpenApiService.cs new file mode 100644 index 0000000..74a789d --- /dev/null +++ b/src/OpenApi.Client.Mcp/Services/IOpenApiService.cs @@ -0,0 +1,34 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +namespace OpenApi.Client.Mcp.Services; + +internal interface IOpenApiService +{ + /// + /// Creates an OpenAPI client from the specified URL address of the OpenAPI JSON file or Swagger-generated JSON. + /// + Task CreateFromFileAsync(string address, CancellationToken cancellationToken = default); + + /// + /// Retrieves a list of operations from the specified OpenAPI JSON file or Swagger-generated JSON. + /// + Task GetOperationsAsync(string address, CancellationToken cancellationToken = default); + + /// + /// Generates a curl command for a given operation ID from the OpenAPI specification. + /// + Task GenerateCurlCommandAsync( + string address, + string operationId, + string? baseAddress, + CancellationToken cancellationToken = default + ); + + /// + /// Validates the OpenAPI document at the specified address and returns a string indicating the validation result. + /// + Task ValidateDocumentAsync(string address, CancellationToken cancellationToken = default); +} diff --git a/src/OpenApi.Client.Mcp/Services/OpenApiService.cs b/src/OpenApi.Client.Mcp/Services/OpenApiService.cs new file mode 100644 index 0000000..80d62d4 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Services/OpenApiService.cs @@ -0,0 +1,223 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using OpenApi.Client.SourceGenerators.Client; + +namespace OpenApi.Client.Mcp.Services; + +internal sealed class OpenApiService(IServiceProvider serviceProvider, ILogger logger) + : IOpenApiService +{ +#if DEBUG + /// + public async Task CreateFromFileAsync( + string address, + CancellationToken cancellationToken = default + ) + { + IHttpClientFactory factory = serviceProvider.GetRequiredService(); + + using HttpClient client = factory.CreateClient("openapi-fetcher"); + + Stream contents; + + try + { + using HttpResponseMessage response = await client.GetAsync(address, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + logger.LogFetchingFailed(address); + + return "Failed to fetch OpenAPI document: " + response.ReasonPhrase; + } + + contents = await response.Content.ReadAsStreamAsync(cancellationToken); + } + catch (Exception e) + { + logger.LogFetchingFailedWithError(e, address); + + return "Error fetching OpenAPI document: " + e.Message; + } + + ClientGenerator generator = new ClientGenerator( + new GeneratorData + { + NamespaceName = "OpenApi.Client.Generated", + ClassName = "OpenApiClient", + Access = Accessibility.Public, + SerializationTool = SerializationTool.SystemTextJson, + Source = contents, + } + ); + + GenerationResult result = await generator.GenerateAsync(cancellationToken); + + if (result.HasErrors) + { + foreach (GenerationError error in result.Errors) + { + logger.LogError(error.Message); + } + + return "Errors occurred during client generation, first one was: " + + result.Errors.First().Message; + } + + return result.GeneratedClient ?? "Client generation failed, no client was generated."; + } +#endif + + public async Task GetOperationsAsync( + string address, + CancellationToken cancellationToken = default + ) + { + ReadResult readResult = await OpenApiDocument.LoadAsync(address, token: cancellationToken); + + if (readResult.Diagnostic?.Errors.Count > 0) + { + foreach (OpenApiError error in readResult.Diagnostic?.Errors ?? []) + { + logger.LogError(error.Message); + } + + return "Failed to read OpenAPI document: " + + string.Join(", ", readResult.Diagnostic?.Errors.Select(e => e.Message) ?? []); + } + + string result = """ + The following is a list of OpenAPI operations extracted from the specification. Each operation includes its ID, summary, description, HTTP method, and endpoint path. Parameters and responses are included when available. The data is formatted as XML for easier parsing and manipulation. + + """; + + foreach (KeyValuePair singlePath in readResult.Document.Paths) + { + foreach ( + KeyValuePair operation in singlePath.Value.Operations ?? [] + ) + { + result += $$$""" + + {{{operation.Value.OperationId}}} + {{{operation.Value.Summary}}} + {{{operation.Value.Description}}} + {{{singlePath.Key}}} + {{{operation.Key.Method}}} + + """; + } + } + + return result + "\n"; + } + + public async Task GenerateCurlCommandAsync( + string address, + string operationId, + string? baseAddress, + CancellationToken cancellationToken = default + ) + { + ReadResult readResult = await OpenApiDocument.LoadAsync(address, token: cancellationToken); + + if (readResult.Diagnostic?.Errors.Count > 0) + { + foreach (OpenApiError error in readResult.Diagnostic?.Errors ?? []) + { + logger.LogError(error.Message); + } + + return "Failed to read OpenAPI document: " + + string.Join(", ", readResult.Diagnostic?.Errors.Select(e => e.Message) ?? []); + } + + if (readResult.Document is null) + { + return "OpenAPI document is empty or not loaded properly."; + } + + string result = """ + The following is a list of CURL commands for operations from the OpenAPI specification. + Each request includes its operation ID and a sample CURL command. + The data is formatted in XML for structured parsing by language models. + + """; + + foreach (KeyValuePair singlePath in readResult.Document.Paths) + { + foreach ( + KeyValuePair operation in singlePath.Value.Operations ?? [] + ) + { + if (operation.Value.OperationId == operationId) + { + string? baseUrl = baseAddress ?? readResult.Document.Servers?.FirstOrDefault()?.Url; + string fullUrl = (baseUrl != null ? baseUrl.TrimEnd('/') : string.Empty) + singlePath.Key; + + result += $$$""" + + {{{operation.Value.OperationId}}} + curl -X {{{operation.Key.Method}}} "{{{fullUrl}}}" + + """; + } + } + } + + return result + "\n"; + } + + public async Task ValidateDocumentAsync( + string address, + CancellationToken cancellationToken = default + ) + { + try + { + ReadResult readResult = await OpenApiDocument.LoadAsync(address, token: cancellationToken); + + if (readResult.Diagnostic?.Errors.Count > 0) + { + string errorsXml = """ + The following validation errors were found in the OpenAPI document. + The data is formatted in XML for structured parsing by language models. + + """; + + foreach (OpenApiError error in readResult.Diagnostic.Errors) + { + errorsXml += $$$""" + + {{{error.Message}}} + {{{error.Pointer}}} + + """; + } + + errorsXml += "\n"; + + return errorsXml; + } + + return "Validation successful: The OpenAPI document is valid."; + } + catch (Exception ex) + { + logger.LogError(ex, "Error during OpenAPI document validation"); + + return $$$""" + + + Validation error: {{{ex.Message}}} + + + """; + } + } +} diff --git a/src/OpenApi.Client.Mcp/Tools/OpenApiTools.cs b/src/OpenApi.Client.Mcp/Tools/OpenApiTools.cs new file mode 100644 index 0000000..69452b0 --- /dev/null +++ b/src/OpenApi.Client.Mcp/Tools/OpenApiTools.cs @@ -0,0 +1,77 @@ +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, You can obtain one at https://opensource.org/licenses/MIT. +// Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. +// All Rights Reserved. + +using ModelContextProtocol.Server; + +// ReSharper disable UnusedMember.Global +namespace OpenApi.Client.Mcp.Tools; + +[McpServerToolType] +internal sealed class OpenApiTools +{ + [ + McpServerTool, + Description( + "Create OpenAPI client from the given URL address of the OpenAPI json file or swagger generated json" + ) + ] + public static async Task CreateClientFromUrl( + IOpenApiService service, + [Description("Url address of the OpenAPI json file or swagger generated json")] string address + ) + { + string result = await service.CreateFromFileAsync(address); + + return result; + } + + [ + McpServerTool, + Description( + "Get list of operations from the given URL address of the OpenAPI json file or swagger generated json" + ) + ] + public static async Task GetListOfOperations( + IOpenApiService service, + [Description("Url address of the OpenAPI json file or swagger generated json")] string address + ) + { + string result = await service.GetOperationsAsync(address); + + return result; + } + + [ + McpServerTool, + Description("Generate a curl command for a given operation ID from the OpenAPI specification") + ] + public static async Task GenerateCurlCommand( + IOpenApiService service, + [Description("Url address of the OpenAPI json file or swagger generated json")] string address, + [Description("Operation ID for which to generate the curl command")] string operationId, + [Description("Base address for the curl command, if any (optional)")] string? baseAddress + ) + { + string result = await service.GenerateCurlCommandAsync(address, operationId, baseAddress); + + return result; + } + + [ + McpServerTool, + Description( + "Validate the structure and syntax of an OpenAPI document to ensure it adheres to the OpenAPI specification" + ) + ] + public static async Task ValidateOpenApiDocument( + IOpenApiService service, + [Description("Url address of the OpenAPI json file or swagger generated json")] string address + ) + { + string result = await service.ValidateDocumentAsync(address); + + return result; + } +} diff --git a/src/OpenApi.Client.Mcp/appsettings.json b/src/OpenApi.Client.Mcp/appsettings.json new file mode 100644 index 0000000..d19f700 --- /dev/null +++ b/src/OpenApi.Client.Mcp/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.AspNetCore.DataProtection.KeyManagement": "Error", + "Microsoft.AspNetCore.Server.Kestrel": "Error", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Hosting.Lifetime": "Warning", + "Microsoft.AspNetCore.Mvc.Infrastructure": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/src/OpenApi.Client.SourceGenerators/Client/ClientGenerator.cs b/src/OpenApi.Client.SourceGenerators/Client/ClientGenerator.cs index 5d08861..93cd216 100644 --- a/src/OpenApi.Client.SourceGenerators/Client/ClientGenerator.cs +++ b/src/OpenApi.Client.SourceGenerators/Client/ClientGenerator.cs @@ -171,7 +171,6 @@ private string ComputeClient() .CompilationUnit() .AddMembers(namespaceDeclaration); - // Add an empty line after OpenApiClientGeneration.Header SyntaxTriviaList headerTrivia = SyntaxFactory.ParseLeadingTrivia(OpenApiClientGeneration.Header); compilationUnit = compilationUnit.WithLeadingTrivia(headerTrivia); @@ -255,8 +254,6 @@ private MemberDeclarationSyntax ComputeInterface() IEnumerable interfaceMembers = ComputeInterfaceMembers(); - // Generate the interface with the updated summary - // Add GeneratedCodeAttribute to the interface return SyntaxFactory .InterfaceDeclaration('I' + metadata.ClassName) .AddModifiers(SyntaxFactory.Token(metadata.Access.ToSyntaxKind())) @@ -277,6 +274,15 @@ KeyValuePair openApiOperation in openApiPath.Value ?? [] ) { + if ( + metadata.Operations.Length > 0 + && !metadata.Operations.Contains(openApiOperation.Value.OperationId) + ) + { + // NOTE: Skip operations not included in the metadata + continue; + } + // TODO: Handle parameters, request bodies, and responses IdentifierNameSyntax taskType = SyntaxFactory.IdentifierName( "global::System.Threading.Tasks.Task" @@ -362,7 +368,6 @@ private MemberDeclarationSyntax ComputeClass() ) ); - // Add GeneratedCodeAttribute to the class return SyntaxFactory .ClassDeclaration(metadata.ClassName) .AddModifiers( @@ -388,55 +393,20 @@ KeyValuePair openApiOperation in openApiPath.Value ?? [] ) { + if ( + metadata.Operations.Length > 0 + && !metadata.Operations.Contains(openApiOperation.Value.OperationId) + ) + { + // NOTE: Skip operations not included in the metadata + continue; + } + // TODO: Handle parameters, request bodies, and responses IdentifierNameSyntax taskType = SyntaxFactory.IdentifierName( "global::System.Threading.Tasks.Task" ); - FieldDeclarationSyntax httpClientField = SyntaxFactory - .FieldDeclaration( - SyntaxFactory - .VariableDeclaration( - SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient") - ) - .AddVariables(SyntaxFactory.VariableDeclarator("_httpClient")) - ) - .AddModifiers( - SyntaxFactory.Token(SyntaxKind.PrivateKeyword), - SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword) - ); - - PropertyDeclarationSyntax httpClientProperty = SyntaxFactory - .PropertyDeclaration( - SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient"), - "HttpClient" - ) - .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)) - .WithExpressionBody( - SyntaxFactory.ArrowExpressionClause(SyntaxFactory.IdentifierName("_httpClient")) - ) - .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); - - ConstructorDeclarationSyntax constructor = SyntaxFactory - .ConstructorDeclaration(metadata.ClassName) - .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)) - .AddParameterListParameters( - SyntaxFactory - .Parameter(SyntaxFactory.Identifier("httpClient")) - .WithType(SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient")) - ) - .WithBody( - SyntaxFactory.Block( - SyntaxFactory.ExpressionStatement( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("_httpClient"), - SyntaxFactory.IdentifierName("httpClient") - ) - ) - ) - ); - yield return SyntaxFactory .MethodDeclaration( taskType, @@ -468,6 +438,10 @@ KeyValuePair openApiOperation in openApiPath.Value private IEnumerable ComputeModels() { + //foreach (KeyValuePair schema in document.Components?.Schemas ?? []) + //{ + //} + // TODO: Implement model generation based on OpenAPI document yield break; } diff --git a/src/OpenApi.Client.SourceGenerators/Client/GeneratorData.cs b/src/OpenApi.Client.SourceGenerators/Client/GeneratorData.cs index 397dac6..c549c0a 100644 --- a/src/OpenApi.Client.SourceGenerators/Client/GeneratorData.cs +++ b/src/OpenApi.Client.SourceGenerators/Client/GeneratorData.cs @@ -22,7 +22,7 @@ public sealed record GeneratorData public string? Contents { get; init; } - public required string? Templates { get; init; } + public string? Templates { get; init; } public string[] Operations { get; init; } = []; diff --git a/src/OpenApi.Client.SourceGenerators/OpenApiClientGeneration.cs b/src/OpenApi.Client.SourceGenerators/OpenApiClientGeneration.cs index 9a5e5eb..25c5988 100644 --- a/src/OpenApi.Client.SourceGenerators/OpenApiClientGeneration.cs +++ b/src/OpenApi.Client.SourceGenerators/OpenApiClientGeneration.cs @@ -55,48 +55,25 @@ internal sealed class {{{MarkerAttributeName}}} : global::System.Attribute { /// Initializes a new instance of the class. /// The specification resource name for the Open API Client. + /// The list of operations to include in the Open API Client. /// The specification is the name of the resource which is a yaml or json open api code. - public {{{MarkerAttributeName}}}(string specification) + public {{{MarkerAttributeName}}}(string specification, params string[] operations) { Specification = specification; - Serialization = OpenApiClientSerialization.SystemTextJson; - Templates = null; - } - - /// Initializes a new instance of the class. - /// The specification resource name for the Open API Client. - /// A flag indicating whether to use the Service Collection for the Open API Client. - /// The specification is the name of the resource which is a yaml or json open api code. - public {{{MarkerAttributeName}}}(string specification, OpenApiClientSerialization serializationTool) - { - Specification = specification; - Serialization = serializationTool; - Templates = null; - } - - /// Initializes a new instance of the class. - /// The specification resource name for the Open API Client. - /// A flag indicating whether to use the Service Collection for the Open API Client. - /// Directory relative to specification file in which the templates are located. - /// The specification is the name of the resource which is a yaml or json open api code. - public {{{MarkerAttributeName}}}(string specification, OpenApiClientSerialization serializationTool, string templates) - { - Specification = specification; - Serialization = serializationTool; - Templates = templates; + Operations = operations; } /// The specification URL for the Open API Client. public string Specification { get; } /// A flag indicating whether to use the Service Collection for the Open API Client. - public OpenApiClientSerialization Serialization { get; } + public OpenApiClientSerialization Serialization { get; set; } = OpenApiClientSerialization.SystemTextJson; /// Directory relative to specification file in which the templates are located. public string? Templates { get; set; } /// List of operations to include in the Open API Client. - public string? Operations { get; set; } + public string[] Operations { get; } /// Whether the generated classes should use records. public bool UseRecords { get; set; } = true; diff --git a/src/OpenApi.Client.SourceGenerators/OpenApiClientGenerator.cs b/src/OpenApi.Client.SourceGenerators/OpenApiClientGenerator.cs index 8406ad1..c33b9c9 100644 --- a/src/OpenApi.Client.SourceGenerators/OpenApiClientGenerator.cs +++ b/src/OpenApi.Client.SourceGenerators/OpenApiClientGenerator.cs @@ -67,6 +67,11 @@ is not INamedTypeSymbol namedSymbol } string specification = string.Empty; + bool nullable = true; + bool useRecords = true; + string? templates = null; + string[] operations = []; + Location? location = null; SerializationTool serializationTool = SerializationTool.SystemTextJson; ImmutableArray attributes = namedSymbol.GetAttributes(); @@ -78,6 +83,33 @@ is not INamedTypeSymbol namedSymbol continue; } + foreach (KeyValuePair namedArgument in attribute.NamedArguments) + { + if (namedArgument.Key == "Templates") + { + templates = namedArgument.Value.Value as string; + } + + if (namedArgument.Key == "Nullable") + { + nullable = namedArgument.Value.Value is not bool boolValue || boolValue; + } + + if (namedArgument.Key == "UseRecords") + { + nullable = namedArgument.Value.Value is not bool boolValue || boolValue; + } + + if (namedArgument.Key == "SerializationTool") + { + serializationTool = (namedArgument.Value.Value is int intValue ? intValue : 0) switch + { + 1 => SerializationTool.NewtonsoftJson, + _ => SerializationTool.SystemTextJson, + }; + } + } + location = attribute.ApplicationSyntaxReference?.SyntaxTree.GetLocation( attribute.ApplicationSyntaxReference.Span ); @@ -92,10 +124,14 @@ is not INamedTypeSymbol namedSymbol { TypedConstant useDependencyInjectionArgument = attribute.ConstructorArguments[1]; - if (((int?)useDependencyInjectionArgument.Value ?? 0) == 1) - { - serializationTool = SerializationTool.NewtonsoftJson; - } + operations = ( + useDependencyInjectionArgument.Kind == TypedConstantKind.Array + ? useDependencyInjectionArgument + .Values.Select(v => v.Value as string) + .Where(s => s != null) + .ToArray() + : [] + )!; } } @@ -107,7 +143,10 @@ is not INamedTypeSymbol namedSymbol SerializationTool = serializationTool, Access = namedSymbol.DeclaredAccessibility, Location = location, - Templates = null, + Templates = templates, + Nullable = nullable, + UseRecords = useRecords, + Operations = operations, }; } @@ -179,6 +218,8 @@ private static void Execute( NamespaceName = compilationAndFiles.GeneratorData.NamespaceName, SerializationTool = compilationAndFiles.GeneratorData.SerializationTool, Templates = compilationAndFiles.GeneratorData.Templates, + Operations = compilationAndFiles.GeneratorData.Operations, + Nullable = compilationAndFiles.GeneratorData.Nullable, } ); diff --git a/src/OpenApi.Client.SourceGenerators/Readers/CustomOpenApiJsonReader.cs b/src/OpenApi.Client.SourceGenerators/Readers/CustomOpenApiJsonReader.cs index 2f0ede5..903c03f 100644 --- a/src/OpenApi.Client.SourceGenerators/Readers/CustomOpenApiJsonReader.cs +++ b/src/OpenApi.Client.SourceGenerators/Readers/CustomOpenApiJsonReader.cs @@ -6,10 +6,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -using Microsoft.OpenApi; -using Microsoft.OpenApi.Reader; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; namespace OpenApi.Client.SourceGenerators.Readers; diff --git a/tests/OpenApi.Client.SourceGenerators.UnitTests/CodeAnalysis/IncrementalGeneratorExtensions.cs b/tests/OpenApi.Client.SourceGenerators.UnitTests/CodeAnalysis/IncrementalGeneratorExtensions.cs index eea40b3..414bfe4 100644 --- a/tests/OpenApi.Client.SourceGenerators.UnitTests/CodeAnalysis/IncrementalGeneratorExtensions.cs +++ b/tests/OpenApi.Client.SourceGenerators.UnitTests/CodeAnalysis/IncrementalGeneratorExtensions.cs @@ -3,6 +3,7 @@ // Copyright (C) Leszek Pomianowski and OpenAPI Client Contributors. // All Rights Reserved. +using System; using System.Collections.Generic; using System.Linq; using Microsoft.CodeAnalysis.CSharp; @@ -13,8 +14,14 @@ public static class IncrementalGeneratorExtensions { /// /// Creates a for the specified generator and source code. + /// + /// @see + /// + /// + /// @see + /// /// - public static GeneratorDriver CreateDriver( + public static GeneratorDriver RunGenerators( this IIncrementalGenerator generator, string? sourceCode = null, params AdditionalText[] additionalTexts @@ -27,10 +34,13 @@ params AdditionalText[] additionalTexts """ ); - IEnumerable references = - [ - MetadataReference.CreateFromFile(typeof(IncrementalGeneratorExtensions).Assembly.Location), - ]; + IEnumerable references = AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(static assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat( + [MetadataReference.CreateFromFile(typeof(IncrementalGeneratorExtensions).Assembly.Location)] + ); CSharpCompilation compilation = CSharpCompilation.Create( assemblyName: "OpenApiClientUnitTest", diff --git a/tests/OpenApi.Client.SourceGenerators.UnitTests/SourceGeneratorTests.cs b/tests/OpenApi.Client.SourceGenerators.UnitTests/SourceGeneratorTests.cs index d4671aa..f31f298 100644 --- a/tests/OpenApi.Client.SourceGenerators.UnitTests/SourceGeneratorTests.cs +++ b/tests/OpenApi.Client.SourceGenerators.UnitTests/SourceGeneratorTests.cs @@ -13,7 +13,7 @@ public sealed class SourceGeneratorTests [Fact] public void Initialize_ShouldGenerateAttribute() { - GeneratorDriver driver = new OpenApiClientGenerator().CreateDriver(); + GeneratorDriver driver = new OpenApiClientGenerator().RunGenerators(); GeneratorDriverRunResult result = driver.GetRunResult(); @@ -36,14 +36,21 @@ public void Initialize_ShouldGenerateAttribute() [Fact] public void Initialize_ShouldGenerateSourceCode() { - GeneratorDriver driver = new OpenApiClientGenerator().CreateDriver( + GeneratorDriver driver = new OpenApiClientGenerator().RunGenerators( """ using OpenApi.Client; - namespace OpenApiClientUnitTest + namespace TickTack.Module { - [OpenApiClient("openapi-3.1.0")] - public partial class MyClient + [OpenApiClient( + "openapi-3.1.0", + "get-square", + "get-board", + Serialization = OpenApiClientSerialization.SystemTextJson, + Nullable = true, + UseRecords = true + )] + internal partial class TickTackToeClient { } } @@ -53,6 +60,16 @@ public partial class MyClient GeneratorDriverRunResult result = driver.GetRunResult(); - var test = 1; + SyntaxTree? generatedClientTree = result.GeneratedTrees.FirstOrDefault(x => + x.FilePath.Contains("TickTackToeClient.g.cs") + ); + generatedClientTree.Should().NotBeNull(); + + string generatedText = generatedClientTree.GetText().ToString(); + + generatedText + .Should() + .ContainAll("internal partial class TickTackToeClient", "GetSquare", "GetBoard") + .And.NotContainAll("PutSquare"); } }