├── .gitignore
├── LICENSE
├── README.md
├── RendleLabs.OpenApi.sln
├── RendleLabs.OpenApi.sln.DotSettings
├── experiments
└── ApiBase
│ ├── Api
│ ├── Books.cs
│ ├── BooksBase.cs
│ └── MapApiExtension.cs
│ ├── ApiBase.csproj
│ ├── Data
│ └── BookData.cs
│ ├── Models
│ └── Book.cs
│ ├── Program.cs
│ ├── Properties
│ └── launchSettings.json
│ ├── appsettings.Development.json
│ └── appsettings.json
├── global.json
├── src
├── Analyzer
│ ├── Analyzer.csproj
│ ├── AttributeHelper.cs
│ ├── ControllerAnalyzer.cs
│ ├── Facts
│ │ ├── IsActionMethodExtension.cs
│ │ └── IsController.cs
│ ├── NamespaceHelper.cs
│ ├── TextHelpers.cs
│ └── TypeHelper.cs
├── Build
│ ├── Build.csproj
│ ├── Builder.cs
│ ├── OpenApiDiagnosticWrite.cs
│ ├── Program.cs
│ ├── ReferenceVisitor.cs
│ └── SchemaLoader.cs
├── Bundle
│ ├── Bundle.csproj
│ ├── BundleException.cs
│ ├── Bundler.cs
│ ├── FragmentFinder.cs
│ ├── OpenApiDiagnosticWrite.cs
│ ├── Program.cs
│ ├── ReferenceInfo.cs
│ ├── ReferenceInfoCollection.cs
│ ├── ReferenceLoader.cs
│ ├── ReferencePath.cs
│ ├── ReferenceResolver.cs
│ ├── ReferenceVisitor.cs
│ ├── ReferenceWalker.cs
│ ├── SchemaLoader.cs
│ └── YamlMappingNodeExtensions.cs
├── Generator
│ ├── ApiFirst
│ │ ├── ApiBaseGenerator.cs
│ │ ├── ApiFirstGenerator.cs
│ │ ├── CSharpHelpers.cs
│ │ ├── ModelDefinition.cs
│ │ ├── ModelFinder.cs
│ │ ├── ModelGenerator.cs
│ │ ├── ModelProperty.cs
│ │ ├── ParameterHelpers.cs
│ │ ├── PathItemHelpers.cs
│ │ ├── ResultHelper.cs
│ │ ├── SchemaHelpers.cs
│ │ └── StatusCodeHelper.cs
│ ├── Controllers
│ │ ├── ActionMethodParameter.cs
│ │ └── BaseActionMethodGenerator.cs
│ ├── Generate.cs
│ ├── Generator.csproj
│ ├── Internal
│ │ └── IndentedTextWriterExtensions.cs
│ ├── MinimalApi
│ │ ├── MinimalApiGenerator.cs
│ │ └── Project.xml
│ └── Program.cs
├── Testing.Xunit
│ ├── Testing.Xunit.csproj
│ └── XunitAssertExtension.cs
├── Testing
│ ├── HttpClientExtensions.cs
│ ├── Internal
│ │ ├── JsonDocumentExtensions.cs
│ │ ├── LinqExtensions.cs
│ │ ├── OpenApiModelExtensions.cs
│ │ ├── QueryString.cs
│ │ ├── YamlExtensions.cs
│ │ └── YamlToJson.cs
│ ├── JsonAssert.cs
│ ├── JsonEqualException.cs
│ ├── OpenApiTest.cs
│ ├── OpenApiTestBody.cs
│ ├── OpenApiTestDocument.cs
│ ├── OpenApiTestDocumentParser.cs
│ ├── OpenApiTestElement.cs
│ ├── OpenApiTestExpect.cs
│ ├── OpenApiTestPath.cs
│ ├── OpenApiTestRequest.cs
│ ├── OpenApiTestResponse.cs
│ ├── OpenApiTheoryData.cs
│ └── Testing.csproj
└── Web
│ ├── ElementsEndpoint.cs
│ ├── EndpointConventionBuilderExtensions.cs
│ ├── RedocEndpoint.cs
│ ├── Resources
│ ├── elements.html
│ └── redoc.html
│ ├── StaticOpenApiEndpointRouteBuilderExtensions.cs
│ ├── StaticOpenApiLoadException.cs
│ ├── StaticOpenApiOptions.cs
│ ├── SwaggerUiEndpoints.cs
│ ├── Web.csproj
│ ├── package-lock.json
│ └── package.json
├── test
├── Analyzer.Tests
│ ├── Analyzer.Tests.csproj
│ ├── UnitTest1.cs
│ └── Usings.cs
├── Bundle.Tests
│ ├── Bundle.Tests.csproj
│ ├── FragmentFinderTests.cs
│ ├── ReferenceWalkerTests.cs
│ ├── TestData
│ │ ├── Responses
│ │ │ └── InternalServerError.yaml
│ │ ├── Schema
│ │ │ ├── Country.json
│ │ │ ├── IsoCountryCode.json
│ │ │ └── ProblemDetails.json
│ │ ├── openapi.json
│ │ └── openapi.yaml
│ ├── Usings.cs
│ └── YamlTest.cs
├── Generator.Tests
│ ├── ApiBaseGeneratorTests.cs
│ ├── BaseActionMethodGeneratorTests.cs
│ ├── Generator.Tests.csproj
│ ├── ModelFinderTests.cs
│ ├── ModelGeneratorTests.cs
│ ├── Usings.cs
│ ├── Writer.cs
│ └── openapi.yaml
├── Testing.Api
│ ├── Data
│ │ ├── Book.cs
│ │ ├── BookData.cs
│ │ └── NewBook.cs
│ ├── Program.cs
│ ├── Properties
│ │ └── launchSettings.json
│ ├── Testing.Api.csproj
│ ├── appsettings.Development.json
│ └── appsettings.json
├── Testing.Tests
│ ├── Api
│ │ └── openapi.tests.yaml
│ ├── ApiTests.cs
│ ├── HttpBin
│ │ ├── json
│ │ │ ├── openapi.json
│ │ │ └── openapi.tests.json
│ │ └── yaml
│ │ │ ├── openapi.tests.yaml
│ │ │ └── openapi.yaml
│ ├── MemberDataTests.cs
│ ├── OpenApiTestDocumentParserTests.cs
│ ├── OpenTheoryDataTests.cs
│ ├── ResourceStrings.cs
│ ├── SequenceTests.cs
│ ├── Testing.Tests.csproj
│ └── Usings.cs
├── Testing.WebApi
│ ├── Controllers
│ │ └── WeatherForecastImpl.cs
│ ├── Program.cs
│ ├── Properties
│ │ └── launchSettings.json
│ ├── Testing.WebApi.csproj
│ ├── WeatherForecast.cs
│ ├── appsettings.Development.json
│ └── appsettings.json
├── Web.TestApp
│ ├── Program.cs
│ ├── Properties
│ │ └── launchSettings.json
│ ├── Web.TestApp.csproj
│ ├── api.md
│ ├── appsettings.Development.json
│ ├── appsettings.json
│ └── openapi.yaml
└── WebApi.TestApp
│ ├── Controllers
│ └── WeatherForecastController.cs
│ ├── Program.cs
│ ├── Properties
│ └── launchSettings.json
│ ├── WeatherForecast.cs
│ ├── WebApi.TestApp.csproj
│ ├── appsettings.Development.json
│ └── appsettings.json
└── ui
├── package-lock.json
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 | [Ll]ogs/
33 |
34 | # Rider
35 | .idea/
36 |
37 | # Visual Studio 2015/2017 cache/options directory
38 | .vs/
39 | # Uncomment if you have tasks that create the project's static files in wwwroot
40 | #wwwroot/
41 |
42 | # Visual Studio 2017 auto generated files
43 | Generated\ Files/
44 |
45 | # MSTest test Results
46 | [Tt]est[Rr]esult*/
47 | [Bb]uild[Ll]og.*
48 |
49 | # NUnit
50 | *.VisualState.xml
51 | TestResult.xml
52 | nunit-*.xml
53 |
54 | # Build Results of an ATL Project
55 | [Dd]ebugPS/
56 | [Rr]eleasePS/
57 | dlldata.c
58 |
59 | # Benchmark Results
60 | BenchmarkDotNet.Artifacts/
61 |
62 | # .NET Core
63 | project.lock.json
64 | project.fragment.lock.json
65 | artifacts/
66 |
67 | # StyleCop
68 | StyleCopReport.xml
69 |
70 | # Files built by Visual Studio
71 | *_i.c
72 | *_p.c
73 | *_h.h
74 | *.ilk
75 | *.meta
76 | *.obj
77 | *.iobj
78 | *.pch
79 | *.pdb
80 | *.ipdb
81 | *.pgc
82 | *.pgd
83 | *.rsp
84 | *.sbr
85 | *.tlb
86 | *.tli
87 | *.tlh
88 | *.tmp
89 | *.tmp_proj
90 | *_wpftmp.csproj
91 | *.log
92 | *.vspscc
93 | *.vssscc
94 | .builds
95 | *.pidb
96 | *.svclog
97 | *.scc
98 |
99 | # Chutzpah Test files
100 | _Chutzpah*
101 |
102 | # Visual C++ cache files
103 | ipch/
104 | *.aps
105 | *.ncb
106 | *.opendb
107 | *.opensdf
108 | *.sdf
109 | *.cachefile
110 | *.VC.db
111 | *.VC.VC.opendb
112 |
113 | # Visual Studio profiler
114 | *.psess
115 | *.vsp
116 | *.vspx
117 | *.sap
118 |
119 | # Visual Studio Trace Files
120 | *.e2e
121 |
122 | # TFS 2012 Local Workspace
123 | $tf/
124 |
125 | # Guidance Automation Toolkit
126 | *.gpState
127 |
128 | # ReSharper is a .NET coding add-in
129 | _ReSharper*/
130 | *.[Rr]e[Ss]harper
131 | *.DotSettings.user
132 |
133 | # TeamCity is a build add-in
134 | _TeamCity*
135 |
136 | # DotCover is a Code Coverage Tool
137 | *.dotCover
138 |
139 | # AxoCover is a Code Coverage Tool
140 | .axoCover/*
141 | !.axoCover/settings.json
142 |
143 | # Visual Studio code coverage results
144 | *.coverage
145 | *.coveragexml
146 |
147 | # NCrunch
148 | _NCrunch_*
149 | .*crunch*.local.xml
150 | nCrunchTemp_*
151 |
152 | # MightyMoose
153 | *.mm.*
154 | AutoTest.Net/
155 |
156 | # Web workbench (sass)
157 | .sass-cache/
158 |
159 | # Installshield output folder
160 | [Ee]xpress/
161 |
162 | # DocProject is a documentation generator add-in
163 | DocProject/buildhelp/
164 | DocProject/Help/*.HxT
165 | DocProject/Help/*.HxC
166 | DocProject/Help/*.hhc
167 | DocProject/Help/*.hhk
168 | DocProject/Help/*.hhp
169 | DocProject/Help/Html2
170 | DocProject/Help/html
171 |
172 | # Click-Once directory
173 | publish/
174 |
175 | # Publish Web Output
176 | *.[Pp]ublish.xml
177 | *.azurePubxml
178 | # Note: Comment the next line if you want to checkin your web deploy settings,
179 | # but database connection strings (with potential passwords) will be unencrypted
180 | *.pubxml
181 | *.publishproj
182 |
183 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
184 | # checkin your Azure Web App publish settings, but sensitive information contained
185 | # in these scripts will be unencrypted
186 | PublishScripts/
187 |
188 | # NuGet Packages
189 | *.nupkg
190 | # NuGet Symbol Packages
191 | *.snupkg
192 | # The packages folder can be ignored because of Package Restore
193 | **/[Pp]ackages/*
194 | # except build/, which is used as an MSBuild target.
195 | !**/[Pp]ackages/build/
196 | # Uncomment if necessary however generally it will be regenerated when needed
197 | #!**/[Pp]ackages/repositories.config
198 | # NuGet v3's project.json files produces more ignorable files
199 | *.nuget.props
200 | *.nuget.targets
201 |
202 | # Microsoft Azure Build Output
203 | csx/
204 | *.build.csdef
205 |
206 | # Microsoft Azure Emulator
207 | ecf/
208 | rcf/
209 |
210 | # Windows Store app package directories and files
211 | AppPackages/
212 | BundleArtifacts/
213 | Package.StoreAssociation.xml
214 | _pkginfo.txt
215 | *.appx
216 | *.appxbundle
217 | *.appxupload
218 |
219 | # Visual Studio cache files
220 | # files ending in .cache can be ignored
221 | *.[Cc]ache
222 | # but keep track of directories ending in .cache
223 | !?*.[Cc]ache/
224 |
225 | # Others
226 | ClientBin/
227 | ~$*
228 | *~
229 | *.dbmdl
230 | *.dbproj.schemaview
231 | *.jfm
232 | *.pfx
233 | *.publishsettings
234 | orleans.codegen.cs
235 |
236 | # Including strong name files can present a security risk
237 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
238 | #*.snk
239 |
240 | # Since there are multiple workflows, uncomment next line to ignore bower_components
241 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
242 | #bower_components/
243 |
244 | # RIA/Silverlight projects
245 | Generated_Code/
246 |
247 | # Backup & report files from converting an old project file
248 | # to a newer Visual Studio version. Backup files are not needed,
249 | # because we have git ;-)
250 | _UpgradeReport_Files/
251 | Backup*/
252 | UpgradeLog*.XML
253 | UpgradeLog*.htm
254 | ServiceFabricBackup/
255 | *.rptproj.bak
256 |
257 | # SQL Server files
258 | *.mdf
259 | *.ldf
260 | *.ndf
261 |
262 | # Business Intelligence projects
263 | *.rdl.data
264 | *.bim.layout
265 | *.bim_*.settings
266 | *.rptproj.rsuser
267 | *- [Bb]ackup.rdl
268 | *- [Bb]ackup ([0-9]).rdl
269 | *- [Bb]ackup ([0-9][0-9]).rdl
270 |
271 | # Microsoft Fakes
272 | FakesAssemblies/
273 |
274 | # GhostDoc plugin setting file
275 | *.GhostDoc.xml
276 |
277 | # Node.js Tools for Visual Studio
278 | .ntvs_analysis.dat
279 | node_modules/
280 |
281 | # Visual Studio 6 build log
282 | *.plg
283 |
284 | # Visual Studio 6 workspace options file
285 | *.opt
286 |
287 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
288 | *.vbw
289 |
290 | # Visual Studio LightSwitch build output
291 | **/*.HTMLClient/GeneratedArtifacts
292 | **/*.DesktopClient/GeneratedArtifacts
293 | **/*.DesktopClient/ModelManifest.xml
294 | **/*.Server/GeneratedArtifacts
295 | **/*.Server/ModelManifest.xml
296 | _Pvt_Extensions
297 |
298 | # Paket dependency manager
299 | .paket/paket.exe
300 | paket-files/
301 |
302 | # FAKE - F# Make
303 | .fake/
304 |
305 | # CodeRush personal settings
306 | .cr/personal
307 |
308 | # Python Tools for Visual Studio (PTVS)
309 | __pycache__/
310 | *.pyc
311 |
312 | # Cake - Uncomment if you are using it
313 | # tools/**
314 | # !tools/packages.config
315 |
316 | # Tabs Studio
317 | *.tss
318 |
319 | # Telerik's JustMock configuration file
320 | *.jmconfig
321 |
322 | # BizTalk build output
323 | *.btp.cs
324 | *.btm.cs
325 | *.odx.cs
326 | *.xsd.cs
327 |
328 | # OpenCover UI analysis results
329 | OpenCover/
330 |
331 | # Azure Stream Analytics local run output
332 | ASALocalRun/
333 |
334 | # MSBuild Binary and Structured Log
335 | *.binlog
336 |
337 | # NVidia Nsight GPU debugger configuration file
338 | *.nvuser
339 |
340 | # MFractors (Xamarin productivity tool) working folder
341 | .mfractor/
342 |
343 | # Local History for Visual Studio
344 | .localhistory/
345 |
346 | # BeatPulse healthcheck temp database
347 | healthchecksdb
348 |
349 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
350 | MigrationBackup/
351 |
352 | # Ionide (cross platform F# VS Code tools) working folder
353 | .ionide/
354 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 RendleLabs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OpenApi
2 | Libraries for working with static OpenApi files and testing OpenApi implementations
3 |
--------------------------------------------------------------------------------
/RendleLabs.OpenApi.sln.DotSettings:
--------------------------------------------------------------------------------
1 |
2 | True
3 | True
4 | True
--------------------------------------------------------------------------------
/experiments/ApiBase/Api/Books.cs:
--------------------------------------------------------------------------------
1 | using ApiBase.Data;
2 | using ApiBase.Models;
3 |
4 | namespace ApiBase.Api;
5 |
6 | public sealed class Books : BooksBase
7 | {
8 | private readonly BookData _data;
9 | private readonly ILogger _logger;
10 |
11 | public Books(BookData data, ILogger logger)
12 | {
13 | _data = data;
14 | _logger = logger;
15 | }
16 |
17 | protected override ValueTask Get(int id, HttpContext context)
18 | {
19 | try
20 | {
21 | var book = _data.Get(id);
22 | return new ValueTask(book is null
23 | ? NotFound()
24 | : Ok(book));
25 | }
26 | catch (Exception ex)
27 | {
28 | _logger.LogError(ex, "Books.Get failed");
29 | throw;
30 | }
31 | }
32 |
33 | protected override ValueTask Add(Book book, HttpContext context)
34 | {
35 | book = _data.Add(book);
36 | return new ValueTask(Created(Links.Get(book.Id)));
37 | }
38 | }
--------------------------------------------------------------------------------
/experiments/ApiBase/Api/BooksBase.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Text;
3 | using Microsoft.AspNetCore.Mvc;
4 | using ApiBase.Models;
5 |
6 | namespace ApiBase.Api;
7 |
8 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.NonPublicMethods)]
9 | public abstract partial class BooksBase
10 | {
11 | private static readonly IResult NotImplementedResult = Results.StatusCode(501);
12 |
13 | private static void __Map(WebApplication app, Func builder) where T : BooksBase
14 | {
15 | app.MapGet("/books/{id:int}", (int id, HttpContext context) =>
16 | {
17 | var impl = context.RequestServices.GetService() ?? builder(context.RequestServices);
18 | return impl.Get(id, context);
19 | });
20 |
21 | app.MapPost("/books", ([FromBody] Book book, HttpContext context) =>
22 | {
23 | var impl = context.RequestServices.GetService() ?? builder(context.RequestServices);
24 | return impl.Add(book, context);
25 | });
26 | }
27 |
28 | protected static IResult Ok(Book book) => Results.Ok(book);
29 | protected static IResult Created(Uri uri) => Results.Created(uri, null);
30 | protected static IResult NotFound() => Results.NotFound();
31 |
32 | public static LinkProvider Links { get; } = new LinkProvider();
33 |
34 | public readonly struct LinkProvider
35 | {
36 | public Uri Get(int id, string? format = null) => new($"/books/{id}{GetQueryString(format)}", UriKind.Relative);
37 | public Uri Add() => new Uri("/books", UriKind.Relative);
38 |
39 | private static string GetQueryString(string? format)
40 | {
41 | if (format is null) return string.Empty;
42 | var builder = new StringBuilder();
43 | if (format is not null)
44 | {
45 | if (builder.Length == 0)
46 | {
47 | builder.Append('?');
48 | }
49 | else
50 | {
51 | builder.Append('&');
52 | }
53 |
54 | builder.Append($"format={Uri.EscapeDataString(format)}");
55 | }
56 |
57 | return builder.ToString();
58 | }
59 | }
60 |
61 | protected virtual ValueTask Get(int id, HttpContext context) => new(NotImplementedResult);
62 |
63 | protected virtual ValueTask Add(Book book, HttpContext context) => new(NotImplementedResult);
64 | }
--------------------------------------------------------------------------------
/experiments/ApiBase/Api/MapApiExtension.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 |
3 | namespace ApiBase.Api;
4 |
5 | public static class MapApiExtension
6 | {
7 | public static WebApplication MapApi(this WebApplication app)
8 | {
9 | var mapMethod = GetMapMethod();
10 | mapMethod.Invoke(null, new object[]{ app, CreateBuilder() });
11 | return app;
12 | }
13 |
14 | private static MethodInfo GetMapMethod()
15 | {
16 | var type = typeof(T);
17 | MethodInfo? mapMethod = null;
18 | while (mapMethod is null && type is not null)
19 | {
20 | mapMethod = type.GetMethod("__Map", BindingFlags.Static | BindingFlags.NonPublic);
21 | type = type.BaseType;
22 | }
23 |
24 | if (mapMethod is null) throw new InvalidOperationException();
25 | return mapMethod.MakeGenericMethod(typeof(T));
26 | }
27 |
28 | private static Func CreateBuilder()
29 | {
30 | var ctors = typeof(T).GetConstructors(BindingFlags.Public | BindingFlags.Instance);
31 | var ctor = ctors.FirstOrDefault();
32 | if (ctor is null) return _ => Activator.CreateInstance();
33 | var parameterTypes = ctor.GetParameters();
34 | return services =>
35 | {
36 | int length = parameterTypes.Length;
37 | object[] parameters = new object[length];
38 | for (int i = 0; i < length; i++)
39 | {
40 | var parameterType = parameterTypes[i].ParameterType;
41 | parameters[i] = services.GetRequiredService(parameterType);
42 | }
43 |
44 | return (T)Activator.CreateInstance(typeof(T), parameters)!;
45 | };
46 | }
47 | }
--------------------------------------------------------------------------------
/experiments/ApiBase/ApiBase.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/experiments/ApiBase/Data/BookData.cs:
--------------------------------------------------------------------------------
1 | using ApiBase.Models;
2 |
3 | namespace ApiBase.Data;
4 |
5 | public class BookData
6 | {
7 | private readonly Dictionary _books = new()
8 | {
9 | [1] = new Book { Id = 1, Title = "Mort", Author = "Terry Pratchett", Year = 1987 },
10 | [42] = new Book { Id = 42, Title = "The Hitchhiker's Guide to the Galaxy", Author = "Douglas Adams", Year = 1979 },
11 | };
12 |
13 | private readonly object _mutex = new();
14 |
15 | public Book? Get(int id)
16 | {
17 | return _books.TryGetValue(id, out var book) ? book : null;
18 | }
19 |
20 | public Book Add(Book book)
21 | {
22 | lock (_mutex)
23 | {
24 | var id = _books.Keys.Max() + 1;
25 | book = new Book
26 | {
27 | Id = id,
28 | Title = book.Title,
29 | Author = book.Author,
30 | Year = book.Year
31 | };
32 | _books.Add(id, book);
33 | }
34 |
35 | return book;
36 | }
37 | }
--------------------------------------------------------------------------------
/experiments/ApiBase/Models/Book.cs:
--------------------------------------------------------------------------------
1 | namespace ApiBase.Models;
2 |
3 | public partial class Book
4 | {
5 | public int Id { get; init; }
6 | public string Title { get; set; }
7 | public string Author { get; set; }
8 | public int Year { get; set; }
9 | }
--------------------------------------------------------------------------------
/experiments/ApiBase/Program.cs:
--------------------------------------------------------------------------------
1 | using ApiBase.Api;
2 | using ApiBase.Data;
3 |
4 | var builder = WebApplication.CreateBuilder(args);
5 |
6 | builder.Services.AddSingleton();
7 |
8 | var app = builder.Build();
9 |
10 | app.MapGet("/", () => "Hello World!");
11 |
12 | app.MapApi();
13 |
14 | app.Run();
15 |
--------------------------------------------------------------------------------
/experiments/ApiBase/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "ApiBase": {
4 | "commandName": "Project",
5 | "dotnetRunMessages": true,
6 | "launchBrowser": false,
7 | "applicationUrl": "https://localhost:5001;http://localhost:5000",
8 | "environmentVariables": {
9 | "ASPNETCORE_ENVIRONMENT": "Development"
10 | }
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/experiments/ApiBase/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft.AspNetCore": "Warning"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/experiments/ApiBase/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft.AspNetCore": "Warning"
6 | }
7 | },
8 | "AllowedHosts": "*"
9 | }
10 |
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "rollForward": "latestFeature",
4 | "version": "6.0.400"
5 | }
6 | }
--------------------------------------------------------------------------------
/src/Analyzer/Analyzer.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | enable
6 | enable
7 | latest
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/Analyzer/AttributeHelper.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Analyzer;
4 |
5 | internal static class AttributeHelper
6 | {
7 | public static bool HasHttpMethodAttribute(this IMethodSymbol methodSymbol)
8 | {
9 | foreach (var attribute in methodSymbol.GetAttributes().AsSpan())
10 | {
11 | if (IsHttpMethodAttribute(attribute.AttributeClass)) return true;
12 | }
13 |
14 | return false;
15 | }
16 |
17 | private static bool IsHttpMethodAttribute(INamedTypeSymbol? namedTypeSymbol)
18 | {
19 | return namedTypeSymbol is not null
20 | && HttpMethodAttributes.Contains(namedTypeSymbol.Name)
21 | && namedTypeSymbol.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc");
22 | }
23 |
24 | private static readonly HashSet HttpMethodAttributes = new()
25 | {
26 | "HttpGetAttribute", "HttpPostAttribute", "HttpPutAttribute", "HttpPatchAttribute",
27 | "HttpDeleteAttribute", "HttpOptionsAttribute", "HttpHeadAttribute"
28 | };
29 | }
--------------------------------------------------------------------------------
/src/Analyzer/ControllerAnalyzer.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 | using Microsoft.CodeAnalysis.Diagnostics;
4 |
5 | namespace Analyzer;
6 |
7 | [DiagnosticAnalyzer(LanguageNames.CSharp)]
8 | public class ControllerAnalyzer : DiagnosticAnalyzer
9 | {
10 | public const string DiagnosticId = "DotLabs.OpenApi";
11 |
12 | public override void Initialize(AnalysisContext context)
13 | {
14 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
15 | context.EnableConcurrentExecution();
16 |
17 | context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
18 | context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method);
19 | }
20 |
21 | private static void AnalyzeNamedType(SymbolAnalysisContext context)
22 | {
23 | }
24 |
25 | private static void AnalyzeMethod(SymbolAnalysisContext context)
26 | {
27 | var symbol = (IMethodSymbol)context.Symbol;
28 |
29 | if (!symbol.ContainingType.Inherits("Microsoft.AspNetCore.Mvc.ControllerBase")) return;
30 |
31 | if (!symbol.HasHttpMethodAttribute()) return;
32 |
33 | }
34 |
35 | public override ImmutableArray SupportedDiagnostics { get; }
36 | }
37 |
38 | internal static class AttributeHelper
39 | {
40 | public static bool HasHttpMethodAttribute(this IMethodSymbol methodSymbol)
41 | {
42 | foreach (var attribute in methodSymbol.GetAttributes().AsSpan())
43 | {
44 | if (IsHttpMethodAttribute(attribute.AttributeClass)) return true;
45 | }
46 |
47 | return false;
48 | }
49 |
50 | private static bool IsHttpMethodAttribute(INamedTypeSymbol? namedTypeSymbol)
51 | {
52 | return namedTypeSymbol is not null
53 | && HttpMethodAttributes.Contains(namedTypeSymbol.Name)
54 | && namedTypeSymbol.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc");
55 | }
56 |
57 | private static readonly HashSet HttpMethodAttributes = new()
58 | {
59 | "HttpGetAttribute", "HttpPostAttribute", "HttpPutAttribute", "HttpPatchAttribute",
60 | "HttpDeleteAttribute", "HttpOptionsAttribute", "HttpHeadAttribute"
61 | };
62 | }
63 |
64 | internal static class TypeHelper
65 | {
66 | public static bool Inherits(this INamedTypeSymbol namedTypeSymbol, string fullTypeName) => Inherits(namedTypeSymbol, fullTypeName.AsSpan());
67 |
68 | public static bool Inherits(this INamedTypeSymbol namedTypeSymbol, ReadOnlySpan fullTypeName)
69 | {
70 | var typeName = TextHelpers.GetLastToken(ref fullTypeName, '.');
71 |
72 | for (var baseType = namedTypeSymbol.BaseType; baseType is not null; baseType = baseType.BaseType)
73 | {
74 | if (baseType.Name.AsSpan() == typeName && baseType.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc"))
75 | {
76 | }
77 | }
78 |
79 | return false;
80 | }
81 | }
82 |
83 | internal static class NamespaceHelper
84 | {
85 | public static bool Is(this INamespaceSymbol namespaceSymbol, string name) => Is(namespaceSymbol, name.AsSpan());
86 |
87 | public static bool Is(this INamespaceSymbol namespaceSymbol, ReadOnlySpan name)
88 | {
89 | while (name.Length > 0)
90 | {
91 | var section = TextHelpers.GetLastToken(ref name, '.');
92 | if (section != namespaceSymbol.Name.AsSpan())
93 | {
94 | return false;
95 | }
96 |
97 | namespaceSymbol = namespaceSymbol.ContainingNamespace;
98 | }
99 |
100 | return namespaceSymbol.IsGlobalNamespace;
101 | }
102 | }
103 |
104 | public static class TextHelpers
105 | {
106 | public static ReadOnlySpan GetLastToken(ref ReadOnlySpan text, char delimiter)
107 | {
108 | int index = text.LastIndexOf(delimiter);
109 | ReadOnlySpan result;
110 | if (index < 0)
111 | {
112 | result = text.Slice(0);
113 | text = ReadOnlySpan.Empty;
114 | }
115 | else
116 | {
117 | result = text.Slice(index + 1);
118 | text = text.Slice(0, index);
119 | }
120 |
121 | return result;
122 | }
123 | }
--------------------------------------------------------------------------------
/src/Analyzer/Facts/IsActionMethodExtension.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Analyzer.Facts;
4 |
5 | public static class IsActionMethodExtension
6 | {
7 | public static bool IsAction(this IMethodSymbol methodSymbol)
8 | {
9 | if (methodSymbol.MethodKind != MethodKind.Ordinary) return false;
10 | if (methodSymbol.DeclaredAccessibility != Accessibility.Public) return false;
11 | if (!methodSymbol.ContainingType.IsController()) return false;
12 | if (methodSymbol.GetAttributes().Any(IsNonActionAttribute)) return false;
13 |
14 | return true;
15 | }
16 |
17 | private static bool IsNonActionAttribute(AttributeData a) =>
18 | a.AttributeClass.Name == "NonActionAttribute"
19 | && a.AttributeClass.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc");
20 | }
--------------------------------------------------------------------------------
/src/Analyzer/Facts/IsController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Analyzer.Facts;
4 |
5 | public static class IsControllerExtension
6 | {
7 | public static bool IsController(this INamedTypeSymbol symbol)
8 | {
9 | return InheritsControllerBase(symbol)
10 | || HasApiControllerAttribute(symbol);
11 | }
12 |
13 | private static bool InheritsControllerBase(INamedTypeSymbol symbol) =>
14 | symbol.Inherits("Microsoft.AspNetCore.Mvc.ControllerBase");
15 |
16 | private static bool HasApiControllerAttribute(INamedTypeSymbol symbol) =>
17 | symbol.GetAttributes()
18 | .Any(IsApiControllerAttribute);
19 |
20 | private static bool IsApiControllerAttribute(AttributeData a) =>
21 | a.AttributeClass.Name == "ApiController"
22 | && a.AttributeClass.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc");
23 | }
--------------------------------------------------------------------------------
/src/Analyzer/NamespaceHelper.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Analyzer;
4 |
5 | internal static class NamespaceHelper
6 | {
7 | public static bool Is(this INamespaceSymbol namespaceSymbol, string name) => Is(namespaceSymbol, name.AsSpan());
8 |
9 | public static bool Is(this INamespaceSymbol namespaceSymbol, ReadOnlySpan name)
10 | {
11 | while (name.Length > 0)
12 | {
13 | var section = TextHelpers.GetLastToken(ref name, '.');
14 | if (section != namespaceSymbol.Name.AsSpan())
15 | {
16 | return false;
17 | }
18 |
19 | namespaceSymbol = namespaceSymbol.ContainingNamespace;
20 | }
21 |
22 | return namespaceSymbol.IsGlobalNamespace;
23 | }
24 | }
--------------------------------------------------------------------------------
/src/Analyzer/TextHelpers.cs:
--------------------------------------------------------------------------------
1 | namespace Analyzer;
2 |
3 | public static class TextHelpers
4 | {
5 | public static ReadOnlySpan GetLastToken(ref ReadOnlySpan text, char delimiter)
6 | {
7 | int index = text.LastIndexOf(delimiter);
8 | ReadOnlySpan result;
9 | if (index < 0)
10 | {
11 | result = text.Slice(0);
12 | text = ReadOnlySpan.Empty;
13 | }
14 | else
15 | {
16 | result = text.Slice(index + 1);
17 | text = text.Slice(0, index);
18 | }
19 |
20 | return result;
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Analyzer/TypeHelper.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 | using Microsoft.CodeAnalysis.CSharp.Syntax;
4 |
5 | namespace Analyzer;
6 |
7 | internal static class TypeHelper
8 | {
9 | public static bool Inherits(this INamedTypeSymbol namedTypeSymbol, string fullTypeName) => Inherits(namedTypeSymbol, fullTypeName.AsSpan());
10 |
11 | public static bool Inherits(this INamedTypeSymbol namedTypeSymbol, ReadOnlySpan fullTypeName)
12 | {
13 | var typeName = TextHelpers.GetLastToken(ref fullTypeName, '.');
14 |
15 | for (var baseType = namedTypeSymbol.BaseType; baseType is not null; baseType = baseType.BaseType)
16 | {
17 | if (baseType.Name.AsSpan() == typeName && baseType.ContainingNamespace.Is("Microsoft.AspNetCore.Mvc"))
18 | {
19 | }
20 | }
21 |
22 | return false;
23 | }
24 |
25 | public static bool TryGetNamedType(this SemanticModel model, ExpressionSyntax syntax, out INamedTypeSymbol? namedTypeSymbol)
26 | {
27 | if (model.GetTypeInfo(syntax).Type is INamedTypeSymbol symbol)
28 | {
29 | namedTypeSymbol = symbol;
30 | return true;
31 | }
32 |
33 | namedTypeSymbol = null;
34 | return false;
35 | }
36 | }
37 |
38 | internal static class MethodHelpers
39 | {
40 | public static ImmutableHashSet GetReturnedTypes(this IMethodSymbol methodSymbol, Compilation compilation, CancellationToken cancellation)
41 | {
42 | var builder = ImmutableHashSet.CreateBuilder(SymbolEqualityComparer.Default);
43 |
44 | foreach (var syntaxReference in methodSymbol.DeclaringSyntaxReferences)
45 | {
46 | var semanticModel = compilation.GetSemanticModel(syntaxReference.SyntaxTree);
47 |
48 | var node = (MethodDeclarationSyntax)syntaxReference.GetSyntax(cancellation);
49 |
50 | if (node.Body is not null)
51 | {
52 | var returnStatements = node.DescendantNodes()
53 | .OfType();
54 |
55 | foreach (var returnStatement in returnStatements)
56 | {
57 | if (semanticModel.TryGetNamedType(returnStatement.Expression, out var namedTypeSymbol))
58 | {
59 | builder.Add(namedTypeSymbol);
60 | }
61 | }
62 | }
63 | else if (node.ExpressionBody is not null)
64 | {
65 | if (semanticModel.TryGetNamedType(node.ExpressionBody.Expression, out var namedTypeSymbol))
66 | {
67 | builder.Add(namedTypeSymbol);
68 | }
69 | }
70 | }
71 |
72 | return builder.ToImmutable();
73 | }
74 | }
--------------------------------------------------------------------------------
/src/Build/Build.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net6.0
6 | enable
7 | enable
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Build/Builder.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 | using Microsoft.OpenApi.Readers;
3 | using Microsoft.OpenApi.Services;
4 |
5 | namespace Build;
6 |
7 | public sealed class Builder : IDisposable
8 | {
9 | private readonly Stream _source;
10 | private readonly string _filePath;
11 | private readonly string _directory;
12 |
13 | public Builder(Stream source, string filePath)
14 | {
15 | _source = source;
16 | _filePath = filePath;
17 | _directory = Path.GetDirectoryName(filePath)!;
18 | }
19 |
20 | public async Task Build()
21 | {
22 | // var settings = new OpenApiReaderSettings
23 | // {
24 | // LoadExternalRefs = true,
25 | // BaseUrl = new Uri(_filePath),
26 | // };
27 |
28 | var result = await new OpenApiStreamReader().ReadAsync(_source);
29 |
30 | result.OpenApiDiagnostic.Write();
31 |
32 | if (result.OpenApiDiagnostic.Errors is { Count: > 0 } errors)
33 | {
34 | return null;
35 | }
36 |
37 | var referenceVisitor = new ReferenceVisitor(result.OpenApiDocument, _directory);
38 | var walker = new OpenApiWalker(referenceVisitor);
39 |
40 | do
41 | {
42 | referenceVisitor.AnyChanges = false;
43 | walker.Walk(result.OpenApiDocument);
44 | } while (referenceVisitor.AnyChanges);
45 |
46 | return result.OpenApiDocument;
47 | }
48 |
49 | public void Dispose()
50 | {
51 | _source.Dispose();
52 | }
53 | }
--------------------------------------------------------------------------------
/src/Build/OpenApiDiagnosticWrite.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 | using Microsoft.OpenApi.Readers;
3 |
4 | namespace Build;
5 |
6 | internal static class OpenApiDiagnosticWrite
7 | {
8 | public static void Write(this OpenApiDiagnostic diagnostic)
9 | {
10 | if (diagnostic.Warnings is { Count: > 0 } warnings)
11 | {
12 | WriteWarnings(warnings);
13 | }
14 |
15 | if (diagnostic.Errors is { Count: > 0 } errors)
16 | {
17 | WriteErrors(errors);
18 | }
19 | }
20 |
21 | private static void WriteErrors(IList errors)
22 | {
23 | foreach (var error in errors)
24 | {
25 | Console.WriteLine($"ERROR: [{error.Pointer}] {error.Message}");
26 | }
27 | }
28 |
29 | private static void WriteWarnings(IList warnings)
30 | {
31 | foreach (var warning in warnings)
32 | {
33 | Console.WriteLine($"WARNING: [{warning.Pointer}] {warning.Message}");
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/src/Build/Program.cs:
--------------------------------------------------------------------------------
1 | // See https://aka.ms/new-console-template for more information
2 | Console.WriteLine("Hello, World!");
3 |
--------------------------------------------------------------------------------
/src/Build/ReferenceVisitor.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Interfaces;
2 | using Microsoft.OpenApi.Models;
3 | using Microsoft.OpenApi.Services;
4 | using Path = System.IO.Path;
5 |
6 | namespace Build;
7 |
8 | public class ReferenceVisitor : OpenApiVisitorBase
9 | {
10 | private readonly OpenApiDocument _document;
11 | private readonly string _baseDirectory;
12 | private readonly SchemaLoader _schemaLoader;
13 |
14 | public bool AnyChanges { get; set; }
15 | public Dictionary PathToIdLookup { get; } = new(StringComparer.OrdinalIgnoreCase);
16 |
17 | public ReferenceVisitor(OpenApiDocument document, string baseDirectory)
18 | {
19 | _schemaLoader = new SchemaLoader();
20 | _document = document;
21 | _baseDirectory = baseDirectory;
22 | }
23 |
24 | public override void Visit(IOpenApiReferenceable referenceable)
25 | {
26 | if (referenceable is not OpenApiSchema) return;
27 | if (!referenceable.UnresolvedReference) return;
28 | if (!referenceable.Reference.IsExternal) return;
29 | if (referenceable.Reference.ExternalResource is not { Length: > 0 } externalResource) return;
30 |
31 | var path = Path.GetFullPath(externalResource, _baseDirectory);
32 |
33 | if (!PathToIdLookup.TryGetValue(path, out var id))
34 | {
35 | var schema = _schemaLoader.LoadSchema(path, out var diagnostic);
36 | if (schema is null) return;
37 |
38 | AnyChanges = true;
39 |
40 | id = GetComponentId(path);
41 |
42 | PathToIdLookup[path] = id;
43 |
44 | _document.Components ??= new OpenApiComponents();
45 | _document.Components.Schemas[id] = schema;
46 | }
47 |
48 | referenceable.Reference = new OpenApiReference
49 | {
50 | Id = id,
51 | Type = ReferenceType.Schema,
52 | HostDocument = _document,
53 | };
54 | referenceable.UnresolvedReference = false;
55 | }
56 |
57 | private static string GetComponentId(string path)
58 | {
59 | var fileName = Path.GetFileNameWithoutExtension(path.AsSpan()).TrimStart('.');
60 | int dot = fileName.IndexOf('.');
61 | if (dot > -1)
62 | {
63 | fileName = fileName[..dot];
64 | }
65 |
66 | return new string(fileName);
67 | }
68 | }
--------------------------------------------------------------------------------
/src/Build/SchemaLoader.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using Microsoft.OpenApi;
3 | using Microsoft.OpenApi.Models;
4 | using Microsoft.OpenApi.Readers;
5 |
6 | namespace Build;
7 |
8 | public class SchemaLoader
9 | {
10 | private static readonly HashSet IgnoreErrors = new HashSet
11 | {
12 | "$schema is not a valid property at #/",
13 | "$id is not a valid property at #/",
14 | };
15 | public OpenApiSchema? LoadSchema(string path, out OpenApiDiagnostic diagnostic)
16 | {
17 | var text = File.ReadAllText(path);
18 | var reader = new OpenApiStringReader();
19 | var schema = reader.ReadFragment(text, OpenApiSpecVersion.OpenApi3_0, out diagnostic);
20 | var ignored = diagnostic.Errors.Where(e => IgnoreErrors.Contains(e.Message)).ToArray();
21 | if (ignored.Length > 0)
22 | {
23 | foreach (var error in ignored)
24 | {
25 | diagnostic.Errors.Remove(error);
26 | }
27 | }
28 | diagnostic.Write();
29 | return schema;
30 | }
31 | }
--------------------------------------------------------------------------------
/src/Bundle/Bundle.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net6.0
6 | enable
7 | enable
8 | RendleLabs.OpenApi.Bundle
9 | RendleLabs.OpenApi.Bundle
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/Bundle/BundleException.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Readers;
2 |
3 | namespace RendleLabs.OpenApi.Bundle;
4 |
5 | public class BundleException : Exception
6 | {
7 | public BundleException(string message) : base(message)
8 | {
9 | }
10 |
11 | public BundleException(string message, Exception inner) : base(message, inner)
12 | {
13 | }
14 |
15 | public BundleException(string message, OpenApiDiagnostic diagnostic) : base(message)
16 | {
17 | Diagnostic = diagnostic;
18 | }
19 |
20 | public BundleException(string message, OpenApiDiagnostic diagnostic, Exception inner) : base(message, inner)
21 | {
22 | Diagnostic = diagnostic;
23 | }
24 |
25 | public OpenApiDiagnostic? Diagnostic { get; }
26 | }
--------------------------------------------------------------------------------
/src/Bundle/Bundler.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 | using Microsoft.OpenApi.Readers;
3 | using Microsoft.OpenApi.Services;
4 |
5 | namespace RendleLabs.OpenApi.Bundle;
6 |
7 | public sealed class Bundler : IDisposable
8 | {
9 | private readonly Stream _source;
10 | private readonly string _directory;
11 |
12 | public Bundler(Stream source, string filePath)
13 | {
14 | _source = source;
15 | _directory = Path.GetDirectoryName(filePath)!;
16 | }
17 |
18 | public async Task Build()
19 | {
20 | var result = await new OpenApiStreamReader().ReadAsync(_source);
21 |
22 | result.OpenApiDiagnostic.Write();
23 |
24 | if (result.OpenApiDiagnostic.Errors is { Count: > 0 } errors)
25 | {
26 | return null;
27 | }
28 |
29 | var document = result.OpenApiDocument;
30 |
31 | var references = new ReferenceInfoCollection();
32 |
33 | var referenceVisitor = new ReferenceVisitor(_directory, references);
34 | var walker = new OpenApiWalker(referenceVisitor);
35 | walker.Walk(document);
36 | if (references.Count == 0) return document;
37 |
38 | return document;
39 | }
40 |
41 | public void Dispose()
42 | {
43 | _source.Dispose();
44 | }
45 | }
--------------------------------------------------------------------------------
/src/Bundle/FragmentFinder.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi;
2 | using Microsoft.OpenApi.Interfaces;
3 | using Microsoft.OpenApi.Readers;
4 | using SharpYaml.Serialization;
5 |
6 | namespace RendleLabs.OpenApi.Bundle;
7 |
8 | internal static class FragmentFinder
9 | {
10 | public static T Find(string source, string path) where T : IOpenApiElement
11 | {
12 | int hash = path.IndexOf('#');
13 | if (hash > 0) path = path.Substring(hash).Trim('#', '/');
14 | var parts = path.Split('/');
15 |
16 | var yaml = ParseYaml(source);
17 |
18 | YamlMappingNode? node = null;
19 |
20 | foreach (var document in yaml.Documents)
21 | {
22 | node = (YamlMappingNode)document.RootNode;
23 | foreach (var part in parts)
24 | {
25 | if (!node.TryGetMappingNode(part, out node))
26 | {
27 | break;
28 | }
29 | }
30 |
31 | if (node is not null) break;
32 | }
33 |
34 | var fragment = node?.ToText();
35 |
36 | var reader = new OpenApiStringReader();
37 | var element = reader.ReadFragment(fragment, OpenApiSpecVersion.OpenApi3_0, out var diagnostic);
38 |
39 | if (diagnostic.Errors is { Count: > 0 })
40 | {
41 | if (diagnostic.Errors.Any(e => !e.Message.Contains("is not a valid property")))
42 | {
43 | throw new BundleException("Error reading fragment", diagnostic);
44 | }
45 | }
46 |
47 | return element;
48 | }
49 |
50 | private static YamlStream ParseYaml(string source)
51 | {
52 | var yaml = new YamlStream();
53 | using var stringReader = new StringReader(source);
54 | yaml.Load(stringReader);
55 |
56 | return yaml;
57 | }
58 | }
--------------------------------------------------------------------------------
/src/Bundle/OpenApiDiagnosticWrite.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 | using Microsoft.OpenApi.Readers;
3 |
4 | namespace RendleLabs.OpenApi.Bundle;
5 |
6 | internal static class OpenApiDiagnosticWrite
7 | {
8 | public static void Write(this OpenApiDiagnostic diagnostic)
9 | {
10 | if (diagnostic.Warnings is { Count: > 0 } warnings)
11 | {
12 | WriteWarnings(warnings);
13 | }
14 |
15 | if (diagnostic.Errors is { Count: > 0 } errors)
16 | {
17 | WriteErrors(errors);
18 | }
19 | }
20 |
21 | private static void WriteErrors(IList errors)
22 | {
23 | foreach (var error in errors)
24 | {
25 | Console.WriteLine($"ERROR: [{error.Pointer}] {error.Message}");
26 | }
27 | }
28 |
29 | private static void WriteWarnings(IList warnings)
30 | {
31 | foreach (var warning in warnings)
32 | {
33 | Console.WriteLine($"WARNING: [{warning.Pointer}] {warning.Message}");
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/src/Bundle/Program.cs:
--------------------------------------------------------------------------------
1 | // See https://aka.ms/new-console-template for more information
2 | Console.WriteLine("Hello, World!");
3 |
--------------------------------------------------------------------------------
/src/Bundle/ReferenceInfo.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Interfaces;
2 | using Microsoft.OpenApi.Models;
3 |
4 | namespace RendleLabs.OpenApi.Bundle;
5 |
6 | public abstract class ReferenceInfo
7 | {
8 | protected static readonly Dictionary ReferenceTypes = new()
9 | {
10 | [typeof(OpenApiCallback)] = ReferenceType.Callback,
11 | [typeof(OpenApiExample)] = ReferenceType.Example,
12 | [typeof(OpenApiHeader)] = ReferenceType.Header,
13 | [typeof(OpenApiLink)] = ReferenceType.Link,
14 | [typeof(OpenApiParameter)] = ReferenceType.Parameter,
15 | [typeof(OpenApiRequestBody)] = ReferenceType.RequestBody,
16 | [typeof(OpenApiResponse)] = ReferenceType.Response,
17 | [typeof(OpenApiSchema)] = ReferenceType.Schema,
18 | [typeof(OpenApiSecurityScheme)] = ReferenceType.SecurityScheme,
19 | [typeof(OpenApiTag)] = ReferenceType.Tag,
20 | };
21 |
22 | protected ReferenceInfo(string path)
23 | {
24 | Path = path;
25 | ReadOnlySpan id;
26 | if (ReferencePath.IsHttp(path))
27 | {
28 | var uri = new Uri(path);
29 | id = System.IO.Path.GetFileNameWithoutExtension(uri.AbsolutePath);
30 | }
31 | else
32 | {
33 | id = System.IO.Path.GetFileName(path);
34 | }
35 |
36 | id = id.TrimStart('.');
37 |
38 | var dot = id.IndexOf('.');
39 | if (dot > 0)
40 | {
41 | id = id[..dot];
42 | }
43 |
44 | Id = new string(id);
45 | }
46 |
47 | public string Path { get; }
48 | public string Id { get; set; }
49 |
50 | public abstract ReferenceType Type { get; }
51 | }
52 |
53 | public class ReferenceInfo : ReferenceInfo where T : IOpenApiReferenceable
54 | {
55 | public List References { get; }
56 |
57 | public T? ResolvedReference { get; set; }
58 |
59 | public ReferenceInfo(string path) : base(path)
60 | {
61 | References = new List();
62 | }
63 |
64 | public override ReferenceType Type => ReferenceTypes[typeof(T)];
65 | }
--------------------------------------------------------------------------------
/src/Bundle/ReferenceInfoCollection.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using Microsoft.OpenApi.Interfaces;
3 |
4 | namespace RendleLabs.OpenApi.Bundle;
5 |
6 | public class ReferenceInfoCollection : KeyedCollection
7 | {
8 | protected override string GetKeyForItem(ReferenceInfo item) => item.Path;
9 |
10 | public ReferenceInfo GetOrAdd(string path) where T : IOpenApiReferenceable
11 | {
12 | if (TryGetValue(path, out var info))
13 | {
14 | return (ReferenceInfo)info;
15 | }
16 |
17 | var referenceInfo = new ReferenceInfo(path);
18 | Add(referenceInfo);
19 | return referenceInfo;
20 | }
21 | }
--------------------------------------------------------------------------------
/src/Bundle/ReferenceLoader.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi;
2 | using Microsoft.OpenApi.Interfaces;
3 | using Microsoft.OpenApi.Readers;
4 |
5 | namespace RendleLabs.OpenApi.Bundle;
6 |
7 | public class ReferenceLoader : IDisposable
8 | {
9 | private readonly HttpClient _client = new();
10 | private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase);
11 |
12 | public async Task LoadAsync(ReferenceInfo referenceInfo) where T : IOpenApiReferenceable
13 | {
14 | string text;
15 | if (ReferencePath.IsHttp(referenceInfo.Path))
16 | {
17 | text = await LoadHttpAsync(referenceInfo.Path);
18 | }
19 | else
20 | {
21 | text = await LoadFileAsync(referenceInfo.Path);
22 | }
23 |
24 |
25 | if (referenceInfo.Path.Contains('#'))
26 | {
27 | return FragmentFinder.Find(text, referenceInfo.Path);
28 | }
29 |
30 | var reader = new OpenApiStringReader();
31 | var fragment = reader.ReadFragment(text, OpenApiSpecVersion.OpenApi3_0, out var diagnostic);
32 |
33 | if (diagnostic.Errors is { Count: > 0 })
34 | {
35 | if (diagnostic.Errors.Any(e => !e.Message.Contains("is not a valid property")))
36 | {
37 | throw new BundleException($"Error parsing {typeof(T).Name}", diagnostic);
38 | }
39 | }
40 |
41 | return fragment;
42 | }
43 |
44 | private async Task LoadFileAsync(string path)
45 | {
46 | path = RemoveFragment(path);
47 |
48 | if (_cache.TryGetValue(path, out var text)) return text;
49 |
50 | try
51 | {
52 | text = await File.ReadAllTextAsync(path);
53 | _cache[path] = text;
54 | return text;
55 | }
56 | catch (Exception ex)
57 | {
58 | throw new BundleException("Could not load file '{path}'", ex);
59 | }
60 | }
61 |
62 | private async Task LoadHttpAsync(string uri)
63 | {
64 | uri = RemoveFragment(uri);
65 |
66 | if (_cache.TryGetValue(uri, out var text)) return text;
67 |
68 | try
69 | {
70 | text = await _client.GetStringAsync(uri);
71 | _cache[uri] = text;
72 | return text;
73 | }
74 | catch (HttpRequestException ex)
75 | {
76 | int status = (int)ex.StatusCode.GetValueOrDefault(0);
77 | throw new BundleException($"GET {uri} returned status {status}", ex);
78 | }
79 | }
80 |
81 | private static string RemoveFragment(string path)
82 | {
83 | int hash = path.IndexOf('#');
84 | if (hash > 0) path = path[..hash];
85 | return path;
86 | }
87 |
88 | public void Dispose()
89 | {
90 | _client.Dispose();
91 | }
92 | }
--------------------------------------------------------------------------------
/src/Bundle/ReferencePath.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 |
3 | namespace RendleLabs.OpenApi.Bundle;
4 |
5 | internal static class ReferencePath
6 | {
7 | private static readonly Regex IsHttpRegex = new Regex(@"^https?:\/\/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
8 |
9 | public static bool IsHttp(string path) => IsHttpRegex.IsMatch(path);
10 | }
--------------------------------------------------------------------------------
/src/Bundle/ReferenceVisitor.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 | using Microsoft.OpenApi.Interfaces;
3 | using Microsoft.OpenApi.Models;
4 | using Microsoft.OpenApi.Services;
5 | using Path = System.IO.Path;
6 |
7 | namespace RendleLabs.OpenApi.Bundle;
8 |
9 | public class ReferenceVisitor : OpenApiVisitorBase
10 | {
11 |
12 | private readonly string _basePath;
13 | private readonly ReferenceInfoCollection _references;
14 |
15 | public bool AnyChanges { get; set; }
16 | public Dictionary PathToIdLookup { get; } = new(StringComparer.OrdinalIgnoreCase);
17 |
18 | public ReferenceVisitor(string basePath, ReferenceInfoCollection references)
19 | {
20 | _basePath = basePath;
21 | _references = references;
22 | }
23 |
24 | private void VisitReference(T element) where T : IOpenApiReferenceable
25 | {
26 | if (!element.UnresolvedReference) return;
27 | if (!element.Reference.IsExternal) return;
28 | if (element.Reference.ExternalResource is not { Length: > 0 } externalResource) return;
29 |
30 | if (ReferencePath.IsHttp(_basePath))
31 | {
32 | if (ReferencePath.IsHttp(externalResource))
33 | {
34 | element.Reference.ExternalResource = externalResource;
35 | }
36 | else
37 | {
38 | var baseUri = new Uri(_basePath);
39 | element.Reference.ExternalResource = new Uri(baseUri, externalResource).ToString();
40 | }
41 | }
42 | else
43 | {
44 | element.Reference.ExternalResource = Path.GetFullPath(externalResource, _basePath);
45 | }
46 |
47 | var info = _references.GetOrAdd(element.Reference.ExternalResource);
48 | info.References.Add(element);
49 | }
50 |
51 | public override void Visit(IOpenApiReferenceable referenceable)
52 | {
53 | switch (referenceable)
54 | {
55 | case OpenApiCallback callback:
56 | VisitReference(callback);
57 | break;
58 | case OpenApiExample example:
59 | VisitReference(example);
60 | break;
61 | case OpenApiHeader header:
62 | VisitReference(header);
63 | break;
64 | case OpenApiLink link:
65 | VisitReference(link);
66 | break;
67 | case OpenApiParameter parameter:
68 | VisitReference(parameter);
69 | break;
70 | case OpenApiPathItem pathItem:
71 | VisitReference(pathItem);
72 | break;
73 | case OpenApiRequestBody requestBody:
74 | VisitReference(requestBody);
75 | break;
76 | case OpenApiResponse response:
77 | VisitReference(response);
78 | break;
79 | case OpenApiSchema schema:
80 | VisitReference(schema);
81 | break;
82 | case OpenApiSecurityScheme securityScheme:
83 | VisitReference(securityScheme);
84 | break;
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/src/Bundle/ReferenceWalker.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Interfaces;
2 | using Microsoft.OpenApi.Models;
3 | using Microsoft.OpenApi.Services;
4 |
5 | namespace RendleLabs.OpenApi.Bundle;
6 |
7 | public sealed class ReferenceWalker
8 | {
9 | public ReferenceWalker()
10 | {
11 | }
12 |
13 | public void Walk(OpenApiDocument document, string directory, ReferenceInfoCollection references)
14 | {
15 | var referenceVisitor = new ReferenceVisitor(directory, references);
16 | var walker = new OpenApiWalker(referenceVisitor);
17 | walker.Walk(document);
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Bundle/SchemaLoader.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi;
2 | using Microsoft.OpenApi.Models;
3 | using Microsoft.OpenApi.Readers;
4 |
5 | namespace RendleLabs.OpenApi.Bundle;
6 |
7 | public class SchemaLoader
8 | {
9 | private static readonly HashSet IgnoreErrors = new()
10 | {
11 | "$schema is not a valid property at #/",
12 | "$id is not a valid property at #/",
13 | };
14 |
15 | public OpenApiSchema? LoadSchema(string path, out OpenApiDiagnostic diagnostic)
16 | {
17 | if (path.StartsWith("http://") || path.StartsWith("https://"))
18 | {
19 |
20 | }
21 | var text = File.ReadAllText(path);
22 | var reader = new OpenApiStringReader();
23 | var schema = reader.ReadFragment(text, OpenApiSpecVersion.OpenApi3_0, out diagnostic);
24 | var ignored = diagnostic.Errors.Where(e => IgnoreErrors.Contains(e.Message)).ToArray();
25 | if (ignored.Length > 0)
26 | {
27 | foreach (var error in ignored)
28 | {
29 | diagnostic.Errors.Remove(error);
30 | }
31 | }
32 | diagnostic.Write();
33 | return schema;
34 | }
35 | }
--------------------------------------------------------------------------------
/src/Bundle/YamlMappingNodeExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Text;
3 | using SharpYaml.Serialization;
4 |
5 | namespace RendleLabs.OpenApi.Bundle;
6 |
7 | internal static class YamlMappingNodeExtensions
8 | {
9 | public static bool TryGetMappingNode(this YamlMappingNode parent, string name, [NotNullWhen(true)] out YamlMappingNode? node)
10 | {
11 | foreach (var (key, value) in parent)
12 | {
13 | if (key is YamlScalarNode scalarNode && scalarNode.Value == name)
14 | {
15 | node = value as YamlMappingNode;
16 | return node is not null;
17 | }
18 | }
19 |
20 | node = null;
21 | return false;
22 | }
23 |
24 | public static string ToText(this YamlMappingNode node)
25 | {
26 | var document = new YamlDocument(node);
27 | var builder = new StringBuilder();
28 | using var writer = new StringWriter(builder);
29 | var yaml = new YamlStream(document);
30 | yaml.Save(writer, true);
31 | return builder.ToString();
32 | }
33 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ApiFirstGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | public class ApiFirstGenerator
6 | {
7 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/CSharpHelpers.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | internal static class CSharpHelpers
6 | {
7 | private static readonly Regex NonAlphaNumeric = new("[^a-zA-Z0-9]", RegexOptions.Compiled);
8 |
9 | public static string ClassName(string openApiName) => PascalCase(openApiName);
10 | public static string PropertyName(string openApiName) => PascalCase(openApiName);
11 |
12 | private static string PascalCase(string openApiName)
13 | {
14 | var name = NonAlphaNumeric.Replace(openApiName, string.Empty);
15 | if (name is not { Length: > 0 })
16 | {
17 | throw new InvalidOperationException($"Cannot get C# name from '{openApiName}'");
18 | }
19 |
20 | if (char.IsUpper(name[0])) return name;
21 | if (name.Length == 1) return name.ToUpperInvariant();
22 | return char.ToUpperInvariant(name[0]) + name[1..];
23 | }
24 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ModelDefinition.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | public class ModelDefinition
6 | {
7 | private readonly Dictionary _properties = new();
8 | public ModelDefinition(string openApiName)
9 | {
10 | OpenApiName = openApiName;
11 | CSharpName = CSharpHelpers.ClassName(openApiName);
12 | }
13 |
14 | public string OpenApiName { get; }
15 | public string CSharpName { get; }
16 |
17 | public void AddProperty(string name, OpenApiSchema schema)
18 | {
19 | var property = new ModelProperty(name, schema);
20 | _properties[property.CSharpName] = property;
21 | }
22 |
23 | public ICollection Properties => _properties.Values;
24 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ModelFinder.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | public static class ModelFinder
6 | {
7 | public static IEnumerable FindModels(OpenApiDocument document)
8 | {
9 | return FindRequestModels(document).Concat(FindResponseModels(document));
10 | }
11 |
12 | private static IEnumerable FindRequestModels(OpenApiDocument document)
13 | {
14 | return document.Paths.Values
15 | .Where(p => p.Operations.Values is { Count: > 0 })
16 | .SelectMany(p => p.Operations.Values)
17 | .Where(o => o.RequestBody?.Content is { Count: > 0 })
18 | .SelectMany(o => o.RequestBody.Content.Values)
19 | .Where(m => m.Schema is not null)
20 | .Select(m => FixUp(m.Schema))
21 | .WhereNotNull();
22 | }
23 |
24 | private static IEnumerable FindResponseModels(OpenApiDocument document)
25 | {
26 | return document.Paths.Values
27 | .Where(p => p.Operations.Values is { Count: > 0 })
28 | .SelectMany(p => p.Operations.Values)
29 | .Where(o => o.Responses is { Count: > 0 })
30 | .SelectMany(o => o.Responses.Values)
31 | .Where(r => r.Content is { Count: > 0 })
32 | .SelectMany(r => r.Content.Values)
33 | .Where(m => m.Schema is not null)
34 | .Select(m => FixUp(m.Schema))
35 | .WhereNotNull();
36 | }
37 |
38 | private static OpenApiSchema? FixUp(OpenApiSchema schema)
39 | {
40 | while (schema?.Type == "array")
41 | {
42 | schema = schema.Items;
43 | }
44 |
45 | if (schema is null) return null;
46 |
47 | if (schema.Title is not { Length: > 0 })
48 | {
49 | schema.Title = schema.Reference?.Id;
50 | }
51 |
52 | return schema;
53 | }
54 | }
55 |
56 | internal static class NotNullExtension
57 | {
58 | public static IEnumerable WhereNotNull(this IEnumerable source) where T : class
59 | {
60 | foreach (var item in source)
61 | {
62 | if (item is not null) yield return item;
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ModelGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.CodeDom.Compiler;
2 | using Microsoft.OpenApi.Models;
3 |
4 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
5 |
6 | public class ModelGenerator
7 | {
8 | private readonly List _openApiSchemata = new();
9 |
10 | public void AddSchema(OpenApiSchema openApiSchema)
11 | {
12 | _openApiSchemata.Add(openApiSchema);
13 | }
14 |
15 | public async Task GenerateAsync(TextWriter writer)
16 | {
17 | var indentedTextWriter = writer as IndentedTextWriter ?? new IndentedTextWriter(writer);
18 |
19 | var definitions = CreateModelDefinitions();
20 | foreach (var definition in definitions.OrderBy(d => d.CSharpName))
21 | {
22 | await Generate(indentedTextWriter, definition);
23 | }
24 |
25 | await indentedTextWriter.FlushAsync();
26 | }
27 |
28 | private static async Task Generate(IndentedTextWriter writer, ModelDefinition definition)
29 | {
30 | await writer.WriteLineAsync($"public partial class {definition.CSharpName}");
31 |
32 | using (writer.OpenBrace())
33 | {
34 | foreach (var property in definition.Properties)
35 | {
36 | await writer.WriteLineAsync($"[global::System.Text.Json.Serialization.JsonPropertyName(\"{property.OpenApiName}\")]");
37 | await writer.WriteLineAsync($"public {property.CSharpType}? {property.CSharpName} {{ get; set; }}");
38 | }
39 | }
40 |
41 | await writer.WriteLineNoTabsAsync();
42 | }
43 |
44 | private ModelDefinition[] CreateModelDefinitions()
45 | {
46 | var definitions = new Dictionary();
47 |
48 | foreach (var openApiSchema in _openApiSchemata)
49 | {
50 | if (!definitions.TryGetValue(openApiSchema.Title, out var definition))
51 | {
52 | definitions[openApiSchema.Title] = definition = new ModelDefinition(openApiSchema.Title);
53 | }
54 |
55 | foreach (var (name, schema) in openApiSchema.Properties)
56 | {
57 | definition.AddProperty(name, schema);
58 | }
59 | }
60 |
61 | return definitions.Values.ToArray();
62 | }
63 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ModelProperty.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | public class ModelProperty
6 | {
7 | public ModelProperty(string openApiName, OpenApiSchema schema)
8 | {
9 | OpenApiName = openApiName;
10 | OpenApiType = schema.Type;
11 | CSharpName = CSharpHelpers.PropertyName(openApiName);
12 | CSharpType = SchemaHelpers.SchemaTypeToDotNetType(schema);
13 | }
14 |
15 | public string OpenApiName { get; }
16 | public string OpenApiType { get; }
17 | public string CSharpName { get; }
18 | public string CSharpType { get; }
19 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ParameterHelpers.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 | using Microsoft.OpenApi.Models;
3 |
4 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
5 |
6 | internal static class ParameterHelpers
7 | {
8 | private static readonly Regex NonAlphaNumeric = new("[^a-zA-Z0-9]", RegexOptions.Compiled);
9 |
10 | public static string CSharpName(this OpenApiParameter parameter)
11 | {
12 | var name = NonAlphaNumeric.Replace(parameter.Name, string.Empty);
13 | if (name is not { Length: > 0 })
14 | {
15 | throw new InvalidOperationException($"Cannot get C# name from '{parameter.Name}'");
16 | }
17 |
18 | if (name.Length == 1) return name.ToLowerInvariant();
19 | if (char.IsLower(name[0])) return name;
20 | return char.ToLowerInvariant(name[0]) + name[1..];
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/PathItemHelpers.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | internal static class PathItemHelpers
6 | {
7 | public static IEnumerable GetPathParameters(this OpenApiPathItem pathItem) =>
8 | GetParametersIn(pathItem, ParameterLocation.Path);
9 |
10 | public static IEnumerable GetQueryParameters(this OpenApiPathItem pathItem) =>
11 | GetParametersIn(pathItem, ParameterLocation.Query);
12 |
13 | public static IEnumerable GetQueryParameters(this OpenApiOperation operation) =>
14 | operation.Parameters.Where(p => p.In == ParameterLocation.Query);
15 |
16 | public static IEnumerable GetHeaderParameters(this OpenApiPathItem pathItem) =>
17 | GetParametersIn(pathItem, ParameterLocation.Header);
18 |
19 | public static IEnumerable GetCookieParameters(this OpenApiPathItem pathItem) =>
20 | GetParametersIn(pathItem, ParameterLocation.Cookie);
21 |
22 | private static IEnumerable GetParametersIn(OpenApiPathItem pathItem, ParameterLocation location) =>
23 | pathItem.Parameters.Where(p => p.In == location);
24 |
25 | public static IEnumerable> GetOperationsWithTag(this OpenApiPathItem pathItem, string tag) =>
26 | pathItem.Operations
27 | .Where(o => o.Value.Tags.Any(t => t.Name.Equals(tag)));
28 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/ResultHelper.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | internal static class ResultHelper
6 | {
7 | public static ResultType[] GetStatusCodes(OpenApiDocument document, string tag)
8 | {
9 | return EnumerateStatusCodes(document, tag)
10 | .Distinct()
11 | .OrderBy(s => s.StatusCode)
12 | .ThenBy(s => s.Type)
13 | .ToArray();
14 | }
15 |
16 | private static IEnumerable EnumerateStatusCodes(OpenApiDocument document, string tag)
17 | {
18 | foreach (var (_, path) in document.Paths)
19 | {
20 | foreach (var (_, operation) in path.Operations)
21 | {
22 | if (!operation.Tags.Any(t => t.Name.Equals(tag, StringComparison.OrdinalIgnoreCase))) continue;
23 |
24 | foreach (var (codeStr, response) in operation.Responses)
25 | {
26 | if (int.TryParse(codeStr, out var code))
27 | {
28 | yield return GetResultType(code, response);
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
35 | private static ResultType GetResultType(int statusCode, OpenApiResponse response)
36 | {
37 | foreach (var (_, mediaType) in response.Content)
38 | {
39 | if (mediaType.Schema.Title is { Length: > 0 } typeName)
40 | {
41 | return new ResultType(statusCode, typeName);
42 | }
43 |
44 | if (mediaType.Schema.Type == "array")
45 | {
46 | if (mediaType.Schema.Items.Title is { Length: > 0 } arrayTypeName)
47 | {
48 | return new ResultType(statusCode, arrayTypeName, true);
49 | }
50 | }
51 | }
52 |
53 | return new ResultType(statusCode);
54 | }
55 | }
56 |
57 | internal record ResultType(int StatusCode, string? Type = null, bool IsArray = false);
58 |
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/SchemaHelpers.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.OpenApi.Models;
2 |
3 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
4 |
5 | internal static class SchemaHelpers
6 | {
7 | public static string SchemaTypeToDotNetType(OpenApiSchema schema)
8 | {
9 | var type = schema.Type switch
10 | {
11 | "boolean" => "bool",
12 | "number" => "double",
13 | "string" => StringSchemaType(schema),
14 | "integer" => SchemaTypeToInteger(schema),
15 | _ => "object",
16 | };
17 | if (schema.Nullable) type += '?';
18 | return type;
19 | }
20 |
21 | private static string StringSchemaType(OpenApiSchema schema)
22 | {
23 | return schema.Format switch
24 | {
25 | "date-time" => "DateTime",
26 | "time" => "TimeOnly",
27 | "date" => "DateOnly",
28 | "duration" => "TimeSpan",
29 | "uuid" => "Guid",
30 | "uri" => "Uri",
31 | _ => "string",
32 | };
33 | }
34 |
35 | private static string SchemaTypeToInteger(OpenApiSchema schema)
36 | {
37 | return schema.Maximum > int.MaxValue ? "long" : "int";
38 | }
39 | }
--------------------------------------------------------------------------------
/src/Generator/ApiFirst/StatusCodeHelper.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Generator.ApiFirst;
2 |
3 | internal static class StatusCodeHelper
4 | {
5 | public static string GetMethod(int status, string? typeName, bool isArray)
6 | {
7 | string parameterName;
8 | if (typeName is { Length: > 0 })
9 | {
10 | parameterName = typeName.ToCamelCase();
11 | if (isArray)
12 | {
13 | parameterName += "s";
14 | typeName = $"IList<{typeName}>";
15 | }
16 | }
17 | else
18 | {
19 | typeName = "object";
20 | parameterName = "obj";
21 | }
22 | return status switch
23 | {
24 | 200 => $"Ok({typeName}? {parameterName} = null) => Results.Ok({parameterName})",
25 | 201 => $"Created(Uri uri, {typeName}? {parameterName} = null) => Results.Created(uri, {parameterName})",
26 | 202 => $"Accepted(Uri? uri = null, {typeName}? {parameterName} = null) => Results.Accepted(uri.ToString(), {parameterName})",
27 | 204 => "NoContent() => Results.NoContent()",
28 | 301 => "MovedPermanently(Uri uri) => Results.Redirect(uri.ToString(), true, false)",
29 | 302 => "Found(Uri uri) => Results.Redirect(uri.ToString(), false, false)",
30 | 307 => "TemporaryRedirect(Uri uri) => Results.Redirect(uri.ToString(), false, true)",
31 | 308 => "PermanentRedirect(Uri uri) => Results.Redirect(uri.ToString(), true, true)",
32 | 400 => "BadRequest(object? errors = null) => Results.BadRequest(errors)",
33 | 401 => "Unauthorized() => Results.Unauthorized()",
34 | 402 => "PaymentRequired() => Results.StatusCode(402)",
35 | 403 => "Forbidden() => Results.Forbid()",
36 | 404 => "NotFound() => Results.NotFound()",
37 | 405 => "MethodNotAllowed() => Results.StatusCode(405)",
38 | 406 => "NotAcceptable() => Results.StatusCode(406)",
39 | 409 => "Conflict(object? errors = null) => Results.Conflict(errors)",
40 | 410 => "Gone() => Results.StatusCode(410)",
41 | 411 => "LengthRequired() => Results.StatusCode(411)",
42 | 412 => "PreconditionFailed() => Results.StatusCode(412)",
43 | 415 => "UnsupportedMediaType() => Results.StatusCode(415)",
44 | 416 => "RangeNotSatisfiable() => Results.StatusCode(416)",
45 | 417 => "ExpectationFailed() => Results.StatusCode(417)",
46 | 418 => "ImATeapot() => Results.StatusCode(418)",
47 | 425 => "TooEarly() => Results.StatusCode(425)",
48 | 428 => "PreconditionRequired() => Results.StatusCode(428)",
49 | 429 => "TooManyRequests() => Results.StatusCode(429)",
50 | 451 => "UnavailableForLegalReasons() => Results.StatusCode(451)",
51 | _ => $"Status{status}() => Results.StatusCode({status})",
52 | };
53 | }
54 | }
--------------------------------------------------------------------------------
/src/Generator/Controllers/ActionMethodParameter.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Generator.Controllers;
2 |
3 | internal record ActionMethodParameter(string? From, string Type, string Name)
4 | {
5 | public override string ToString()
6 | {
7 | return From is not null ? $"[From{From}] {Type} {Name}" : $"{Type} {Name}";
8 | }
9 | }
--------------------------------------------------------------------------------
/src/Generator/Controllers/BaseActionMethodGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.CodeDom.Compiler;
2 | using System.Diagnostics.CodeAnalysis;
3 | using Microsoft.OpenApi.Models;
4 |
5 | namespace RendleLabs.OpenApi.Generator.Controllers;
6 |
7 | public class BaseActionMethodGenerator
8 | {
9 | private readonly string _path;
10 | private readonly OperationType _operationType;
11 | private readonly OpenApiOperation _operation;
12 |
13 | public BaseActionMethodGenerator(string path, OperationType operationType, OpenApiOperation operation)
14 | {
15 | _path = path;
16 | _operationType = operationType;
17 | _operation = operation;
18 | }
19 |
20 | public void Generate(IndentedTextWriter writer)
21 | {
22 | writer.WriteLine($"[Http{_operationType}(\"{_path}\", Name = \"{_operation.OperationId}\"]");
23 |
24 | var model = GetModelName();
25 | var returnType = model is null ? "IActionResult" : $"ActionResult<{model}>";
26 | var parameters = GetParameters().ToArray();
27 |
28 | writer.Write($"public Task<{returnType}> {_operation.OperationId}(");
29 | if (parameters.Length > 0)
30 | {
31 | writer.Write(string.Join(", ", parameters.Select(p => p.ToString())));
32 | writer.Write(", ");
33 | }
34 |
35 | if (TryGetBodyName(out var bodyName, out var array))
36 | {
37 | writer.Write($"[FromBody] Models.{bodyName}");
38 | if (array)
39 | {
40 | writer.Write("[]");
41 | }
42 |
43 | writer.Write(' ');
44 | writer.Write(char.ToLowerInvariant(bodyName[0]));
45 | writer.Write(bodyName.AsSpan().Slice(1));
46 | writer.Write(", ");
47 | }
48 |
49 | writer.WriteLine("CancellationToken cancellationToken) => Task.FromResult(StatusCode(501));");
50 | }
51 |
52 | private bool TryGetBodyName([NotNullWhen(true)] out string? name, out bool array)
53 | {
54 | array = false;
55 | if (_operation.RequestBody?.Content is { Count: > 0 })
56 | {
57 | if (_operation.RequestBody.Content.TryGetValue("application/json", out var content))
58 | {
59 | if (content.Schema?.Type == "array")
60 | {
61 | array = true;
62 | if (content.Schema.Items?.Title is { Length: > 0 } title)
63 | {
64 | name = title;
65 | }
66 | else
67 | {
68 | name = $"{_operation.OperationId}RequestContent";
69 | }
70 | }
71 | else
72 | {
73 | if (content.Schema?.Title is { Length: > 0 } title)
74 | {
75 | name = title;
76 | }
77 | else
78 | {
79 | name = $"{_operation.OperationId}RequestContent";
80 | }
81 | }
82 |
83 | return true;
84 | }
85 | }
86 |
87 | name = null;
88 | return false;
89 | }
90 |
91 | private string? GetModelName()
92 | {
93 | var anon = false;
94 | foreach (var (statusStr, response) in _operation.Responses)
95 | {
96 | if (response.Content is null) continue;
97 |
98 | if (statusStr is "200" or "201" or "202")
99 | {
100 | if (response.Content.TryGetValue("application/json", out var content))
101 | {
102 | anon = true;
103 | if (content.Schema.Type == "array" && content.Schema.Items.Title is { Length: > 0 })
104 | {
105 | return $"List";
106 | }
107 |
108 | if (content.Schema.Title is { Length: > 0 })
109 | {
110 | return $"Models.{content.Schema.Title}";
111 | }
112 | }
113 | }
114 | }
115 |
116 | return anon ? $"Models.{_operation.OperationId}Model" : null;
117 | }
118 |
119 | private IEnumerable GetParameters()
120 | {
121 | foreach (var apiParameter in _operation.Parameters)
122 | {
123 | var from = apiParameter.In switch
124 | {
125 | ParameterLocation.Query => "Query",
126 | ParameterLocation.Header => "Header",
127 | ParameterLocation.Path => "Route",
128 | _ => null
129 | };
130 | var type = apiParameter.Schema.Type.ToLower() switch
131 | {
132 | "string" => "string",
133 | "number" => "double",
134 | "integer" => apiParameter.Schema.Maximum.HasValue && apiParameter.Schema.Maximum.Value > int.MaxValue ? "long" : "int",
135 | "boolean" => "bool",
136 | _ => "object"
137 | };
138 |
139 | if (!apiParameter.Required)
140 | {
141 | type += "?";
142 | }
143 |
144 | yield return new ActionMethodParameter(from, type, apiParameter.Name);
145 | }
146 | }
147 | }
--------------------------------------------------------------------------------
/src/Generator/Generate.cs:
--------------------------------------------------------------------------------
1 | using System.CommandLine;
2 | using RendleLabs.OpenApi.Generator.MinimalApi;
3 |
4 | namespace RendleLabs.OpenApi.Generator;
5 |
6 | public class Generate
7 | {
8 | public static Command CreateCommand()
9 | {
10 | var command = new Command("generate", "Generate an API project from an OpenAPI spec.");
11 | command.AddAlias("gen");
12 | command.AddAlias("g");
13 | command.Add(MinimalApiGenerator.CreateCommand());
14 | return command;
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Generator/Generator.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net6.0
6 | enable
7 | enable
8 | RendleLabs.OpenApi.Generator
9 | RendleLabs.OpenApi.Generator
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/Generator/Internal/IndentedTextWriterExtensions.cs:
--------------------------------------------------------------------------------
1 | // ReSharper disable once CheckNamespace
2 | namespace System.CodeDom.Compiler;
3 |
4 | public static class IndentedTextWriterExtensions
5 | {
6 | public static IDisposable OpenIndent(this IndentedTextWriter writer) => new Indent(writer);
7 | public static IDisposable OpenBrace(this IndentedTextWriter writer) => new Brace(writer);
8 |
9 | public static Task WriteLineNoTabsAsync(this IndentedTextWriter writer) => writer.WriteLineNoTabsAsync(string.Empty);
10 |
11 | private class Indent : IDisposable
12 | {
13 | private readonly IndentedTextWriter _writer;
14 |
15 | public Indent(IndentedTextWriter writer)
16 | {
17 | _writer = writer;
18 | _writer.Indent++;
19 | }
20 |
21 | public void Dispose() => _writer.Indent--;
22 | }
23 |
24 | private class Brace : IDisposable
25 | {
26 | private readonly IndentedTextWriter _writer;
27 |
28 | public Brace(IndentedTextWriter writer)
29 | {
30 | _writer = writer;
31 | writer.WriteLine("{");
32 | writer.Indent++;
33 | }
34 |
35 | public void Dispose()
36 | {
37 | _writer.Indent--;
38 | _writer.WriteLine("}");
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/src/Generator/MinimalApi/MinimalApiGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.CodeDom.Compiler;
2 | using System.CommandLine;
3 | using System.Diagnostics;
4 | using System.Reflection;
5 | using System.Text;
6 | using Microsoft.OpenApi.Models;
7 | using Microsoft.OpenApi.Readers;
8 |
9 | namespace RendleLabs.OpenApi.Generator.MinimalApi;
10 |
11 | public class MinimalApiGenerator
12 | {
13 | private readonly string _openApiFile;
14 | private readonly string _output;
15 |
16 | private MinimalApiGenerator(string openApiFile, string? output)
17 | {
18 | _openApiFile = openApiFile;
19 | if (output is not { Length: > 0 })
20 | {
21 | output = Path.GetFileNameWithoutExtension(openApiFile);
22 | }
23 | _output = Path.GetFullPath(output);
24 | }
25 |
26 | private async Task InvokeAsync()
27 | {
28 | var (document, diagnostic) = await LoadDocumentAsync();
29 |
30 | if (diagnostic.Errors is { Count: > 0 } errors)
31 | {
32 | foreach (var error in errors)
33 | {
34 | Console.WriteLine($"{error.Message} at {error.Pointer}");
35 | }
36 |
37 | return 1;
38 | }
39 |
40 | Directory.CreateDirectory(_output);
41 | await CreateProjectFile();
42 | await CreateProgramFile(document);
43 |
44 | return 0;
45 | }
46 |
47 | private async Task<(OpenApiDocument, OpenApiDiagnostic)> LoadDocumentAsync()
48 | {
49 | await using var stream = File.OpenRead(_openApiFile);
50 | var result = await new OpenApiStreamReader().ReadAsync(stream);
51 | return (result.OpenApiDocument, result.OpenApiDiagnostic);
52 | }
53 |
54 | private async Task CreateProjectFile()
55 | {
56 | var name = $"{Path.GetFileNameWithoutExtension(_output)}.csproj";
57 | var path = Path.Combine(_output, name);
58 | await using var writeStream = File.Create(path);
59 | await using var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("RendleLabs.OpenApi.Generator.MinimalApi.Project.xml");
60 | Debug.Assert(resourceStream != null);
61 | await resourceStream.CopyToAsync(writeStream);
62 | }
63 |
64 | private async Task CreateProgramFile(OpenApiDocument openApiDocument)
65 | {
66 | var path = Path.Combine("_output", "Program.cs");
67 | await File.WriteAllTextAsync(path, GenerateProgram(openApiDocument));
68 | }
69 |
70 | private string GenerateProgram(OpenApiDocument openApiDocument)
71 | {
72 | var builder = new StringBuilder();
73 | using var stringWriter = new StringWriter(builder);
74 | using var writer = new IndentedTextWriter(stringWriter, " ");
75 |
76 | writer.WriteLine("var builder = WebApplication.CreateBuilder(args);");
77 | writer.WriteLine();
78 | writer.WriteLine("var app = builder.Build();");
79 | writer.WriteLine();
80 |
81 | foreach (var (pathKey, pathItem) in openApiDocument.Paths)
82 | {
83 | foreach (var (operationType, operation) in pathItem.Operations)
84 | {
85 | writer.WriteLine($"app.Map{operationType}(\"{pathKey}\", async (context) =>");
86 | using (writer.OpenIndent())
87 | {
88 | using (writer.OpenBrace())
89 | {
90 |
91 | }
92 | }
93 | writer.Indent++;
94 | writer.WriteLine("{");
95 | }
96 | }
97 |
98 | writer.WriteLine("app.Run();");
99 | writer.Flush();
100 | return builder.ToString();
101 | }
102 |
103 | public static Command CreateCommand()
104 | {
105 | var command = new Command("minimal");
106 |
107 | var openApiFileArgument = new Argument("OpenApiFile");
108 | var outputOption = new Option(new[] { "--output", "-o" }, () => string.Empty, "Output directory");
109 |
110 | command.SetHandler(async (context) =>
111 | {
112 | var openApiFile = context.ParseResult.GetValueForArgument(openApiFileArgument);
113 | var output = context.ParseResult.GetValueForOption(outputOption);
114 | context.ExitCode = await new MinimalApiGenerator(openApiFile, output).InvokeAsync();
115 | });
116 |
117 | command.SetHandler(
118 | (openApiFile, output) => new MinimalApiGenerator(openApiFile, output).InvokeAsync(),
119 | openApiFileArgument,
120 | outputOption);
121 |
122 | return command;
123 | }
124 | }
--------------------------------------------------------------------------------
/src/Generator/MinimalApi/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/Generator/Program.cs:
--------------------------------------------------------------------------------
1 | using System.CommandLine;
2 |
3 | var rootCommand = new RootCommand();
4 |
5 | var generateCommand = new Command("generate", "Generate an API project");
6 | generateCommand.AddAlias("gen");
7 | generateCommand.AddAlias("g");
8 | var openApiFileNameArgument = new Argument("openApiFileName", "The OpenAPI definition to generate code for.");
9 | generateCommand.AddArgument(openApiFileNameArgument);
10 | var outputOption = new Option(new[]{"--output", "-o"}, "Output directory.");
11 | generateCommand.AddOption(outputOption);
12 |
13 | generateCommand.SetHandler((openApiFileName, output) =>
14 | {
15 | Console.WriteLine(openApiFileName);
16 | Console.WriteLine(output);
17 | }, openApiFileNameArgument, outputOption);
18 |
19 | rootCommand.Add(generateCommand);
20 |
21 | return await rootCommand.InvokeAsync(args);
22 |
--------------------------------------------------------------------------------
/src/Testing.Xunit/Testing.Xunit.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/Testing.Xunit/XunitAssertExtension.cs:
--------------------------------------------------------------------------------
1 | using RendleLabs.OpenApi.Testing;
2 |
3 | namespace Testing.Xunit;
4 |
5 | public static class XunitAssertExtension
6 | {
7 | public static void RunAssertions(this OpenApiTest openApiTest)
8 | {
9 | var testResponse = openApiTest.TestResponse;
10 | var actualResponse = openApiTest.ResponseMessage;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/Testing/HttpClientExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 |
3 | namespace RendleLabs.OpenApi.Testing;
4 |
5 | public static class HttpClientExtensions
6 | {
7 | public static Task ExecuteAsync(this HttpClient client, OpenApiTestRequest testRequest)
8 | {
9 | var url = testRequest.ToUri();
10 | var message = new HttpRequestMessage(testRequest.Method, url);
11 |
12 | if (testRequest.Headers is { Count: > 0 })
13 | {
14 | foreach (var (key, values) in testRequest.Headers)
15 | {
16 | message.Headers.TryAddWithoutValidation(key, values);
17 | }
18 | }
19 |
20 | if (testRequest.Body != null)
21 | {
22 | message.Content = new StringContent(testRequest.Body, Encoding.UTF8, testRequest.ContentType);
23 | }
24 |
25 | return client.SendAsync(message);
26 | }
27 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/JsonDocumentExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Buffers;
2 | using System.Text;
3 | using System.Text.Json;
4 |
5 | namespace RendleLabs.OpenApi.Testing.Internal;
6 |
7 | public static class JsonDocumentExtensions
8 | {
9 | public static string? ToUtf8String(this JsonDocument? jsonDocument)
10 | {
11 | if (jsonDocument is null) return null;
12 | var bufferWriter = new ArrayBufferWriter();
13 | var writer = new Utf8JsonWriter(bufferWriter);
14 | jsonDocument.WriteTo(writer);
15 | writer.Flush();
16 | return Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/LinqExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing.Internal;
2 |
3 | internal static class LinqExtensions
4 | {
5 | public static IEnumerable WhereNotNull(this IEnumerable source) where T : class
6 | {
7 | foreach (var item in source)
8 | {
9 | if (item is not null)
10 | {
11 | yield return item;
12 | }
13 | }
14 | }
15 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/OpenApiModelExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using Microsoft.OpenApi.Models;
3 |
4 | namespace RendleLabs.OpenApi.Testing.Internal;
5 |
6 | public static class OpenApiModelExtensions
7 | {
8 | public static bool TryGetParameter(this OpenApiPathItem pathItem, OperationType operationType, string name, [NotNullWhen(true)] out OpenApiParameter? parameter)
9 | {
10 | if (pathItem.Operations.TryGetValue(operationType, out var operation))
11 | {
12 | parameter = operation.Parameters.FirstOrDefault(p => p.Name == name);
13 | if (parameter is not null) return true;
14 | }
15 |
16 | parameter = pathItem.Parameters.FirstOrDefault(p => p.Name == name);
17 | return parameter is not null;
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/QueryString.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing.Internal;
2 |
3 | internal struct QueryString
4 | {
5 | private List<(string, string)>? _values;
6 |
7 | public void Add(string key, string value)
8 | {
9 | _values ??= new List<(string, string)>();
10 | _values.Add((key, value));
11 | }
12 |
13 | public override string ToString()
14 | {
15 | if (_values is null) return string.Empty;
16 | return "?" + string.Join("&", _values.Select(Pair));
17 | }
18 |
19 | private static string Pair((string, string) pair) => $"{pair.Item1}={Uri.EscapeDataString(pair.Item2)}";
20 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/YamlExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using YamlDotNet.RepresentationModel;
3 |
4 | namespace RendleLabs.OpenApi.Testing.Internal;
5 |
6 | internal static class YamlExtensions
7 | {
8 | private static readonly Dictionary Keys = new();
9 |
10 | public static bool TryGetNode(this YamlMappingNode map, string key, [NotNullWhen(true)] out YamlNode? node)
11 | {
12 | var keyNode = KeyNode(key);
13 | return map.Children.TryGetValue(keyNode, out node);
14 | }
15 |
16 | public static bool TryGetScalar(this YamlMappingNode map, string key, [NotNullWhen(true)] out YamlScalarNode? scalarNode)
17 | {
18 | var keyNode = KeyNode(key);
19 | if (map.Children.TryGetValue(keyNode, out var node) && node is YamlScalarNode x)
20 | {
21 | scalarNode = x;
22 | return true;
23 | }
24 |
25 | scalarNode = default;
26 | return false;
27 | }
28 |
29 | public static bool TryGetSequence(this YamlMappingNode map, string key, [NotNullWhen(true)] out YamlSequenceNode? sequenceNode)
30 | {
31 | var keyNode = KeyNode(key);
32 | if (map.Children.TryGetValue(keyNode, out var node) && node is YamlSequenceNode x)
33 | {
34 | sequenceNode = x;
35 | return true;
36 | }
37 |
38 | sequenceNode = default;
39 | return false;
40 | }
41 |
42 | public static IEnumerable> ChildrenOfType(this YamlMappingNode node)
43 | {
44 | foreach (var (key, value) in node.Children)
45 | {
46 | if (key is TKey tkey && value is TValue tvalue)
47 | {
48 | yield return new KeyValuePair(tkey, tvalue);
49 | }
50 | }
51 | }
52 |
53 | public static bool TryGetString(this YamlMappingNode map, string key, [NotNullWhen(true)] out string? value)
54 | {
55 | if (map.TryGetScalar(key, out var scalarNode))
56 | {
57 | value = scalarNode.Value;
58 | return value is not null;
59 | }
60 |
61 | value = default;
62 | return false;
63 | }
64 |
65 | public static bool TryGetInt(this YamlMappingNode map, string key, [NotNullWhen(true)] out int value)
66 | {
67 | if (map.TryGetScalar(key, out var scalarNode))
68 | {
69 | return int.TryParse(scalarNode.Value, out value);
70 | }
71 |
72 | value = default;
73 | return false;
74 | }
75 |
76 | public static bool TryGetMap(this YamlMappingNode map, string key, [NotNullWhen(true)] out YamlMappingNode? mappingNode)
77 | {
78 | var keyNode = KeyNode(key);
79 | if (map.Children.TryGetValue(keyNode, out var node) && node is YamlMappingNode x)
80 | {
81 | mappingNode = x;
82 | return true;
83 | }
84 |
85 | mappingNode = default;
86 | return false;
87 | }
88 |
89 | private static YamlScalarNode KeyNode(string key)
90 | {
91 | if (!Keys.TryGetValue(key, out var keyNode))
92 | {
93 | Keys[key] = keyNode = new YamlScalarNode(key);
94 | }
95 |
96 | return keyNode;
97 | }
98 | }
--------------------------------------------------------------------------------
/src/Testing/Internal/YamlToJson.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using YamlDotNet.Core;
3 | using YamlDotNet.RepresentationModel;
4 |
5 | namespace RendleLabs.OpenApi.Testing.Internal;
6 |
7 | internal static class YamlToJson
8 | {
9 | public static JsonDocument? ToJson(YamlMappingNode map)
10 | {
11 | using var stream = new MemoryStream();
12 | var writer = new Utf8JsonWriter(stream);
13 |
14 | WriteMap(writer, map);
15 |
16 | writer.Flush();
17 |
18 | stream.Position = 0;
19 | return JsonDocument.Parse(stream);
20 | }
21 |
22 | private static void WriteMap(Utf8JsonWriter writer, YamlMappingNode map)
23 | {
24 | writer.WriteStartObject();
25 |
26 | foreach (var (k, v) in map)
27 | {
28 | if (k is not YamlScalarNode keyNode || keyNode.Value is not { Length: > 0 }) continue;
29 |
30 | writer.WritePropertyName(keyNode.Value);
31 |
32 | switch (v)
33 | {
34 | case YamlScalarNode scalarNodeValue:
35 | WriteScalarValue(writer, scalarNodeValue);
36 | break;
37 | case YamlMappingNode mapNodeValue:
38 | WriteMap(writer, mapNodeValue);
39 | break;
40 | case YamlSequenceNode sequenceNodeValue:
41 | WriteArray(writer, sequenceNodeValue);
42 | break;
43 | }
44 | }
45 |
46 | writer.WriteEndObject();
47 | }
48 |
49 | private static void WriteArray(Utf8JsonWriter writer, YamlSequenceNode sequence)
50 | {
51 | writer.WriteStartArray();
52 | foreach (var item in sequence)
53 | {
54 | switch (item)
55 | {
56 | case YamlScalarNode scalar:
57 | WriteScalarValue(writer, scalar);
58 | break;
59 | case YamlMappingNode map:
60 | WriteMap(writer, map);
61 | break;
62 | case YamlSequenceNode seq:
63 | WriteArray(writer, seq);
64 | break;
65 | }
66 | }
67 | writer.WriteEndArray();
68 | }
69 |
70 | private static void WriteScalarValue(Utf8JsonWriter writer, YamlScalarNode value)
71 | {
72 | if (value.Value is null) writer.WriteNullValue();
73 |
74 | if (value.Style == ScalarStyle.Plain)
75 | {
76 | switch (value.Value)
77 | {
78 | case "true":
79 | writer.WriteBooleanValue(true);
80 | return;
81 | case "false":
82 | writer.WriteBooleanValue(false);
83 | return;
84 | case "null":
85 | writer.WriteNullValue();
86 | return;
87 | }
88 |
89 | if (long.TryParse(value.Value, out var longValue))
90 | {
91 | writer.WriteNumberValue(longValue);
92 | return;
93 | }
94 |
95 | if (double.TryParse(value.Value, out var doubleValue))
96 | {
97 | writer.WriteNumberValue(doubleValue);
98 | return;
99 | }
100 | }
101 |
102 | writer.WriteStringValue(value.Value);
103 | }
104 | }
--------------------------------------------------------------------------------
/src/Testing/JsonAssert.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using FluentAssertions;
3 |
4 | namespace RendleLabs.OpenApi.Testing;
5 |
6 | public static class JsonAssert
7 | {
8 | public static void Equivalent(JsonDocument expected, JsonDocument actual)
9 | {
10 | ElementEquivalent(expected.RootElement, actual.RootElement);
11 | }
12 |
13 | private static void ElementEquivalent(JsonElement expected, JsonElement actual, string jsonPath = "")
14 | {
15 | if (expected.ValueKind != actual.ValueKind)
16 | {
17 | throw new JsonEqualException(expected.ValueKind, actual.ValueKind, $"{jsonPath}(Kind)");
18 | }
19 |
20 | switch (expected.ValueKind)
21 | {
22 | case JsonValueKind.Object:
23 | ObjectEquivalent(expected, actual, jsonPath);
24 | break;
25 | case JsonValueKind.Array:
26 | ArrayEquivalent(expected, actual, jsonPath);
27 | break;
28 | case JsonValueKind.String:
29 | StringEquivalent(expected.GetString(), actual.GetString(), jsonPath);
30 | break;
31 | case JsonValueKind.Number:
32 | actual.ValueKind.Should().Be(JsonValueKind.Number);
33 | NumberEquivalent(expected.GetDecimal(), actual.GetDecimal(), jsonPath);
34 | break;
35 | case JsonValueKind.Undefined:
36 | case JsonValueKind.True:
37 | case JsonValueKind.False:
38 | case JsonValueKind.Null:
39 | break;
40 | default:
41 | throw new ArgumentOutOfRangeException();
42 | }
43 | }
44 |
45 | private static void StringEquivalent(string? expected, string? actual, string jsonPath)
46 | {
47 | actual.Should().Be(expected, jsonPath);
48 | }
49 |
50 | private static void NumberEquivalent(decimal? expected, decimal? actual, string jsonPath)
51 | {
52 | actual.Should().Be(expected, jsonPath);
53 | }
54 |
55 | private static void ObjectEquivalent(JsonElement expected, JsonElement actual, string? jsonPath)
56 | {
57 | foreach (var expectedProperty in expected.EnumerateObject())
58 | {
59 | var actualProperty = actual.GetProperty(expectedProperty.Name);
60 | ElementEquivalent(expectedProperty.Value, actualProperty, $"{jsonPath}['{expectedProperty.Name}']");
61 | }
62 | }
63 |
64 | private static void ArrayEquivalent(JsonElement expected, JsonElement actual, string jsonPath)
65 | {
66 | var expectedArray = expected.EnumerateArray().ToArray();
67 | var actualArray = actual.EnumerateArray().ToArray();
68 |
69 | if (expectedArray.Length != actualArray.Length)
70 | {
71 | actualArray.Length.Should().Be(expectedArray.Length);
72 | }
73 |
74 | for (int i = 0, l = expectedArray.Length; i < l; i++)
75 | {
76 | ElementEquivalent(expectedArray[i], actualArray[i], $"{jsonPath}[{i}]");
77 | }
78 | }
79 | }
--------------------------------------------------------------------------------
/src/Testing/JsonEqualException.cs:
--------------------------------------------------------------------------------
1 | using Xunit.Sdk;
2 |
3 | namespace RendleLabs.OpenApi.Testing;
4 |
5 | public class JsonEqualException : AssertActualExpectedException
6 | {
7 | public JsonEqualException(object? expected, object? actual, string jsonPath)
8 | : base(expected, actual, CreateMessage(jsonPath), null, null)
9 | {
10 | }
11 |
12 | public JsonEqualException(object? expected, object? actual, string jsonPath, Exception? innerException)
13 | : base(expected, actual, CreateMessage(jsonPath), null, null, innerException)
14 | {
15 | }
16 |
17 | private static string CreateMessage(string jsonPath) => $"Failure: JsonEqual {jsonPath}";
18 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTest.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 | using System.Text.Json;
3 | using System.Text.RegularExpressions;
4 | using FluentAssertions;
5 |
6 | namespace RendleLabs.OpenApi.Testing;
7 |
8 | public class OpenApiTest
9 | {
10 | public OpenApiTest(OpenApiTestRequest testRequest, OpenApiTestResponse testResponse)
11 | {
12 | TestRequest = testRequest;
13 | TestResponse = testResponse;
14 | }
15 |
16 | internal OpenApiTestRequest TestRequest { get; }
17 | internal OpenApiTestResponse TestResponse { get; }
18 | internal HttpRequestMessage RequestMessage { get; private set; }
19 | internal HttpResponseMessage ResponseMessage { get; private set; }
20 |
21 | public async Task ExecuteAsync(HttpClient client)
22 | {
23 |
24 | var url = TestRequest.ToUri();
25 | RequestMessage = new HttpRequestMessage(TestRequest.Method, url);
26 |
27 | if (TestRequest.Headers is { Count: > 0 })
28 | {
29 | foreach (var (key, values) in TestRequest.Headers)
30 | {
31 | RequestMessage.Headers.TryAddWithoutValidation(key, values);
32 | }
33 | }
34 |
35 | if (TestRequest.Body != null)
36 | {
37 | RequestMessage.Content = new StringContent(TestRequest.Body, Encoding.UTF8, TestRequest.ContentType);
38 | }
39 |
40 | ResponseMessage = await client.SendAsync(RequestMessage);
41 | }
42 |
43 | public async Task Assert()
44 | {
45 | ((int)ResponseMessage.StatusCode).Should().Be(TestResponse.Status);
46 |
47 | if (TestResponse.Headers is { Count: > 0 } expectedHeaders)
48 | {
49 | foreach (var (expectedKey, expectedValues) in expectedHeaders)
50 | {
51 | ResponseMessage.Headers.Should().ContainKey(expectedKey);
52 | var actualValues = ResponseMessage.Headers.GetValues(expectedKey)!.ToHashSet();
53 | foreach (var expectedValue in expectedValues)
54 | {
55 | if (expectedValue.StartsWith('/') && expectedValue.EndsWith('/'))
56 | {
57 | var regex = new Regex(expectedValue.Trim('/'));
58 | actualValues.Should().Contain(s => regex.IsMatch(s));
59 | }
60 | else
61 | {
62 | actualValues.Should().Contain(expectedValue);
63 | }
64 | }
65 | }
66 | }
67 |
68 | if (TestResponse.ContentType is { Length: > 0 } contentType)
69 | {
70 | contentType.Should().Be(ResponseMessage.Content.Headers.ContentType?.ToString());
71 |
72 | if (TestResponse.Body is { Length: > 0 } expectedBody)
73 | {
74 | if (contentType.StartsWith("text/"))
75 | {
76 | var body = await ResponseMessage.Content.ReadAsStringAsync();
77 | body.Trim().Should().Be(expectedBody.Trim());
78 | }
79 | else if (contentType == "application/json")
80 | {
81 | var expectedJson = JsonDocument.Parse(expectedBody);
82 | var actualJson = await JsonDocument.ParseAsync(await ResponseMessage.Content.ReadAsStreamAsync());
83 | JsonAssert.Equivalent(expectedJson, actualJson);
84 | }
85 | }
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestBody.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing;
2 |
3 | public class OpenApiTestBody
4 | {
5 | public OpenApiTestBody(string contentType, string requestBody)
6 | {
7 | ContentType = contentType;
8 | Content = requestBody;
9 | }
10 |
11 | public string ContentType { get; }
12 | public string Content { get; }
13 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestDocument.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 |
3 | namespace RendleLabs.OpenApi.Testing;
4 |
5 | public class OpenApiTestDocument
6 | {
7 | public OpenApiTestDocument(string? server, List tests)
8 | {
9 | Server = server;
10 | Tests = tests.ToArray();
11 | }
12 |
13 | public string? Server { get; }
14 |
15 | public OpenApiTestElement[] Tests { get; }
16 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestElement.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing;
2 |
3 | public class OpenApiTestElement
4 | {
5 | public HttpMethod Method { get; set; }
6 | public string Uri { get; set; }
7 | public OpenApiTestBody? RequestBody { get; internal init; }
8 | public IReadOnlyDictionary Headers { get; internal init; }
9 | public OpenApiTestExpect Expect { get; internal init; }
10 | public string? OutputName { get; internal init; }
11 | }
12 |
13 | public class OpenApiTestSequence
14 | {
15 |
16 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestExpect.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 |
3 | namespace RendleLabs.OpenApi.Testing;
4 |
5 | public class OpenApiTestExpect
6 | {
7 | public OpenApiTestExpect(int status, OpenApiTestBody? responseBody, ReadOnlyDictionary headers)
8 | {
9 | Status = status;
10 | ResponseBody = responseBody;
11 | Headers = headers;
12 | }
13 |
14 | public int Status { get; }
15 | public OpenApiTestBody? ResponseBody { get; }
16 | public IReadOnlyDictionary Headers { get; }
17 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestPath.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using Microsoft.OpenApi.Models;
3 |
4 | namespace RendleLabs.OpenApi.Testing;
5 |
6 | public class OpenApiTestPath
7 | {
8 | public OpenApiTestPath(string path, Dictionary operations)
9 | {
10 | Path = path;
11 | Operations = new ReadOnlyDictionary(operations);
12 | }
13 |
14 | public string Path { get; }
15 |
16 | public IReadOnlyDictionary Operations { get; }
17 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestRequest.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing;
2 |
3 | public class OpenApiTestRequest
4 | {
5 | public HttpMethod Method { get; init; } = null!;
6 | public string? Server { get; init; }
7 | public string Path { get; init; } = null!;
8 | public string? Body { get; init; }
9 | public string? ContentType { get; init; }
10 | public IReadOnlyDictionary? Headers { get; init; }
11 |
12 | public override string ToString() => $"{Method} {ToUri()}";
13 |
14 | public Uri ToUri() =>
15 | Server is { Length: > 0 }
16 | ? new Uri($"{Server.TrimEnd('/')}/{Path.TrimStart('/')}", UriKind.Absolute)
17 | : new Uri(Path, UriKind.Relative);
18 | }
--------------------------------------------------------------------------------
/src/Testing/OpenApiTestResponse.cs:
--------------------------------------------------------------------------------
1 | namespace RendleLabs.OpenApi.Testing;
2 |
3 | public class OpenApiTestResponse
4 | {
5 | public int Status { get; set; }
6 | public string? Body { get; set; }
7 | public IReadOnlyDictionary? Headers { get; set; }
8 | public string? ContentType { get; set; }
9 | public string? OutputName { get; set; }
10 |
11 | public string? GetOutput(string path)
12 | {
13 | var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).AsSpan();
14 | if (parts.Length < 2) throw new InvalidOperationException();
15 | if (parts[0].Equals("Headers")) return GetOutputHeader(parts.Slice(1));
16 | else throw new InvalidOperationException();
17 | }
18 |
19 | private string? GetOutputHeader(Span slice)
20 | {
21 | if (Headers is null) return null;
22 | if (Headers.TryGetValue(slice[0], out var headers) && headers?.Length > 0) return headers.FirstOrDefault();
23 | return null;
24 | }
25 |
26 | public override string ToString() => Status.ToString();
27 | }
28 |
--------------------------------------------------------------------------------
/src/Testing/OpenApiTheoryData.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 | using System.Text.RegularExpressions;
3 | using Microsoft.OpenApi.Models;
4 | using RendleLabs.OpenApi.Testing.Internal;
5 | using Xunit.Sdk;
6 |
7 | namespace RendleLabs.OpenApi.Testing;
8 |
9 | public class OpenApiTestDataAttribute : DataAttribute
10 | {
11 | public override IEnumerable