Skip to content

Commit 56bd36a

Browse files
authored
Reference multiple OpenAPI specifications (#1621)
* API landing page now contains a listing of all API's for ease of reference * simplify navigation if using tag groups * lint and only reload API on debug * Allow multiple API source to be specified in a single docset * work on typename exposure * Manually inject live reload script, the default was causing issues on larger chucked Results.Content * Fix live reload * fix post merge compilation error
1 parent 521b73c commit 56bd36a

File tree

11 files changed

+261
-153
lines changed

11 files changed

+261
-153
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
<PackageVersion Include="Markdig" Version="0.41.1" />
4343
<PackageVersion Include="NetEscapades.EnumGenerators" Version="1.0.0-beta12" PrivateAssets="all" ExcludeAssets="runtime" />
4444
<PackageVersion Include="Proc" Version="0.9.1" />
45-
<PackageVersion Include="RazorSlices" Version="0.9.0" />
45+
<PackageVersion Include="RazorSlices" Version="0.9.2" />
4646
<PackageVersion Include="Samboy063.Tomlet" Version="6.0.0" />
4747
<PackageVersion Include="Slugify.Core" Version="4.0.1" />
4848
<PackageVersion Include="SoftCircuits.IniFileParser" Version="2.7.0" />

docs/_docset.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ subs:
2020
features:
2121
primary-nav: false
2222

23-
#api: kibana-openapi.json
24-
api: elasticsearch-openapi.json
23+
api:
24+
elasticsearch: elasticsearch-openapi.json
25+
kibana: kibana-openapi.json
2526

2627
toc:
2728
- file: index.md

src/Elastic.ApiExplorer/OpenApiGenerator.cs

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ public class OpenApiGenerator(ILoggerFactory logFactory, BuildContext context, I
4444
private readonly IFileSystem _writeFileSystem = context.WriteFileSystem;
4545
private readonly StaticFileContentHashProvider _contentHashProvider = new(new EmbeddedOrPhysicalFileProvider(context));
4646

47-
public LandingNavigationItem CreateNavigation(OpenApiDocument openApiDocument)
47+
public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument)
4848
{
49-
var url = $"{context.UrlPathPrefix}/api";
49+
var url = $"{context.UrlPathPrefix}/api/" + apiUrlSuffix;
5050
var rootNavigation = new LandingNavigationItem(url);
5151

5252
var ops = openApiDocument.Paths
@@ -62,15 +62,17 @@ public LandingNavigationItem CreateNavigation(OpenApiDocument openApiDocument)
6262
? anyApi.Node.GetValue<string>()
6363
: null;
6464
var tag = op.Value.Tags?.FirstOrDefault()?.Reference.Id;
65-
var classification = openApiDocument.Info.Title == "Elasticsearch Request & Response Specification"
66-
? ClassifyElasticsearchTag(tag ?? "unknown")
67-
: "unknown";
65+
var tagClassification = (extensions?.TryGetValue("x-tag-group", out var g) ?? false) && g is OpenApiAny anyTagGroup
66+
? anyTagGroup.Node.GetValue<string>()
67+
: openApiDocument.Info.Title == "Elasticsearch Request & Response Specification"
68+
? ClassifyElasticsearchTag(tag ?? "unknown")
69+
: "unknown";
6870

6971
var apiString = ns is null
7072
? api ?? op.Value.Summary ?? Guid.NewGuid().ToString("N") : $"{ns}.{api}";
7173
return new
7274
{
73-
Classification = classification,
75+
Classification = tagClassification,
7476
Api = apiString,
7577
Tag = tag,
7678
pair.Path,
@@ -158,25 +160,26 @@ group tagGroup by classificationGroup.Key
158160
var hasClassifications = classifications.Count > 1;
159161
foreach (var classification in classifications)
160162
{
161-
if (hasClassifications)
163+
if (hasClassifications && classification.Name != "common")
162164
{
163165
var classificationNavigationItem = new ClassificationNavigationItem(classification, rootNavigation, rootNavigation);
164166
var tagNavigationItems = new List<IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem>>();
165167

166-
CreateTagNavigationItems(classification, classificationNavigationItem, classificationNavigationItem, tagNavigationItems);
168+
CreateTagNavigationItems(apiUrlSuffix, classification, classificationNavigationItem, classificationNavigationItem, tagNavigationItems);
167169
topLevelNavigationItems.Add(classificationNavigationItem);
168170
// if there is only a single tag item will be added directly to the classificationNavigationItem, otherwise they will be added to the tagNavigationItems
169171
if (classificationNavigationItem.NavigationItems.Count == 0)
170172
classificationNavigationItem.NavigationItems = tagNavigationItems;
171173
}
172174
else
173-
CreateTagNavigationItems(classification, rootNavigation, rootNavigation, topLevelNavigationItems);
175+
CreateTagNavigationItems(apiUrlSuffix, classification, rootNavigation, rootNavigation, topLevelNavigationItems);
174176
}
175177
rootNavigation.NavigationItems = topLevelNavigationItems;
176178
return rootNavigation;
177179
}
178180

179181
private void CreateTagNavigationItems(
182+
string apiUrlSuffix,
180183
ApiClassification classification,
181184
IRootNavigationItem<IApiGroupingModel, INavigationItem> rootNavigation,
182185
IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem> parent,
@@ -190,13 +193,13 @@ List<IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem>> parentNavig
190193
if (hasTags)
191194
{
192195
var tagNavigationItem = new TagNavigationItem(tag, rootNavigation, parent);
193-
CreateEndpointNavigationItems(rootNavigation, tag, tagNavigationItem, endpointNavigationItems);
196+
CreateEndpointNavigationItems(apiUrlSuffix, rootNavigation, tag, tagNavigationItem, endpointNavigationItems);
194197
parentNavigationItems.Add(tagNavigationItem);
195198
tagNavigationItem.NavigationItems = endpointNavigationItems;
196199
}
197200
else
198201
{
199-
CreateEndpointNavigationItems(rootNavigation, tag, parent, endpointNavigationItems);
202+
CreateEndpointNavigationItems(apiUrlSuffix, rootNavigation, tag, parent, endpointNavigationItems);
200203
if (parent is ClassificationNavigationItem classificationNavigationItem)
201204
classificationNavigationItem.NavigationItems = endpointNavigationItems;
202205
else if (parent is LandingNavigationItem landingNavigationItem)
@@ -206,6 +209,7 @@ List<IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem>> parentNavig
206209
}
207210

208211
private void CreateEndpointNavigationItems(
212+
string apiUrlSuffix,
209213
IRootNavigationItem<IApiGroupingModel, INavigationItem> rootNavigation,
210214
ApiTag tag,
211215
IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem> parentNavigationItem,
@@ -220,7 +224,7 @@ List<IEndpointOrOperationNavigationItem> endpointNavigationItems
220224
var operationNavigationItems = new List<OperationNavigationItem>();
221225
foreach (var operation in endpoint.Operations)
222226
{
223-
var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, operation, rootNavigation, endpointNavigationItem)
227+
var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, apiUrlSuffix, operation, rootNavigation, endpointNavigationItem)
224228
{
225229
Hidden = true
226230
};
@@ -232,7 +236,7 @@ List<IEndpointOrOperationNavigationItem> endpointNavigationItems
232236
else
233237
{
234238
var operation = endpoint.Operations.First();
235-
var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, operation, rootNavigation, parentNavigationItem);
239+
var operationNavigationItem = new OperationNavigationItem(context.UrlPathPrefix, apiUrlSuffix, operation, rootNavigation, parentNavigationItem);
236240
endpointNavigationItems.Add(operationNavigationItem);
237241

238242
}
@@ -241,47 +245,53 @@ List<IEndpointOrOperationNavigationItem> endpointNavigationItems
241245

242246
public async Task Generate(Cancel ctx = default)
243247
{
244-
if (context.Configuration.OpenApiSpecification is null)
248+
if (context.Configuration.OpenApiSpecifications is null)
245249
return;
246250

247-
var openApiDocument = await OpenApiReader.Create(context.Configuration.OpenApiSpecification);
248-
if (openApiDocument is null)
249-
return;
251+
foreach (var (prefix, path) in context.Configuration.OpenApiSpecifications)
252+
{
253+
var openApiDocument = await OpenApiReader.Create(path);
254+
if (openApiDocument is null)
255+
return;
250256

251-
var navigation = CreateNavigation(openApiDocument);
252-
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info.Title);
257+
var navigation = CreateNavigation(prefix, openApiDocument);
258+
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info.Title);
253259

254-
var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation);
260+
var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation);
255261

