Skip to content

Commit 4cdebe6

Browse files
authored
[API Explorer] Basic customized landing pages (#3121)
1 parent 5f38153 commit 4cdebe6

15 files changed

Lines changed: 417 additions & 35 deletions

File tree

docs/_docset.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ cross_links:
88
exclude:
99
- '_*.md'
1010
- '!_search.md'
11+
# API landing templates (see api.*.template): not standalone TOC pages; including them causes "Could not find … in navigation" during serve/build.
12+
- '*-api-overview.md'
1113
subs:
1214
a-global-variable: "This was defined in docset.yml"
1315
serverless-short: Serverless
@@ -48,7 +50,9 @@ suppress:
4850

4951
api:
5052
elasticsearch: elasticsearch-openapi.json
51-
kibana: kibana-openapi.json
53+
kibana:
54+
spec: kibana-openapi.json
55+
template: kibana-api-overview.md
5256
dashboard: dashboard-openapi.json
5357

5458
toc:

docs/configure/content-set/api-explorer.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ This feature is still under development and the functionality described on this
1212

1313
## Configure the API Explorer
1414

15-
Add the `api` key to your `docset.yml` file to enable the API Explorer. The key maps product names to OpenAPI JSON specification files. Paths are relative to the folder that contains `docset.yml`.
15+
Add the `api` key to your `docset.yml` file to enable the API Explorer. The key maps product names to OpenAPI JSON specification files.
16+
Paths are relative to the folder that contains `docset.yml`.
17+
18+
### Basic Configuration
1619

1720
```yaml
1821
api:
@@ -24,6 +27,39 @@ Each product key produces its own section of API documentation. For example, `el
2427

2528
The `api` key is only valid in `docset.yml`. You can't use it in `toc.yml` files.
2629

30+
### Advanced configuration with templates
31+
32+
When you specify a `template` file, `docs-builder` uses your custom Markdown file as the API landing page instead of generating an automatic overview:
33+
34+
```yaml
35+
api:
36+
kibana:
37+
spec: kibana-openapi.json
38+
template: kibana-api-overview.md
39+
```
40+
41+
Template files:
42+
43+
- Must be Markdown files with `.md` extension
44+
- Can use standard Markdown, substitutions
45+
46+
:::{note}
47+
They must be explicitly excluded if they are not used in your table of contents.
48+
Otherwise `docs-builder` treats them like normal pages and navigation can fail at build or serve time.
49+
Add a glob (or explicit paths) under `exclude:` in `docset.yml` that matches your template filenames.
50+
For example, exclude `*-api-overview.md`.
51+
:::
52+
53+
#### Template example
54+
55+
Here's a sample template file (`kibana-api-overview.md`):
56+
57+
```markdown
58+
# Kibana APIs
59+
60+
Welcome to the Kibana API documentation.
61+
```
62+
2763
## Place your spec files
2864

2965
OpenAPI specification files must be in JSON format and located in the same folder as your `docset.yml` (or in a subfolder of it). The path you specify in `api` is resolved relative to the `docset.yml` location.

docs/kibana-api-overview.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Kibana APIs
2+
3+
Welcome to the Kibana API documentation. This page provides an overview of the available Kibana APIs.
4+
5+
## Kibana spaces
6+
7+
Spaces enable you to organize your dashboards and other saved objects into meaningful categories.
8+
You can use the default space or create your own spaces.
9+
10+
To run APIs in non-default spaces, you must add `s/{space_id}/` to the path.
11+
For example:
12+
13+
```bash
14+
curl -X GET "http://${KIBANA_URL}/s/marketing/api/data_views" \
15+
-H "Authorization: ApiKey ${API_KEY}"
16+
```
17+
18+
If you use the Kibana console to send API requests, it automatically adds the appropriate space identifier.
19+
20+
To learn more, check out [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces).

src/Elastic.ApiExplorer/ApiRenderContext.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using Elastic.ApiExplorer.Landing;
56
using Elastic.Documentation;
67
using Elastic.Documentation.Configuration;
78
using Elastic.Documentation.Navigation;
@@ -21,4 +22,7 @@ StaticFileContentHashProvider StaticFileContentHashProvider
2122
public required string NavigationHtml { get; init; }
2223
public required INavigationItem CurrentNavigation { get; init; }
2324
public required IMarkdownStringRenderer MarkdownRenderer { get; init; }
25+
26+
/// <summary>When set, the API root index uses this model instead of <see cref="Landing.ApiLanding"/>.</summary>
27+
public TemplateLanding? TemplateLandingPage { get; init; }
2428
}

