diff --git a/.github/workflows/msbuild.yml b/.github/workflows/msbuild.yml new file mode 100644 index 000000000..08a370e9e --- /dev/null +++ b/.github/workflows/msbuild.yml @@ -0,0 +1,43 @@ +name: MSBuild Tasks + +on: + workflow_dispatch: + push: + paths-ignore: + - '**/*' + - '!.github/workflows/msbuild.yml' + - '!src/**/*.csproj' + - '!src/**/*.cs' + branches: + - 'main' + pull_request: + paths-ignore: + - '**/*' + - '!.github/workflows/msbuild.yml' + - '!src/**/*.csproj' + - '!src/**/*.cs' + branches: + - '*' + +jobs: + build: + name: 👌 Verify build + runs-on: windows-latest + steps: + - name: 🛒 Checkout repository + uses: actions/checkout@v4 + - name: 🛠️ Prepare + working-directory: test/MSBuild + run: | + dotnet restore ../../src/Refitter.sln + dotnet build -c release ../../src/Refitter/Refitter.csproj + dotnet build -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj + dotnet pack -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj -o . + nuget add .\Refitter.MSBuild.1.0.0.nupkg -source . + dotnet add package Refitter.MSBuild -s . + - name: 🛠️ Build + working-directory: test/MSBuild + run: dotnet build -v d -filelogger + - name: 🧪 Build Output + working-directory: test/MSBuild + run: cat msbuild.log diff --git a/.gitignore b/.gitignore index 15e87d3c7..64d61479c 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,4 @@ test/GeneratedCode/ test/Apizr/GeneratedCode test/MultipleFiles/**/*.cs test/HelloWorld/HelloWorld/Output.cs +test/MSBuild/refitter.msbuild/ diff --git a/src/Refitter.MSBuild/README.md b/src/Refitter.MSBuild/README.md new file mode 100644 index 000000000..e4af651c2 --- /dev/null +++ b/src/Refitter.MSBuild/README.md @@ -0,0 +1,178 @@ +## MSBuild Tasks for Refitter + +Refitter is available as custom MSBuild tasks that uses the [Refitter.Core](https://github.com/christianhelle/refitter/tree/main/src/Refitter.Core) library for generating a REST API Client using the [Refit](https://github.com/reactiveui/refit) library. Refitter can generate the Refit interface from OpenAPI specifications. Refitter could format the generated Refit interface to be managed by [Apizr](https://www.apizr.net) (v6+) and generate some registration helpers too. + + +### .Refitter File format + +The following is an example `.refitter` file + +```js +{ + "openApiPath": "/path/to/your/openAPI", // Required + "namespace": "Org.System.Service.Api.GeneratedCode", // Optional. Default=GeneratedCode + "naming": { + "useOpenApiTitle": false, // Optional. Default=true + "interfaceName": "MyApiClient" // Optional. Default=ApiClient + }, + "generateContracts": true, // Optional. Default=true + "generateXmlDocCodeComments": true, // Optional. Default=true + "generateStatusCodeComments": true, // Optional. Default=true + "addAutoGeneratedHeader": true, // Optional. Default=true + "addAcceptHeaders": true, // Optional. Default=true + "returnIApiResponse": false, // Optional. Default=false + "responseTypeOverride": { // Optional. Default={} + "File_Upload": "IApiResponse", + "File_Download": "System.Net.Http.HttpContent" + }, + "generateOperationHeaders": true, // Optional. Default=true + "typeAccessibility": "Public", // Optional. Values=Public|Internal. Default=Public + "useCancellationTokens": false, // Optional. Default=false + "useIsoDateFormat": false, // Optional. Default=false + "multipleInterfaces": "ByEndpoint", // Optional. May be one of "ByEndpoint" or "ByTag" + "generateDeprecatedOperations": false, // Optional. Default=true + "operationNameTemplate": "{operationName}Async", // Optional. Must contain {operationName} when multipleInterfaces != ByEndpoint + "optionalParameters": false, // Optional. Default=false + "outputFolder": "../CustomOutput" // Optional. Default=./Generated + "outputFilename": "RefitInterface.cs", // Optional. Default=Output.cs for CLI tool + "additionalNamespaces": [ // Optional + "Namespace1", + "Namespace2" + ], + "includeTags": [ // Optional. OpenAPI Tag to include when generating code + "Pet", + "Store", + "User" + ], + "includePathMatches": [ // Optional. Only include Paths that match the provided regular expression + "^/pet/.*", + "^/store/.*" + ], + "useDynamicQuerystringParameters": true, // Optional. Default=false + "usePolymorphicSerialization": false, // Optional. Default=false + "dependencyInjectionSettings": { // Optional + "baseUrl": "https://petstore3.swagger.io/api/v3", // Optional. Leave this blank to set the base address manually + "httpMessageHandlers": [ // Optional + "AuthorizationMessageHandler", + "TelemetryMessageHandler" + ], + "transientErrorHandler": "Polly", // Optional. Value=None|Polly|HttpResilience + "maxRetryCount": 3, // Optional. Default=6 + "firstBackoffRetryInSeconds": 0.5 // Optional. Default=1.0 + }, + "apizrSettings": { // Optional + "withRequestOptions": true, // Optional. Default=true + "withRegistrationHelper": true, // Optional. Default=false + "withCacheProvider": "InMemory", // Optional. Values=None|Akavache|MonkeyCache|InMemory|DistributedAsString|DistributedAsByteArray. Default=None + "withPriority": true, // Optional. Default=false + "withMediation": true, // Optional. Default=false + "withOptionalMediation": true, // Optional. Default=false + "withMappingProvider": "AutoMapper", // Optional. Values=None|AutoMapper|Mapster. Default=None + "withFileTransfer": true // Optional. Default=false + }, + "codeGeneratorSettings": { // Optional. Default settings are the values set in this example + "requiredPropertiesMustBeDefined": true, + "generateDataAnnotations": true, + "anyType": "object", + "dateType": "System.DateTimeOffset", + "dateTimeType": "System.DateTimeOffset", + "timeType": "System.TimeSpan", + "timeSpanType": "System.TimeSpan", + "arrayType": "System.Collections.Generic.ICollection", + "dictionaryType": "System.Collections.Generic.IDictionary", + "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "dictionaryInstanceType": "System.Collections.Generic.Dictionary", + "arrayBaseType": "System.Collections.ObjectModel.Collection", + "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "propertySetterAccessModifier": "", + "generateImmutableArrayProperties": false, + "generateImmutableDictionaryProperties": false, + "handleReferences": false, + "jsonSerializerSettingsTransformationMethod": null, + "generateJsonMethods": false, + "enforceFlagEnums": false, + "inlineNamedDictionaries": false, + "inlineNamedTuples": true, + "inlineNamedArrays": false, + "generateOptionalPropertiesAsNullable": false, + "generateNullableReferenceTypes": false, + "generateNativeRecords": false, + "generateDefaultValues": true, + "inlineNamedAny": false, + "excludedTypeNames": [ + "ExcludedTypeFoo", + "ExcludedTypeBar" + ] + } +} +``` + +- `openApiPath` - points to the OpenAPI Specifications file. This can be the path to a file stored on disk, relative to the `.refitter` file. This can also be a URL to a remote file that will be downloaded over HTTP/HTTPS +- `namespace` - the namespace used in the generated code. If not specified, this defaults to `GeneratedCode` +- `naming.useOpenApiTitle` - a boolean indicating whether the OpenApi title should be used. Default is `true` +- `naming.interfaceName` - the name of the generated interface. The generated code will automatically prefix this with `I` so if this set to `MyApiClient` then the generated interface is called `IMyApiClient`. Default is `ApiClient` +- `generateContracts` - a boolean indicating whether contracts should be generated. A use case for this is several API clients use the same contracts. Default is `true` +- `generateXmlDocCodeComments` - a boolean indicating whether XML doc comments should be generated. Default is `true` +- `addAutoGeneratedHeader` - a boolean indicating whether XML doc comments should be generated. Default is `true` +- `addAcceptHeaders` - a boolean indicating whether to add accept headers [Headers("Accept: application/json")]. Default is `true` +- `returnIApiResponse` - a boolean indicating whether to return `IApiResponse` objects. Default is `false` +- `responseTypeOverride` - a dictionary with operation ids (as specified in the OpenAPI document) and a particular return type to use. The types are wrapped in a task, but otherwise unmodified (so make sure to specify or import their namespaces). Default is `{}` +- `generateOperationHeaders` - a boolean indicating whether to use operation headers in the generated methods. Default is `true` +- `typeAccessibility` - the generated type accessibility. Possible values are `Public` and `Internal`. Default is `Public` +- `useCancellationTokens` - Use cancellation tokens in the generated methods. Default is `false` +- `useIsoDateFormat` - Set to `true` to explicitly format date query string parameters in ISO 8601 standard date format using delimiters (for example: 2023-06-15). Default is `false` +- `multipleInterfaces` - Set to `ByEndpoint` to generate an interface for each endpoint, or `ByTag` to group Endpoints by their Tag (like SwaggerUI groups them). +- `outputFolder` - a string describing a relative path to a desired output folder. Default is `./Generated` +- `outputFilename` - Output filename. Default is `Output.cs` when used from the CLI tool, otherwise its the .refitter filename. So `Petstore.refitter` becomes `Petstore.cs`. +- `additionalNamespaces` - A collection of additional namespaces to include in the generated file. A use case for this is when you want to reuse contracts from a different namespace than the generated code. Default is empty +- `includeTags` - A collection of tags to use a filter for including endpoints that contain this tag. +- `includePathMatches` - A collection of regular expressions used to filter paths. +- `generateDeprecatedOperations` - a boolean indicating whether deprecated operations should be generated or skipped. Default is `true` +- `operationNameTemplate` - Generate operation names using pattern. This must contain the string {operationName}. An example usage of this could be `{operationName}Async` to suffix all method names with Async. When using `"multipleIinterfaces": "ByEndpoint"`, This is name of the Execute() method in the interface +- `optionalParameters` - Generate non-required parameters as nullable optional parameters +- `useDynamicQuerystringParameters`: Set to `true` to wrap multiple query parameters into a single complex one. Default is `false` (no wrapping). +- `dependencyInjectionSettings` - Setting this will generated extension methods to `IServiceCollection` for configuring Refit clients. See https://github.com/reactiveui/refit?tab=readme-ov-file#dynamic-querystring-parameters for more information. + - `baseUrl` - Used as the HttpClient base address. Leave this blank to manually set the base URL + - `httpMessageHandlers` - A collection of `HttpMessageHandler` that is added to the HttpClient pipeline + - `transientErrorHandler` - This is the transient error handler to use. Possible values are `None`, `Polly`, and `HttpResilience`. Default is `None` + - `maxRetryCount` - This is the max retry count used in the Polly retry policy. Default is 6 + - `firstBackoffRetryInSeconds` - This is the duration of the initial retry backoff. Default is 1 second +- `apizrSettings` - Setting this will format Refit interface to be managed by Apizr. See https://www.apizr.net for more information + - `withRequestOptions` - Tells if the Refit interface methods should have a final IApizrRequestOptions options parameter + - `withRegistrationHelper` - Tells if Refitter should generate Apizr registration helpers (extended with dependencyInjectionSettings set, otherwise static) + - `withCacheProvider` - Set the cache provider to be used + - `withPriority` - Tells if Apizr should handle request priority + - `withMediation` - Tells if Apizr should handle request mediation (extended only) + - `withOptionalMediation` - Tells if Apizr should handle optional request mediation (extended only) + - `withMappingProvider` - Set the mapping provider to be used + - `withFileTransfer` - Tells if Apizr should handle file transfer +- `codeGeneratorSettings` - Setting this allows customization of the NSwag generated types and contracts + - `requiredPropertiesMustBeDefined` - Default is true, + - `generateDataAnnotations` - Default is true, + - `anyType` - Default is `object`, + - `dateType` - Default is `System.DateTimeOffset`, + - `dateTimeType` - Default is `System.DateTimeOffset`, + - `timeType` - Default is `System.TimeSpan`, + - `timeSpanType` - Default is `System.TimeSpan`, + - `arrayType` - Default is `System.Collections.Generic.ICollection`, + - `dictionaryType` - Default is `System.Collections.Generic.IDictionary`, + - `arrayInstanceType` - Default is `System.Collections.ObjectModel.Collection`, + - `dictionaryInstanceType` - Default is `System.Collections.Generic.Dictionary`, + - `arrayBaseType` - Default is `System.Collections.ObjectModel.Collection`, + - `dictionaryBaseType` - Default is `System.Collections.Generic.Dictionary`, + - `propertySetterAccessModifier` - Default is ``, + - `generateImmutableArrayProperties` - Default is false, + - `generateImmutableDictionaryProperties` - Default is false, + - `handleReferences` - Default is false, + - `jsonSerializerSettingsTransformationMethod` - Default is null, + - `generateJsonMethods` - Default is false, + - `enforceFlagEnums` - Default is false, + - `inlineNamedDictionaries` - Default is false, + - `inlineNamedTuples` - Default is true, + - `inlineNamedArrays` - Default is false, + - `generateOptionalPropertiesAsNullable` - Default is false, + - `generateNullableReferenceTypes` - Default is false, + - `generateNativeRecords` - Default is false + - `generateDefaultValues` - Default is true + - `inlineNamedAny` - Default is false + - `excludedTypeNames` - Default is empty diff --git a/src/Refitter.MSBuild/Refitter.MSBuild.csproj b/src/Refitter.MSBuild/Refitter.MSBuild.csproj new file mode 100644 index 000000000..8111715d8 --- /dev/null +++ b/src/Refitter.MSBuild/Refitter.MSBuild.csproj @@ -0,0 +1,30 @@ + + + + netstandard2.0 + Refitter MSBuild Tasks + Refitter.MSBuild + Refitter.MSBuild + Refitter.MSBuild + Refitter.MSBuild + A MSBuid task that uses Refitter for generating a REST API Client using the Refit library from OpenAPI specifications. + true + tasks + + + + + + + + + + + + + + + diff --git a/src/Refitter.MSBuild/Refitter.MSBuild.props b/src/Refitter.MSBuild/Refitter.MSBuild.props new file mode 100644 index 000000000..6d6f41cae --- /dev/null +++ b/src/Refitter.MSBuild/Refitter.MSBuild.props @@ -0,0 +1,12 @@ + + + + $(MSBuildThisFileDirectory)..\tasks + $(TaskFolder)\netstandard2.0\Refitter.MSBuild.dll + $(Version) + + + + + diff --git a/src/Refitter.MSBuild/Refitter.MSBuild.targets b/src/Refitter.MSBuild/Refitter.MSBuild.targets new file mode 100644 index 000000000..777951c07 --- /dev/null +++ b/src/Refitter.MSBuild/Refitter.MSBuild.targets @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Refitter.MSBuild/RefitterGenerateTask.cs b/src/Refitter.MSBuild/RefitterGenerateTask.cs new file mode 100644 index 000000000..80d32b1ea --- /dev/null +++ b/src/Refitter.MSBuild/RefitterGenerateTask.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Reflection; +using MSBuildTask = Microsoft.Build.Utilities.Task; + +namespace Refitter.MSBuild; + +public class RefitterGenerateTask : MSBuildTask +{ + public string ProjectFileDirectory { get; set; } + + public override bool Execute() + { + TryLogCommandLine($"Starting {nameof(RefitterGenerateTask)}"); + TryLogCommandLine($"Looking for .refitter files under {ProjectFileDirectory}"); + + var files = Directory.GetFiles( + ProjectFileDirectory, + "*.refitter", + SearchOption.AllDirectories); + + TryLogCommandLine($"Found {files.Length} .refitter files..."); + + foreach (var file in files) + { + TryLogCommandLine($"Processing {file}"); + TryExecuteRefitter(file); + } + + return true; + } + + private void TryExecuteRefitter(string file) + { + try + { + StartProcess(file); + } + catch (Exception e) + { + TryLogErrorFromException(e); + } + } + + private void StartProcess(string file) + { + var assembly = Assembly.GetExecutingAssembly(); + var packageFolder = Path.GetDirectoryName(assembly.Location); + var refitterDll = $"{packageFolder}\\..\\refitter.dll"; + TryLogCommandLine("Starting " + refitterDll); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"{refitterDll} --settings-file {file}", + WorkingDirectory = Path.GetDirectoryName(file)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + process.ErrorDataReceived += (_, args) => TryLogError(args.Data); + process.OutputDataReceived += (_, args) => TryLogCommandLine(args.Data); + process.Start(); + process.BeginErrorReadLine(); + process.BeginOutputReadLine(); + process.WaitForExit(); + } + + private void TryLogErrorFromException(Exception e) + { + try + { + Log.LogErrorFromException(e); + } + catch + { + // Ignore + } + } + + private void TryLogCommandLine(string text) + { + try + { + Log.LogCommandLine(text); + } + catch + { + // ignore + } + } + + private void TryLogError(string text) + { + try + { + Log.LogError(text); + } + catch + { + // ignore + } + } +} diff --git a/src/Refitter.sln b/src/Refitter.sln index 29b0a310f..2b3699133 100644 --- a/src/Refitter.sln +++ b/src/Refitter.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Refitter.SourceGenerator", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Refitter.SourceGenerator.Tests", "Refitter.SourceGenerator.Tests\Refitter.SourceGenerator.Tests.csproj", "{AFF920A9-765D-4ABA-9BEF-2A5BE7A2AFAA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Refitter.MSBuild", "Refitter.MSBuild\Refitter.MSBuild.csproj", "{3CBC5045-9427-42D5-B71F-C25EAC86E042}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {AFF920A9-765D-4ABA-9BEF-2A5BE7A2AFAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {AFF920A9-765D-4ABA-9BEF-2A5BE7A2AFAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {AFF920A9-765D-4ABA-9BEF-2A5BE7A2AFAA}.Release|Any CPU.Build.0 = Release|Any CPU + {3CBC5045-9427-42D5-B71F-C25EAC86E042}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CBC5045-9427-42D5-B71F-C25EAC86E042}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CBC5045-9427-42D5-B71F-C25EAC86E042}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CBC5045-9427-42D5-B71F-C25EAC86E042}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/test/MSBuild/Program.cs b/test/MSBuild/Program.cs new file mode 100644 index 000000000..8a64d5f0d --- /dev/null +++ b/test/MSBuild/Program.cs @@ -0,0 +1,18 @@ +// See https://aka.ms/new-console-template for more information + +using Refit; +using Refitter.MSBuild.Tests.Petstore; + +var client = RestService.For("https://petstore3.swagger.io/api/v3"); +var pet = await client.GetPetById(1); + +Console.WriteLine($"Name: {pet.Name}"); +Console.WriteLine($"Status: {pet.Status}"); + +var pets = await client.FindPetsByStatus(Status.Available); +Console.WriteLine("Found " + pets.Count + " available pet(s)"); + +var taggedPets = await client.FindPetsByTags(["tag1Updated", "new"]); +Console.WriteLine("Found " + taggedPets.Count + " tagged pet(s)"); + +Console.WriteLine(); diff --git a/test/MSBuild/Refitter.MSBuild.Tests.csproj b/test/MSBuild/Refitter.MSBuild.Tests.csproj new file mode 100644 index 000000000..36282569b --- /dev/null +++ b/test/MSBuild/Refitter.MSBuild.Tests.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + diff --git a/test/MSBuild/build.ps1 b/test/MSBuild/build.ps1 new file mode 100644 index 000000000..cbf547178 --- /dev/null +++ b/test/MSBuild/build.ps1 @@ -0,0 +1,18 @@ +Remove-Item bin -Force -Recurse +Remove-Item obj -Force -Recurse +dotnet build-server shutdown +nuget locals global-packages -clear +Remove-Item ./refitter.msbuild -Recurse -Force +Remove-Item Refitter.MSBuild.*.nupkg -Force +Remove-Item Petstore.cs +dotnet restore ../../src/Refitter.sln +dotnet clean -c release ../../src/Refitter.sln +dotnet build -c release ../../src/Refitter/Refitter.csproj +dotnet build -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj +dotnet pack -c release ../../src/Refitter.MSBuild/Refitter.MSBuild.csproj -o . +nuget add .\Refitter.MSBuild.1.0.0.nupkg -source . +dotnet restore +dotnet add package Refitter.MSBuild -s . +dotnet build -v d -filelogger +dotnet remove package Refitter.MSBuild +Remove-Item Refitter.MSBuild.*.nupkg -Force \ No newline at end of file diff --git a/test/MSBuild/petstore.refitter b/test/MSBuild/petstore.refitter new file mode 100644 index 000000000..0bac6d1b2 --- /dev/null +++ b/test/MSBuild/petstore.refitter @@ -0,0 +1,10 @@ +{ + "openApiPath": "https://petstore3.swagger.io/api/v3/openapi.json", + "namespace": "Refitter.MSBuild.Tests.Petstore", + "outputFileName": "Petstore.cs", + "naming": { + "useOpenApiTitle": false, + "interfaceName": "SwaggerPetstore", + "filename": "SwaggerPetstore" + } +}