262+
var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider)
263+
{
264+
NavigationHtml = string.Empty,
265+
CurrentNavigation = navigation,
266+
MarkdownRenderer = markdownStringRenderer
267+
};
268+
_ = await Render(prefix, navigation, navigation.Index, renderContext, navigationRenderer, ctx);
269+
await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, ctx);
256270

257-
var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider)
258-
{
259-
NavigationHtml = string.Empty,
260-
CurrentNavigation = navigation,
261-
MarkdownRenderer = markdownStringRenderer
262-
};
263-
_ = await Render(navigation, navigation.Index, renderContext, navigationRenderer, ctx);
264-
await RenderNavigationItems(navigation);
271+
}
272+
}
265273

266-
async Task RenderNavigationItems(INavigationItem currentNavigation)
274+
private async Task RenderNavigationItems(string prefix, ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, INavigationItem currentNavigation, Cancel ctx)
275+
{
276+
if (currentNavigation is INodeNavigationItem<IApiModel, INavigationItem> node)
267277
{
268-
if (currentNavigation is INodeNavigationItem<IApiModel, INavigationItem> node)
269-
{
270-
_ = await Render(node, node.Index, renderContext, navigationRenderer, ctx);
271-
foreach (var child in node.NavigationItems)
272-
await RenderNavigationItems(child);
273-
}
278+
_ = await Render(prefix, node, node.Index, renderContext, navigationRenderer, ctx);
279+
foreach (var child in node.NavigationItems)
280+
await RenderNavigationItems(prefix, renderContext, navigationRenderer, child, ctx);
281+
}
274282

275-
#pragma warning disable IDE0045
276-
else if (currentNavigation is ILeafNavigationItem<IApiModel> leaf)
277-
#pragma warning restore IDE0045
278-
_ = await Render(leaf, leaf.Model, renderContext, navigationRenderer, ctx);
279-
else
280-
throw new Exception($"Unknown navigation item type {currentNavigation.GetType()}");
283+
else
284+
{
285+
_ = currentNavigation is ILeafNavigationItem<IApiModel> leaf
286+
? await Render(prefix, leaf, leaf.Model, renderContext, navigationRenderer, ctx)
287+
: throw new Exception($"Unknown navigation item type {currentNavigation.GetType()}");
281288
}
282289
}
283290