src/Elastic.ApiExplorer/ApiViewModel.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ public record ApiLayoutViewModel : GlobalLayoutViewModel
2525

2626
public abstract partial class ApiViewModel(ApiRenderContext context)
2727
{
28-
public string NavigationHtml { get; } = context.NavigationHtml;
29-
public StaticFileContentHashProvider StaticFileContentHashProvider { get; } = context.StaticFileContentHashProvider;
30-
public INavigationItem CurrentNavigationItem { get; } = context.CurrentNavigation;
31-
public IMarkdownStringRenderer MarkdownRenderer { get; } = context.MarkdownRenderer;
32-
public BuildContext BuildContext { get; } = context.BuildContext;
33-
public OpenApiDocument Document { get; } = context.Model;
28+
public string NavigationHtml { get; } = context?.NavigationHtml ?? string.Empty;
29+
public StaticFileContentHashProvider StaticFileContentHashProvider { get; } = context?.StaticFileContentHashProvider ?? throw new ArgumentNullException(nameof(context), "StaticFileContentHashProvider cannot be null");
30+
public INavigationItem CurrentNavigationItem { get; } = context?.CurrentNavigation ?? throw new ArgumentNullException(nameof(context), "CurrentNavigation cannot be null");
31+
public IMarkdownStringRenderer MarkdownRenderer { get; } = context?.MarkdownRenderer ?? throw new ArgumentNullException(nameof(context), "MarkdownRenderer cannot be null");
32+
public BuildContext BuildContext { get; } = context?.BuildContext ?? throw new ArgumentNullException(nameof(context), "BuildContext cannot be null");
33+
public OpenApiDocument Document { get; } = context?.Model ?? throw new ArgumentNullException(nameof(context), "OpenApiDocument cannot be null");
3434

3535

3636
public HtmlString RenderMarkdown(string? markdown)
@@ -81,8 +81,8 @@ public ApiLayoutViewModel CreateGlobalLayoutModel()
8181
BuildType = BuildContext.BuildType,
8282
TocItems = GetTocItems(),
8383
// Header properties for isolated mode
84-
HeaderTitle = Document.Info.Title,
85-
HeaderVersion = Document.Info.Version,
84+
HeaderTitle = Document.Info?.Title ?? "API Documentation",
85+
HeaderVersion = Document.Info?.Version ?? "1.0",
8686
GitBranch = BuildContext.Git.Branch != "unavailable" ? BuildContext.Git.Branch : null,
8787
GitCommitShort = BuildContext.Git.Ref is { Length: >= 7 } r && r != "unavailable" ? r[..7] : null,
8888
GitRepository = BuildContext.Git.RepositoryName != "unavailable" ? BuildContext.Git.RepositoryName : null,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.IO.Abstractions;
6+
using Elastic.Documentation.Extensions;
7+
using Microsoft.OpenApi;
8+
using RazorSlices;
9+
10+
namespace Elastic.ApiExplorer.Landing;
11+
12+
/// <summary>
13+
/// Template-based API landing page model.
14+
/// </summary>
15+
public class TemplateLanding(string templateContent) : IApiGroupingModel
16+
{
17+
/// <summary>
18+
/// The processed template content as HTML.
19+
/// </summary>
20+
public string TemplateContent { get; } = templateContent;
21+
22+
/// <summary>
23+
/// Renders the template-based landing page.
24+
/// </summary>
25+
public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default)
26+
{
27+
if (context?.Model == null)
28+
throw new ArgumentNullException(nameof(context), "Context or context.Model cannot be null");
29+
30+
var viewModel = new TemplateLandingViewModel(context)
31+
{
32+
Landing = this,
33+
TemplateContent = TemplateContent,
34+
ApiInfo = context.Model.Info ?? new() { Title = "API Documentation", Version = "1.0" }
35+
};
36+
var slice = TemplateLandingView.Create(viewModel);
37+
await slice.RenderAsync(stream, cancellationToken: ctx);
38+
}
39+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@inherits RazorSliceHttpResult<Elastic.ApiExplorer.Landing.TemplateLandingViewModel>
2+
@using Elastic.ApiExplorer.Landing
3+
@using Elastic.Documentation.Navigation
4+
@using Elastic.Documentation.Site.Navigation
5+
@using Microsoft.AspNetCore.Html
6+
@implements IUsesLayout<Elastic.ApiExplorer._Layout, ApiLayoutViewModel>
7+
@functions {
8+
public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel();
9+
}
10+
11+
<section id="elastic-docs-v3">
12+
@if (!string.IsNullOrEmpty(Model.TemplateContent))
13+
{
14+
@(new HtmlString(Model.TemplateContent))
15+
}
16+
else
17+
{
18+
<h1>API Documentation</h1>
19+
<p>Welcome to the API documentation.</p>
20+
}
21+
</section>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Microsoft.OpenApi;
6+
7+
namespace Elastic.ApiExplorer.Landing;
8+
9+
/// <summary>
10+
/// View model for template-based API landing pages.
11+
/// </summary>
12+
public class TemplateLandingViewModel(ApiRenderContext context) : ApiViewModel(context)
13+
{
14+
/// <summary>
15+
/// The template landing model.
16+
/// </summary>
17+
public required TemplateLanding Landing { get; init; }
18+
19+
/// <summary>
20+
/// The processed template content as HTML.
21+
/// </summary>
22+
public required string TemplateContent { get; init; }
23+
24+
/// <summary>
25+
/// The API information from the OpenAPI specification.
26+
/// </summary>
27+
public required OpenApiInfo ApiInfo { get; init; }
28+
}

src/Elastic.ApiExplorer/OpenApiGenerator.cs

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
using Elastic.ApiExplorer.Landing;
88
using Elastic.ApiExplorer.Operations;
99
using Elastic.ApiExplorer.Schemas;
10+
using Elastic.ApiExplorer.Templates;
1011
using Elastic.Documentation;
1112
using Elastic.Documentation.Configuration;
13+
using Elastic.Documentation.Configuration.Toc;
1214
using Elastic.Documentation.Navigation;
1315
using Elastic.Documentation.Site.FileProviders;
1416
using Elastic.Documentation.Site.Navigation;
@@ -44,6 +46,7 @@ public class OpenApiGenerator(ILoggerFactory logFactory, BuildContext context, I
4446
private readonly ILogger _logger = logFactory.CreateLogger<OpenApiGenerator>();
4547
private readonly IFileSystem _writeFileSystem = context.WriteFileSystem;
4648
private readonly StaticFileContentHashProvider _contentHashProvider = new(new EmbeddedOrPhysicalFileProvider(context));
49+
private readonly TemplateProcessor _templateProcessor = TemplateProcessorFactory.Create(markdownStringRenderer, context.ReadFileSystem);
4750

4851
public LandingNavigationItem CreateNavigation(string apiUrlSuffix, OpenApiDocument openApiDocument)
4952
{
@@ -208,41 +211,79 @@ List<IEndpointOrOperationNavigationItem> endpointNavigationItems
208211

209212
public async Task Generate(Cancel ctx = default)
210213
{
211-
if (context.Configuration.OpenApiSpecifications is null)
212-
return;
214+
// Use the new API configurations if available, otherwise fall back to legacy OpenApiSpecifications
215+
if (context.Configuration.ApiConfigurations is not null)
216+
{
217+
foreach (var (prefix, apiConfig) in context.Configuration.ApiConfigurations)
218+
{
219+
// Validate assumption of single spec per product
220+
if (apiConfig.SpecFiles.Count > 1)
221+
throw new InvalidOperationException($"API product '{prefix}' has {apiConfig.SpecFiles.Count} spec files, but only one spec file per product is currently supported.");
213222

214-
foreach (var (prefix, path) in context.Configuration.OpenApiSpecifications)
223+
var openApiDocument = await OpenApiReader.Create(apiConfig.PrimarySpecFile);
224+
if (openApiDocument is null)
225+
continue;
226+
227+
await GenerateApiProduct(prefix, openApiDocument, apiConfig, ctx);
228+
}
229+
}
230+
else if (context.Configuration.OpenApiSpecifications is not null)
215231
{
216-
var openApiDocument = await OpenApiReader.Create(path);
217-
if (openApiDocument is null)
218-
return;
232+
// Legacy fallback
233+
foreach (var (prefix, path) in context.Configuration.OpenApiSpecifications)
234+
{
235+
var openApiDocument = await OpenApiReader.Create(path);
236+
if (openApiDocument is null)
237+
continue;
219238

220-
var navigation = CreateNavigation(prefix, openApiDocument);
221-
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info.Title);
239+
await GenerateApiProduct(prefix, openApiDocument, null, ctx);
240+
}
241+
}
242+
}
222243

223-
var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation);
244+
private async Task GenerateApiProduct(string prefix, OpenApiDocument openApiDocument, ResolvedApiConfiguration? apiConfig, Cancel ctx)
245+
{
246+
var navigation = CreateNavigation(prefix, openApiDocument);
247+
_logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? "<no title>");
224248

225-
var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider)
226-
{
227-
NavigationHtml = string.Empty,
228-
CurrentNavigation = navigation,
229-
MarkdownRenderer = markdownStringRenderer
230-
};
231-
_ = await Render(prefix, navigation, navigation.Index.Model, renderContext, navigationRenderer, ctx);
232-
await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, ctx);
249+
var navigationRenderer = new IsolatedBuildNavigationHtmlWriter(context, navigation);
233250

