Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/data/openapi/api-explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,13 +320,15 @@ A request body leaves out `readOnly` fields, because only the server sets them,

A request or response body that declares more than one content type shows a **Content type** select at the top of its body, with the content types that have a schema first. Choosing one swaps the body below it. A response row shows the content type in view as a tag, and the select updates it. A content type with no schema, such as one the spec lists only to attach examples, shows "No schema is declared for this content type."

A bar of quick links sits above the parameter, body and response sections of an operation page: **Path**, **Query**, **Headers**, **Body** and **Responses**, for the sections the operation has. **Required privileges** comes before the bar and is not one of its links. The bar stays under the page header while you scroll, a link jumps to its section, and the link of the section in view is underlined.

Each section is a collapsible heading, expanded when the page loads. A reader can fold it and open it again. Each status code under **Responses** works the same way, and opening one leaves the others as they are. The **hide properties** toggle on an object field is open on load too, so nested fields show without a click; select it to fold them. Its summary punctuation says what the names are:

| Summary | Meaning |
|---|---|
| `{ a, b }` | The fields of a JSON request body |
| `A \| B` | Alternatives, such as the variants of a `oneOf` or the credentials of **Authentication** |
| `A + B` | Needed together, such as the privileges in **Required authorization** |
| `A + B` | Needed together, such as the privileges in **Required privileges** |
| `a, b` | Anything else, such as parameters or the fields of a form |

The names in a summary aren't searchable text, so find-in-page (<kbd>Ctrl</kbd>+<kbd>F</kbd>) skips them and finds the same field inside the section, which opens if a reader has folded it. Sections start expanded, so find-in-page reaches them on first load. Reaching a section a reader has folded relies on `hidden="until-found"`: in a browser without it, a folded section can't be searched.
Expand Down Expand Up @@ -420,9 +422,9 @@ The paths come from either of two sources, and both produce the same rows:
- The `**All methods and paths for this operation:**` (or `**Spaces method and path for this operation:**`) HTML block that bump.sh-flavoured specs put in the operation description. The block is removed from the rendered description. When it lists a `/s/{space_id}/…` route, the page also lists an optional `space_id` path parameter, described as the space identifier, defaulting to the default space, followed by the `Refer to [Spaces](…) for more information.` sentence that follows the block. That sentence is removed from the operation description.
- Separate spec operations that share `x-namespace` and `x-api-name` (see below).

### Required authorization [x-req-auth]
### Required privileges [x-req-auth]

The **Required authorization** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives.
The **Required privileges** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives.

Add the operation-level `x-req-auth` extension to list those privileges. It is a JSON array of strings. Each non-empty string becomes one row, with leading and trailing whitespace trimmed.

Expand All @@ -447,13 +449,13 @@ Specs that do not use `x-req-auth` still work. The API Explorer reads two patter

A line that mixes `OR` and `AND`, or a bullet with extra words around the values, stays in the description as written. When `x-req-auth` is present, it wins over the description. When neither source has privileges, the section is omitted. Malformed `x-req-auth` values are skipped and the build may log a warning.

`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required authorization**. The credentials to send go in **Authentication**.
`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required privileges**. The credentials to send go in **Authentication**.

### Authentication

The **Authentication** section shows how to send a credential. It is expanded by default, and a reader who already has auth set up can fold it. The header shows the scheme names, for example `Api key | Basic | Bearer`.

The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required authorization** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section.
The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required privileges** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section.

Every endpoint with authentication has an `Auth` badge in the row under its path, before the availability badge. The badges in that row, `Auth` and `Deprecated`, share the outlined look of the availability badge. `Deprecated` keeps a red outline and text, and a badge that links is underlined:

Expand Down
2 changes: 1 addition & 1 deletion src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public record ApiPostSection(string Heading, string Anchor, HtmlString BodyHtml,
internal static readonly FrozenSet<string> OperationReservedAnchors = FrozenSet.ToFrozenSet(
[
"paths",
"required-authorization",
"required-privileges",
"authentication",
"description",
"path-params",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ private static void WriteAuthorization(StringBuilder markdown, OperationPageMode
{
if (page.RequiredPrivileges.Count > 0)
{
ApiCommonMark.Heading(markdown, 2, "Required authorization");
ApiCommonMark.Heading(markdown, 2, "Required privileges");
foreach (var row in page.RequiredPrivileges)
{
var values = string.Join(row.Join is null ? ", " : $" {row.Join} ", row.Values.Select(ApiCommonMark.CodeSpan));
Expand Down
5 changes: 5 additions & 0 deletions src/Elastic.ApiExplorer/Operations/OperationView.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
</a>
}
@(await RenderPartialAsync<_AuthorizationSection, AuthorizationSectionModel>(Model.Authorization))
@* The design-system stylesheet has no styles for the bar yet, so the flagged page leaves it out. *@
@if (!Model.BuildContext.Configuration.Features.NavigationPreviewEnabled && Model.SectionLinks is { Count: > 1 } sectionLinks)
{
@(await RenderPartialAsync<_SectionLinks, IReadOnlyList<SectionLink>>(sectionLinks))
}
@foreach (var section in pageModel.ParameterSections)
{
@(await RenderPartialAsync<_ParameterListSection, ParameterListSection>(section))
Expand Down
28 changes: 26 additions & 2 deletions src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public record AuthenticationSectionModel(
bool AlwaysTabs = false
);

/// <summary>Required privileges and credentials for the Required authorization and Authentication sections; <paramref name="RenderMarkdown"/> renders descriptions.</summary>
/// <summary>Required privileges and credentials for the Required privileges and Authentication sections; <paramref name="RenderMarkdown"/> renders descriptions.</summary>
public record AuthorizationSectionModel(
IReadOnlyList<AuthRequirement> Requirements,
IReadOnlyList<PrivilegeRow> Privileges,
Expand All @@ -38,6 +38,9 @@ public record AuthorizationSectionModel(
: Privileges.Any(static r => r.Join == "and") ? SummaryStyle.All : SummaryStyle.List;
}

/// <summary>One quick link of the bar above an operation's sections.</summary>
public record SectionLink(string Label, string Anchor);

public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context)
{
public required ApiOperation Operation { get; init; }
Expand All @@ -53,6 +56,27 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context
Page.AuthBadge?.Kind == AuthBadgeKind.Optional
);

/// <summary>
/// The sections under the quick links bar, in page order. Required privileges is not one of them: it comes before the bar.
/// </summary>
public IReadOnlyList<SectionLink> SectionLinks
{
get
{
var links = new List<SectionLink>(
Page.ParameterSections.Select(static s => new SectionLink(ParameterLabel(s.Location), s.Header.Anchor))
);
if (Operation.Operation.RequestBody is not null)
links.Add(new SectionLink("Body", "request-body"));
if (Page.Responses.Count > 0)
links.Add(new SectionLink("Responses", "responses"));
return links;
}
}

private static string ParameterLabel(ParameterLocation location) =>
location == ParameterLocation.Header ? "Headers" : location.ToString();

/// <summary>Where the <c>auth</c> badge links: the Authentication page, or the endpoint's own section.</summary>
public string? AuthBadgeHref => Page.AuthBadge?.Target switch
{
Expand All @@ -77,7 +101,7 @@ protected override IReadOnlyList<TocItem> GetTocItems()
var tocItems = new List<TocItem> { new("Paths", "paths", 2) };

if (Page.RequiredPrivileges.Count > 0)
tocItems.Add(new TocItem("Required authorization", "required-authorization", 2));
tocItems.Add(new TocItem("Required privileges", "required-privileges", 2));

if (Page.AuthRequirements.Count > 0)
tocItems.Add(new TocItem("Authentication", "authentication", 2));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
@if (Model.Privileges.Count > 0)
{
<div @ParamSectionHeader.WrapAttributes()>
@(await RenderPartialAsync<_ParamSectionHeader, ParamSectionHeader>(new ParamSectionHeader("Required authorization", "required-authorization", Model.PrivilegeNames, Model.PrivilegeStyle)))
<div @ParamSectionHeader.BodyAttributes("required-authorization-list")>
@(await RenderPartialAsync<_ParamSectionHeader, ParamSectionHeader>(new ParamSectionHeader("Required privileges", "required-privileges", Model.PrivilegeNames, Model.PrivilegeStyle)))
<div @ParamSectionHeader.BodyAttributes("required-privileges-list")>
<dl class="property-list api-privileges">
@foreach (var row in Model.Privileges)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@inherits RazorSlice<IReadOnlyList<Elastic.ApiExplorer.Operations.SectionLink>>
@* Quick links to the sections below. The scroll-spy of the "On this page" outline (ds/toc.ts) marks the link of the section in view. *@
<nav class="api-section-links" aria-label="Sections of this endpoint" data-toc data-toc-sticky>
@foreach (var link in Model)
{
<a href="#@link.Anchor" data-toc-link>@link.Label</a>
}
</nav>
56 changes: 55 additions & 1 deletion src/Elastic.Documentation.Site/Assets/api-docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -2944,6 +2944,19 @@ button.example-response-tab:focus-visible {
min-width: 0;
padding-top: 16px;
padding-bottom: 48px;
/* Stays in view while the sections scroll, however long the page is. The rail itself does not clip, so the
Copy page menu can open past its edge; the examples below it shrink to the window and scroll. */
position: sticky;
top: var(--offset-top, 0px);
align-self: start;
max-height: calc(100vh - var(--offset-top, 0px));
box-sizing: border-box;
}

.api-page-rail > :not(.api-page-actions) {
flex: 0 1 auto;
min-height: 0;
overflow: hidden auto;
}

.api-page-rail > .api-page-actions {
Expand All @@ -2956,7 +2969,7 @@ button.example-response-tab:focus-visible {
margin-bottom: 0;
}

/* The examples are normal page flow: no sticky, no scrolling box. Long examples make the page long. */
/* The code blocks are normal page flow and cap at a fixed number of lines; the rail around them is sticky (above). */
#api-examples-panel .api-examples-panel-nav {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -4210,3 +4223,44 @@ html:has(.api-page-shell) {
}
}
}

/* Quick links to the sections of an endpoint: one bar that stays under the header while the sections scroll. */
.api-section-links {
position: sticky;
top: var(--offset-top, 0px);
z-index: 5;
display: flex;
flex-wrap: nowrap;
gap: 24px;
margin: 24px 0 0;
overflow-x: auto;
background: #fff;
border-bottom: 1px solid #e3e8f2;
}

.api-section-links a {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
border-bottom: 2px solid transparent;
color: #516381;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}

.api-section-links a:hover {
color: #1d2a3e;
}

.api-section-links a[aria-current] {
border-bottom-color: #0b64dd;
color: #111c2c;
}

/* A section reached from the bar stops below the bar instead of under it. */
#elastic-api-v3 .api-param-section-header,
#elastic-api-v3 .responses-header {
scroll-margin-top: calc(var(--offset-top, 0px) + 64px);
}
2 changes: 2 additions & 0 deletions src/Elastic.Documentation.Site/Assets/api-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { initApiExamples, syncDescriptions } from './api-examples-carousel'
import { fitParamSummaries } from './api-param-summary'
import { initUnionChips, revealVariantsAround } from './api-union-chips'
import { iconCheckEui, iconCopyEui } from './copybutton'
import { initToc } from './ds/toc'
import { prefersReducedMotion } from './motion'
import { flashTooltip, initTooltips } from './tooltip'

Expand Down Expand Up @@ -312,6 +313,7 @@ function updateExamplesButtonVisibility(): void {
}

function initOperationView(): void {
initToc()
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
Resolved
Comment thread
infra-vault-gh-plugin-prod[bot] marked this conversation as resolved.
Resolved
// Bound once: htmx swaps the section, so the handler looks the elements up each time
if (!examplesScrollBound) {
examplesScrollBound = true
Expand Down
33 changes: 33 additions & 0 deletions src/Elastic.Documentation.Site/Assets/api-section-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { initApiDocs } from './api-docs'
import { beforeAll, describe, expect, it } from 'vitest'

describe('section links', () => {
beforeAll(() => initApiDocs())

it('marks the first link of the bar on an operation page, through the outline scroll-spy', () => {
document.body.innerHTML = `
<section id="elastic-api-v3">
<nav class="api-section-links" data-toc data-toc-sticky>
<a href="#query-params" data-toc-link>Query</a>
<a href="#responses" data-toc-link>Response</a>
</nav>
<h2 id="query-params"></h2>
<h2 id="responses"></h2>
</section>`
// jsdom has no layout: a page taller than the window, scrolled to the top
Object.defineProperty(document.documentElement, 'scrollHeight', {
value: 5000,
configurable: true,
})
// jsdom has no layout: the first section is in view, the second is below the fold
const top = { 'query-params': -100, responses: 900 }
for (const [id, y] of Object.entries(top))
document.getElementById(id)!.getBoundingClientRect = () =>
({ top: y }) as DOMRect
initApiDocs()

const current = document.querySelectorAll('[aria-current]')
expect(current).toHaveLength(1)
expect(current[0].getAttribute('href')).toBe('#query-params')
})
})
Loading
Loading