284-
private async Task<IFileInfo> Render<T>(INavigationItem current, T page, ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, Cancel ctx)
291+
#pragma warning disable IDE0060
292+
private async Task<IFileInfo> Render<T>(string prefix, INavigationItem current, T page, ApiRenderContext renderContext,
293+
#pragma warning restore IDE0060
294+
IsolatedBuildNavigationHtmlWriter navigationRenderer, Cancel ctx)
285295
where T : INavigationModel, IPageRenderer<ApiRenderContext>
286296
{
287297
var outputFile = OutputFile(current);

src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@
1212

1313
namespace Elastic.ApiExplorer.Operations;
1414

15+
public interface IApiProperty
16+
{
17+
18+
}
19+
20+
public record ApiObject
21+
{
22+
public required string Name { get; init; }
23+
public IReadOnlyCollection<IApiProperty> Properties { get; init; } = [];
24+
}
25+
26+
27+
1528
public record ApiOperation(OperationType OperationType, OpenApiOperation Operation, string Route, IOpenApiPathItem Path, string ApiName) : IApiModel
1629
{
1730
public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default)
@@ -29,6 +42,7 @@ public class OperationNavigationItem : ILeafNavigationItem<ApiOperation>, IEndpo
2942
{
3043
public OperationNavigationItem(
3144
string? urlPathPrefix,
45+
string apiUrlSuffix,
3246
ApiOperation apiOperation,
3347
IRootNavigationItem<IApiGroupingModel, INavigationItem> root,
3448
IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem> parent
@@ -39,7 +53,7 @@ IApiGroupingNavigationItem<IApiGroupingModel, INavigationItem> parent
3953
NavigationTitle = apiOperation.ApiName;
4054
Parent = parent;
4155
var moniker = apiOperation.Operation.OperationId ?? apiOperation.Route.Replace("}", "").Replace("{", "").Replace('/', '-');
42-
Url = $"{urlPathPrefix}/api/endpoints/{moniker}";
56+
Url = $"{urlPathPrefix?.TrimEnd('/')}/api/{apiUrlSuffix}/{moniker}";
4357
Id = ShortId.Create(Url);
4458
}
4559

src/Elastic.ApiExplorer/Operations/OperationView.cshtml

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,59 @@
11
@using Elastic.ApiExplorer.Landing
22
@using Elastic.ApiExplorer.Operations
33
@using Microsoft.OpenApi.Models
4+
@using Microsoft.OpenApi.Models.Interfaces
45
@inherits RazorSliceHttpResult<Elastic.ApiExplorer.Operations.OperationViewModel>
56
@implements IUsesLayout<Elastic.ApiExplorer._Layout, GlobalLayoutViewModel>
67
@functions {
78
public GlobalLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel();
9+
10+
public string GetTypeName(JsonSchemaType? type)
11+
{
12+
var typeName = "";
13+
if (type is null)
14+
return "unknown and null";
15+
16+
if (type.Value.HasFlag(JsonSchemaType.Boolean))
17+
typeName = "boolean";
18+
else if (type.Value.HasFlag(JsonSchemaType.Integer))
19+
typeName = "integer";
20+
else if (type.Value.HasFlag(JsonSchemaType.String))
21+
typeName = "string";
22+
else if (type.Value.HasFlag(JsonSchemaType.Object))
23+
{
24+
typeName = "object";
25+
}
26+
else if (type.Value.HasFlag(JsonSchemaType.Null))
27+
typeName = "null";
28+
else if (type.Value.HasFlag(JsonSchemaType.Number))
29+
typeName = "number";
30+
else
31+
{
32+
}
33+
34+
if (type.Value.HasFlag(JsonSchemaType.Array))
35+
typeName += " array";
36+
return typeName;
37+
}
38+
39+
public string GetTypeName(IOpenApiSchema propertyValue)
40+
{
41+
var typeName = string.Empty;
42+
if (propertyValue.Type is not null)
43+
{
44+
typeName = GetTypeName(propertyValue.Type);
45+
if (typeName is not "object" and not "array")
46+
return typeName;
47+
}
48+
49+
if (propertyValue.Schema is not null)
50+
return propertyValue.Schema;
51+
52+
if (propertyValue.Enum is { Count: >0 } e)
53+
return "enum";
54+
55+
return $"unknown value {typeName}";
56+
}
857
}
958
@{
1059
var self = Model.CurrentNavigationItem as OperationNavigationItem;
@@ -34,7 +83,7 @@
3483
var method = overload.Model.OperationType.ToString().ToLowerInvariant();
3584
var current = overload.Model.Route == Model.Operation.Route && overload.Model.OperationType == Model.Operation.OperationType ? "current" : "";
3685
<li class="api-url-list-item">
37-
<a href="@overload.Url" class="@current">
86+
<a href="@overload.Url" class="@current" hx-disable="true">
3887
<span class="api-method api-method-@method">@method.ToUpperInvariant()</span>
3988
<span class="api-url">@overload.Model.Route</span>
4089
</a>
@@ -72,15 +121,26 @@
72121
@if (operation.RequestBody is not null)
73122
{
74123
<h3>Request Body</h3>
124+
var content = operation.RequestBody.Content.FirstOrDefault().Value;
75125
if (!string.IsNullOrEmpty(operation.RequestBody.Description))
76126
{
77127
<p>@operation.RequestBody.Description</p>
78128
}
129+
130+
if (content.Schema is not null)
131+
{
79132
<dl>
80-
@foreach (var path in operation.RequestBody.Content)
133+
@foreach (var property in content.Schema.Properties)
81134
{
135+
if (property.Value.Type is null)
136+
{
137+
138+
}
139+
<dt id="@property.Key"><a href="#@property.Key"><code>@property.Key</code> @GetTypeName(property.Value) </a></dt>
140+
<dd>@Model.RenderMarkdown(property.Value.Description)</dd>
82141
}
83142
</dl>
143+
}
84144
}
85145
</section>
86146
<aside>

src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public record ConfigurationFile : ITableOfContentsScope
5151

5252
public IDirectoryInfo ScopeDirectory { get; }
5353

54-
public IFileInfo? OpenApiSpecification { get; }
54+
public IReadOnlyDictionary<string, IFileInfo>? OpenApiSpecifications { get; }
5555

5656
/// This is a documentation set that is not linked to by assembler.
5757
/// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference
@@ -112,11 +112,18 @@ public ConfigurationFile(IDocumentationContext context, VersionsConfiguration ve
112112
// read this later
113113
break;
114114
case "api":
115-
var specification = reader.ReadString(entry.Entry);
116-
if (specification is null)
115+
var configuredApis = reader.ReadDictionary(entry.Entry);
116+
if (configuredApis.Count == 0)
117117
break;
118-
var path = Path.Combine(context.DocumentationSourceDirectory.FullName, specification);
119-
OpenApiSpecification = context.ReadFileSystem.FileInfo.New(path);
118+
119+
var specs = new Dictionary<string, IFileInfo>(StringComparer.OrdinalIgnoreCase);
120+
foreach (var (k, v) in configuredApis)
121+
{
122+
var path = Path.Combine(context.DocumentationSourceDirectory.FullName, v);
123+
var fi = context.ReadFileSystem.FileInfo.New(path);
124+
specs[k] = fi;
125+
}
126+
OpenApiSpecifications = specs;
120127
break;
121128
case "products":
122129
if (entry.Entry.Value is not YamlSequenceNode sequence)
@@ -130,7 +137,7 @@ public ConfigurationFile(IDocumentationContext context, VersionsConfiguration ve
130137
YamlScalarNode? productId = null;
131138
foreach (var child in node.Children)
132139
{
133-
if (child.Key is YamlScalarNode { Value: "id" } && child.Value is YamlScalarNode scalarNode)
140+
if (child is { Key: YamlScalarNode { Value: "id" }, Value: YamlScalarNode scalarNode })
134141
{
135142
productId = scalarNode;
136143
break;

src/tooling/Directory.Build.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<!-- TODO ENABLE to document our code properly <GenerateDocumentationFile>true</GenerateDocumentationFile> -->
1111
<PublishRepositoryUrl>true</PublishRepositoryUrl>
12+
1213
</PropertyGroup>
1314

1415
<ItemGroup Condition="'$(OutputType)' == 'Exe'">
@@ -17,4 +18,4 @@
1718
<Content Include="$(SolutionRoot)\NOTICE.txt" Pack="True" PackagePath="NOTICE.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest"/>
1819
</ItemGroup>
1920

20-
</Project>
21+
</Project>

0 commit comments

Comments
 (0)