251+
TemplateLanding? templateLanding = null;
252+
if (apiConfig?.HasCustomTemplate == true)
253+
{
254+
var templateContent = await _templateProcessor.ProcessTemplateAsync(apiConfig, ctx);
255+
if (!string.IsNullOrWhiteSpace(templateContent))
256+
templateLanding = new TemplateLanding(templateContent);
234257
}
258+
259+
var renderContext = new ApiRenderContext(context, openApiDocument, _contentHashProvider)
260+
{
261+
NavigationHtml = string.Empty,
262+
CurrentNavigation = navigation,
263+
MarkdownRenderer = markdownStringRenderer,
264+
TemplateLandingPage = templateLanding
265+
};
266+
267+
await RenderNavigationItems(prefix, renderContext, navigationRenderer, navigation, navigation, ctx);
235268
}
236269

237-
private async Task RenderNavigationItems(string prefix, ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, INavigationItem currentNavigation, Cancel ctx)
270+
private async Task RenderNavigationItems(
271+
string prefix,
272+
ApiRenderContext renderContext,
273+
IsolatedBuildNavigationHtmlWriter navigationRenderer,
274+
INavigationItem currentNavigation,
275+
INavigationItem rootNavigation,
276+
Cancel ctx)
238277
{
239278
if (currentNavigation is INodeNavigationItem<IApiModel, INavigationItem> node)
240279
{
241-
_ = await Render(prefix, node, node.Index.Model, renderContext, navigationRenderer, ctx);
280+
_ = renderContext.TemplateLandingPage is { } templateLanding && ReferenceEquals(currentNavigation, rootNavigation)
281+
? await Render(prefix, node, templateLanding, renderContext, navigationRenderer, ctx)
282+
: await Render(prefix, node, node.Index.Model, renderContext, navigationRenderer, ctx);
283+
242284
foreach (var child in node.NavigationItems)
243-
await RenderNavigationItems(prefix, renderContext, navigationRenderer, child, ctx);
285+
await RenderNavigationItems(prefix, renderContext, navigationRenderer, child, rootNavigation, ctx);
244286
}
245-
246287
else
247288
{
248289
_ = currentNavigation is ILeafNavigationItem<IApiModel> leaf

0 commit comments

Comments
 (0)