diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 949301a6da..807ef9de99 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -4,220 +4,370 @@ navigation_title: API Explorer # API Explorer -The API Explorer turns an OpenAPI spec into HTML pages. If you add an `api:` entry in `docset.yml`, {{dbuild}} generates: - -- a product landing page -- one tag landing page per tag -- one operation page per operation -- schema type pages for shared types +The API Explorer renders OpenAPI specifications as interactive API documentation. When you configure it in your content set, `docs-builder` automatically generates pages for each API operation, request and response schemas, shared type definitions, and inline examples. :::{warning} This feature is still under development and the functionality described on this page might change. ::: -## Get started +## Configure the API Explorer + +Add the `api` key to your `docset.yml` file to enable the API Explorer. Each product key takes a +single-entry sequence with a required `spec:` and `product:`, and optional `repository:` and +`children:`: + +```yaml +api: + elasticsearch: + - spec: elasticsearch-openapi.json + product: elasticsearch + kibana: + - spec: kibana-openapi.json + product: kibana +``` + +Each product key produces its own section of API documentation. For example, `elasticsearch` generates pages under `/api/elasticsearch/` and `kibana` generates pages under `/api/kibana/`. -This repository includes a working example. Follow these steps against that example. +The `api` key is only valid in `docset.yml`. You can't use it in `toc.yml` files. -:::::{stepper} +### `spec:` (required) -::::{step} Read the `api:` entry in `_docset.yml` +A path to an OpenAPI spec file, relative to the folder that contains `docset.yml`. `spec:` serves +two purposes at once: -The `api` key is valid in `docset.yml` only. Do not put it in `toc.yml`. +- If a file exists at that path, {{dbuild}} renders it directly. This is the common setup for a + docset that carries its own spec file. +- Its basename (for example `elasticsearch-openapi.json`) is always used to look up this API's + entry in the remote version index, whether or not the file exists locally. See + [Remote spec resolution](#remote-spec-resolution). -This repository uses `_docset.yml`. The live entry is: +### `product:` (required) + +A product id defined in `products.yml`. This binds the API to that product's versioning system +and display name. The build fails with a suggestion if `product:` doesn't match a known product id. + +### `repository:` (optional) + +An `org/repo` override (for example `elastic/elasticsearch-specification`) used to look up this +API in the remote version index, instead of the current checkout's own GitHub remote. Set this +whenever the repository that publishes the OpenAPI spec differs from the repository the docset +itself builds from: ```yaml api: - docs-builder-elasticsearch: - - spec: elasticsearch.json + elasticsearch: + - spec: elasticsearch-openapi.json product: elasticsearch repository: elastic/elasticsearch-specification ``` -The map key is the URL suffix. This key produces `/api/doc/docs-builder-elasticsearch/`. +Most docsets omit `repository:` — it's only needed for this cross-repo case. When omitted, +{{dbuild}} derives the repository from the current checkout's GitHub remote. -Each key takes a sequence with exactly one entry. That entry requires `spec:` and `product:`. `repository:` and `children:` are optional. See [Reference](#reference). +### `children:` (optional) -:::: +Explicit hand-written pages rendered under `api//`, in the declared order: -::::{step} Preview the generated pages +```yaml +api: + kibana: + - spec: kibana-openapi.json + product: kibana + children: + - file: kibana-api-overview.md +``` -If you pass `--watch`, {{dbuild}} does not generate API pages. Run serve without `--watch`: +`children:` is the only way to inject hand-written content into an API reference section: -```bash -docs-builder serve -``` +- Child pages are fully rendered Markdown with access to all MyST directives, substitutions, and cross-links. +- Child files are automatically excluded from normal HTML generation — you do not need to add them to the `exclude:` list. -Open [http://localhost:3000/api/doc/docs-builder-elasticsearch/](http://localhost:3000/api/doc/docs-builder-elasticsearch/). {{dbuild}} generates API pages on the first `/api/` request. After that, it rebuilds them when the spec file or files under `api//` change. +**What you cannot do today:** there is no way to override or augment an individual operation, +tag, schema, or parameter description using a local Markdown file. Every description for generated +operations, tags, and schema types comes verbatim from the OpenAPI JSON. For per-operation and +per-parameter enrichment see the [CLI reference](../cli-schema/index.md), which provides a +fine-grained supplemental mechanism as a reference model for what future API augmentation could +look like. -If you only edit Markdown outside the API tree, pass `--skip-api` to `docs-builder build`. +#### Child file naming and validation -:::: +A file's URL slug is derived from its filename: lowercase, with spaces and underscores replaced by +hyphens, and the `.md` extension removed. For example, `Getting-Started.md` becomes the slug +`getting-started`. -::::{step} Open the supplemental fixture +The following slugs are reserved and cannot be used as child file names: -Put operation files in `api//`. The file name is `op-` plus the spec `operationId`. Do not add a toc entry. +| Reserved slug | Reason | +|---|---| +| `types` | API Explorer uses this path for schema type pages | +| `tags` | API Explorer uses this path for tag landing pages | -This repository includes `docs/api/docs-builder-elasticsearch/op-async-search-get.md`. After serve, open: +Additionally, the slug must not match any operation moniker already generated by the spec. The +build fails with a descriptive error if either collision occurs, naming the conflicting file and +the reserved or operation segment. -[http://localhost:3000/api/doc/docs-builder-elasticsearch/operation/operation-async-search-get/](http://localhost:3000/api/doc/docs-builder-elasticsearch/operation/operation-async-search-get/) +If the same slug is produced by two different child files in the same product, the build +also fails with a duplicate-slug error. -That file: +### One spec per product -- replaces the spec description -- overrides the `keep_alive` and `id` parameter text -- appends a **When to poll** section after the generated reference +Each product key in the `api:` block must have **exactly one** entry, with **exactly one** +`spec:`. The build fails if a product sequence is empty or has more than one entry. Multiple +specs per product are not currently supported. -Heading rules, tag files, and `children:` pages are in [Writing supplemental content](./supplemental.md). +## Remote spec resolution -:::: +When `spec:` does not resolve to a file on disk, {{dbuild}} resolves the current (`main`) version +of that spec remotely through a CloudFront-backed version index shared by every Elastic repository +that publishes OpenAPI specs. -::::{step} Override one major version +### How specs are published -If one major needs different text, add a `.vN.md` file next to the base file. This repository does not ship a `.vN.md` file. The pattern is: +Each repository publishes its OpenAPI spec under a stable object key in a shared bucket: -```text -api/elasticsearch/ - op-search.md - op-search.v8.md +``` +///. ``` -The unversioned `/api/doc//` tree uses the overlay of the highest numeric major that this product renders. Merge rules are in [Writing supplemental content](./supplemental.md#version-specific-files). - -:::: +For example, Elasticsearch's spec is published from a separate specification repository, at keys +like `elastic/elasticsearch-specification/main/elasticsearch.json` and +`elastic/elasticsearch-specification/8.19/elasticsearch.json`. -::::{step} Read the build error, then fix the file +### The version index -If the file name does not match an `operationId`, the build fails. If a parameter key is not in the spec, the build also fails. +A single root `index.json` manifest maps every published spec to its highest-minor branch per +major. It is keyed by `org/repo`, then by spec basename (matching `spec:`'s basename), then by +version moniker (`main`, `9`, `8`, ...): -```text -API supplemental file 'op-nope.md' does not match any operationId in the latest spec -API supplemental: Parameter 'typo' not found in operation 'async-search-get' in the latest spec +```json +{ + "elastic/elasticsearch-specification": { + "elasticsearch.json": { + "main": { "version": "main" }, + "9": { "version": "9.5" }, + "8": { "version": "8.19" } + } + } +} ``` -Fix the file. Then rebuild. More messages are in [Writing supplemental content](./supplemental.md#validation-errors). - -:::: +{{dbuild}} fetches this manifest once per build from +`https://d29hkgsdo66d1n.cloudfront.net/index.json`, then looks up the `org/repo` (from +`repository:`, falling back to the current checkout's GitHub remote) and the `spec:` basename to +find this API's versions. Spec objects are fetched at +`{base}/{org}/{repo}/{version}/{spec-basename}`. -::::: +If the API has no local spec file and the `org/repo` or spec basename does not have a matching +entry in the index, the build fails with an error naming the API and what was missing. If a local +spec file is also configured, that error becomes a warning instead, and the build falls back to +rendering the local file. -## Reference +For versioned products, {{dbuild}} renders every resolved version from the index: -| `docset.yml` key | Required | Description | +| Index moniker | URL path | Role | |---|---|---| -| `spec:` | yes | Path to an OpenAPI file, relative to `docset.yml`. If the file exists, {{dbuild}} renders it for `main`. The basename is always used to look up the remote version index. | -| `product:` | yes | A product id from `products.yml`. This binds the API to that product's versioning system. | -| `repository:` | no | `org/repo` used to look up the version index. Set this when the spec is published from a different GitHub repository than the docset. | -| `children:` | no | Extra Markdown pages under `api//`, in declared order. See [children:](./supplemental.md#children-pages). | +| `main` | `/api/doc//` | Canonical current-major tree | +| `9`, `8`, … | `/api/doc//v9/`, `/api/doc//v8/`, … | Released major snapshots | -Each product key must have exactly one sequence entry. That entry must have exactly one `spec:`. An empty sequence fails the build. A sequence with more than one entry also fails the build. +The numeric `9` entry is a frozen v9 snapshot. It is distinct from the moving `main` entry. +When a local spec file exists, it overrides only the `main` moniker. Older majors still resolve +remotely through the index. -### `spec:` +Versionless products (`versioning: serverless` and similar) render only the unversioned +`/api/doc//` path even when the index lists historical monikers. When more than one +version is rendered, API pages show a simple version dropdown at the top of the left navigation +rail. The dropdown links to each version's landing page. -If the file exists on disk, {{dbuild}} uses it for the `main` moniker. Older majors still come from the version index. +### Smoke-test every CloudFront spec locally -The basename always looks up the version index. That is true when the file exists. It is also true when the file is missing. See [Remote spec resolution](#remote-spec-resolution). +The docs-builder dev docset ships six API keys that mirror every spec currently listed +in the live version index. They have no local spec files, so `docs-builder serve` fetches each one +from CloudFront: -### `product:` +| URL path | Index entry | +|---|---| +| `/api/elasticsearch/` | `elastic/elasticsearch-specification` → `elasticsearch.json` | +| `/api/elasticsearch-serverless/` | `elastic/elasticsearch-specification` → `elasticsearch-serverless.json` | +| `/api/kibana/` | `elastic/kibana` → `kibana.yaml` | +| `/api/kibana-serverless/` | `elastic/kibana` → `kibana-serverless.yaml` | +| `/api/cloud-connect/` | `elastic/cloud-connected-api` → `cloud-connect.yml` | +| `/api/cloud-serverless/` | `elastic/serverless-api-specification` → `elastic-cloud-serverless.yml` | -If `product:` is not a known product id, the build fails. The error includes a suggestion. +Run `docs-builder serve` (without `--watch`) and open any path above. -### `repository:` +## Place your spec files -This repository sets `repository: elastic/elasticsearch-specification` because the spec is published from that repository, not from `elastic/docs-builder`. +To carry a spec locally, place the OpenAPI specification file in the same folder as your +`docset.yml` (or in a subfolder of it). The path you specify in `spec:` is resolved relative to +the `docset.yml` location. -If you omit `repository:`, {{dbuild}} uses the GitHub remote of the current checkout. +For example, if your content set is structured like this: -### `children:` +``` +docs/ + docset.yml + elasticsearch-openapi.json + kibana-openapi.json + index.md + ... +``` -`children:` adds full Markdown pages under the product root. Supplemental `op-*.md` and `tag-*.md` files are not `children:` pages. They merge into generated operation and tag pages. +Your `docset.yml` references the specs as follows: -{{dbuild}} does not emit child files as normal docset HTML. Do not add them to `exclude:`. +```yaml +api: + elasticsearch: + - spec: elasticsearch-openapi.json + product: elasticsearch + kibana: + - spec: kibana-openapi.json + product: kibana +``` -## Page URLs +## When the API Explorer runs -`{key}` is the `api:` map key. It is not the `product:` id. +The API Explorer generates documentation in two scenarios: -| Page | Path | -|---|---| -| Product root (`main`) | `/api/doc/{key}/` | -| Released major | `/api/doc/{key}/v9/`, `/api/doc/{key}/v8/` | -| Operation | `/api/doc/{key}/operation/operation-{operationId}/` | -| Tag landing | `/api/doc/{key}/group/endpoint-{tagSlug}/` | -| Schema type | `/api/doc/{key}/types/{schemaMoniker}/` | -| Child Markdown | `/api/doc/{key}/{slug}/` | +- **`docs-builder build`**: API docs are generated as part of the standard build. Use `--skip-api` to skip generation for faster iteration on content. +- **`docs-builder serve`**: API docs are generated on startup and regenerated automatically when spec files change. -{{dbuild}} lowercases the `operationId` in the URL. For tag slugs, it replaces spaces with hyphens and lowercases the name. Underscores stay. +:::{note} +API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. +::: -The `api:` key creates this URL tree. Do not list generated operation pages in `toc.yml`. From Markdown inside an API page, you can link with paths such as `../group/endpoint-search` and `../operation/operation-search`. {{dbuild}} rewrites those links against the current product base. +## Link to API pages in navigation -## Multi-version behavior +You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax: -For a versioned product, {{dbuild}} renders every resolved version: +```yaml +toc: + - file: index.md + - title: Elasticsearch API Reference + crosslink: elasticsearch://api/elasticsearch/ +``` -| Index moniker | URL path | Role | -|---|---|---| -| `main` | `/api/doc/{key}/` | Current-major tree | -| `9`, `8` | `/api/doc/{key}/v9/`, `/v8/` | Frozen major snapshots | +## What the API Explorer renders -The numeric `9` entry is a frozen snapshot. It is not the same as `main`. The unversioned tree uses the overlay of the highest numeric major that this product renders. +The API Explorer generates the following types of pages from your OpenAPI spec: -If a local spec file exists, it overrides `main` only. +- **Landing page**: An overview of the API grouped by tag +- **Tag landing pages**: One page per tag that lists operations in that tag, with the tag's display name, optional OpenAPI `description` (CommonMark), and optional `externalDocs` link +- **Operation pages**: One page per API operation, with the HTTP method, path, parameters, request body, response schemas, and examples +- **Schema type pages**: Dedicated pages for complex shared types such as `QueryContainer` and `AggregationContainer` -A versionless product (`versioning: serverless` and similar) renders only `/api/doc/{key}/`. If more than one version is rendered, the left navigation shows a version dropdown. +## OpenAPI extensions -## Remote spec resolution +The API Explorer supports some OpenAPI specification extensions to enhance navigation and display: -If `spec:` does not resolve to a file on disk, {{dbuild}} fetches `main` from a CloudFront version index. Repositories that publish OpenAPI specs share this index. +- [x-codeSamples](#x-codesamples) +- [x-displayName](#x-displayname) +- [x-req-auth](#x-req-auth) +- [x-tagGroups](#x-taggroups) -Object keys in the bucket look like this: +For background on OpenAPI vendor extensions, refer to [OpenAPI Specification](https://spec.openapis.org/oas/latest.html#specification-extensions). -``` -///. -``` +### Multi-language code examples [x-codesamples] -Example: `elastic/elasticsearch-specification/main/elasticsearch.json`. +When an OpenAPI operation includes the `x-codeSamples` extension, the API Explorer renders the code samples with a language selector tab. This lets users switch between available languages such as Console, cURL, Python, JavaScript, Ruby, PHP, and Java. -The root manifest is `https://d29hkgsdo66d1n.cloudfront.net/index.json`. It is keyed by `org/repo`, then spec basename, then moniker (`main`, `9`, `8`). {{dbuild}} fetches that manifest once per build. It looks up `repository:` first. If `repository:` is missing, it uses the GitHub remote of the current checkout. Spec objects are fetched at `{base}/{org}/{repo}/{version}/{spec-basename}`. +The `x-codeSamples` extension is a JSON array of objects, each with a `lang` and `source` field: -If there is no local spec and the index has no matching entry, the build fails. If a local spec exists, that miss is a warning. Then {{dbuild}} renders the local file. +```json +"x-codeSamples": [ + { "lang": "Console", "source": "GET /_search" }, + { "lang": "curl", "source": "curl -X GET ..." }, + { "lang": "Python", "source": "resp = client.search()" } +] +``` -## When the API Explorer runs +Code samples appear in the right-hand **Examples** rail on every operation page that has the extension, regardless of HTTP method. When an operation also declares multiple named request/response `examples`, the rail builds **scenarios from request examples** (matched to response examples by title/summary) and exposes a `` in the header, attached to the scenario whose request body matches the Console sample. When a scenario has request JSON but no `x-codeSamples`, that JSON uses the same request code card (label `JSON`). There is no separate "Request" heading in the rail — only the **Examples** heading when multiple scenarios exist. Request and response code boxes show a non-selectable line-number gutter (selection and copy omit the numbers). Response bodies that are JSON objects/arrays use the Figma Card/Code token colors (black structure, green strings, blue booleans, maroon numbers); other payloads such as SSE streams stay plaintext so highlighting does not invent misleading colors. Single-line `curl` samples are reformatted for display (method and URL on the first line, one flag per line, with `\` continuations). OpenAPI example `description` text is not shown in the rail (the code samples and response JSON carry the content). -- **`docs-builder build`.** {{dbuild}} generates API pages unless you pass `--skip-api`. -- **`docs-builder serve`.** {{dbuild}} generates API pages on the first `/api/` request. It rebuilds them when the spec or `api//` Markdown files change. `--watch` skips API generation. -- **Assembler builds.** {{dbuild}} generates API pages when the `assembler-api-explorer` feature flag is on. `staging` and `preview` set `ASSEMBLER_API_EXPLORER`. Production does not. +When an operation has **no** `x-codeSamples`, the API Explorer synthesizes a minimal **Console** and **curl** sample from the HTTP method, path, required query parameters, required headers (for example `kbn-xsrf`), and the document `servers` URL so the examples rail is never empty. Author-provided `x-codeSamples` always win over these synthetic samples. When the rail has samples (or request examples) but the operation declares response status codes without example bodies, the rail still shows status-code tabs: responses with no content render **No body**, and responses that declare a content type/schema but no example render **No example**. -## OpenAPI extensions +When there is only a single scenario (or only `x-codeSamples` / synthetic samples), the rail skips the scenario selector. The selected language persists across operations and page navigations. Console is treated as the default language and appears first in the language selector when present. -These spec extensions change how pages render. They live in the OpenAPI file, not in supplemental Markdown. +### Prerequisites [x-req-auth] -### `x-codeSamples` +Add the operation-level `x-req-auth` extension to list authentication or privilege requirements that users must satisfy before calling the API. +The API Explorer renders these lines in a **Prerequisites** section on the operation page. -If an operation has `x-codeSamples`, the operation page shows a **Code Examples** section. Each array item needs `lang` and `source`. Console is sorted first when present. The tab list is the `lang` values in the spec. Tabs use the `api-language` sync group, so the selected language stays across pages. +`x-req-auth` is a JSON array of strings. +Each non-empty string becomes one item in the prerequisites list (leading and trailing whitespace is trimmed). ```json -"x-codeSamples": [ - { "lang": "Console", "source": "GET /_search" }, - { "lang": "curl", "source": "curl -X GET ..." } -] +{ + "get": { + "operationId": "get-snapshot", + "responses": { "200": { "description": "ok" } }, + "x-req-auth": [ + "Cluster privilege: `cluster:admin/snapshot`" + ] + } +} ``` -### `x-req-auth` -If an operation has `x-req-auth` as a JSON array of strings, the operation page shows a **Prerequisites** section after **Paths**. Empty strings are dropped. If the value is not an array, the section is omitted and the build may log a warning. + +When prerequisites are present, **Prerequisites** also appears in the on-page table of contents (after **Paths**). +When the extension is missing, empty, or not a JSON array, the section is omitted. +Malformed values are skipped and the build may log a warning. + +### Tag labels [x-displayname] + +Use the `x-displayName` extension (from [Redocly](https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-display-name)) on tag objects to provide user-friendly display names in navigation and landing pages while maintaining stable URLs based on the canonical tag name. ```json -"x-req-auth": [ - "Cluster privilege: `cluster:admin/snapshot`" -] +{ + "tags": [ + { + "name": "tasks", + "description": "The task management APIs enable you to get information about tasks currently running.", + "x-displayName": "Task management" + }, + { + "name": "ml_anomaly", + "description": "Machine learning anomaly detection APIs.", + "x-displayName": "Machine Learning Anomaly Detection" + } + ] +} ``` -### `x-displayName` +**Behavior:** -If a tag object has `x-displayName`, navigation and tag headings use that string. URLs still use the canonical tag `name`. If two tag names slug to the same URL segment, the build fails. +- When `x-displayName` is present, it's used for navigation titles, tag landing page titles, and section headings on the main API overview +- When `x-displayName` is absent, the canonical tag `name` is used as a fallback +- Tag landing page URLs and tag URL segments are derived from the canonical tag `name` + +:::{note} +If two different canonical tag names normalize to the same tag landing page URL, the build fails with an error that names both tags and the colliding segment so the spec can be fixed. +::: + +### Tag groups [x-taggroups] + +Use the document-level `x-tagGroups` extension (from [Redocly](https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-tag-groups)) to define how tags are grouped in the API Explorer sidebar. Each group has a display `name` and a list of tag `name` values that belong to it. Group order in the array is the order of top-level sections in the navigation. + +```json +{ + "openapi": "3.0.3", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": {}, + "x-tagGroups": [ + { + "name": "Search & Document APIs", + "tags": ["search", "document", "eql", "esql", "sql"] + }, + { + "name": "Cluster Management", + "tags": ["indices", "cluster", "snapshot"] + } + ] +} +``` -### `x-tagGroups` +**Behavior:** -If the document has `x-tagGroups`, the sidebar groups tags by those lists. Group order follows the array. A group title links to the product landing page. It is not its own URL. Tags that are missing from every group appear under `unknown`. The build logs a warning. +- When `x-tagGroups` is present and valid, the API Explorer uses it as an additional level of grouping in the sidebar. +- In the navigation tree, a group's section title links to the **main API overview** for that product (it is not a separate page and does not point at the first tag in the group; tag landings stay under `.../tags/...` only for tags). +- When `x-tagGroups` is absent, tags are listed directly under the API root in a single flat layer. +- Any operation tag that is not listed under any group is still included: it appears under a fallback section named `unknown`, and the build logs a warning so you can fix the spec. diff --git a/src/Elastic.ApiExplorer/AGENTS.md b/src/Elastic.ApiExplorer/AGENTS.md index 058463dd14..4eba2c1359 100644 --- a/src/Elastic.ApiExplorer/AGENTS.md +++ b/src/Elastic.ApiExplorer/AGENTS.md @@ -26,7 +26,7 @@ Components/ Reusable View+ViewModel widgets embedded by more than o PropertyTree/ The collapsible property listing: ApiProperty view-model tree, ApiPropertyTreeBuilder (maps Model → view models), TypeAnnotation. _Partials/ Its templates: _PropertyItem, _PropertyList, _UnionOptions, _SchemaType, - _ValidationConstraints, _RecursiveBadge. + _ValidationConstraints. Landing/ Slice: product landing, tag landing and intro/outro markdown pages. Operations/ Slice: operation pages (ApiOperation/ApiEndpoint, page model, view). diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs index f15d08bcd3..a2c5071065 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiProperty.cs @@ -126,7 +126,7 @@ public record ApiProperty public required bool IsLast { get; init; } public required bool IsRecursive { get; init; } - /// Whether the row shows the required/optional tag for request or response context. + /// Whether the row is in a request body (vs response); kept for callers that branch on context. public required bool IsRequest { get; init; } public required TypeAnnotation Type { get; init; } diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs index cc15050112..14e21587bd 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/ApiPropertyTreeBuilder.cs @@ -143,7 +143,6 @@ public static IReadOnlyList BuildConstraints(IOpenApiSchema s private HtmlString RenderDescription(string name, string? specDescription, PropertyTreeScope scope) { - // ponytail: match property Name only. Nested paths if authors need them. var description = scope.IsRequest && scope.DescriptionOverrides is { Count: > 0 } && scope.DescriptionOverrides.TryGetValue(name, out var overrideText) ? overrideText : specDescription; @@ -173,7 +172,8 @@ private ApiProperty BuildProperty(PropertyRow row, PropertyTreeScope scope) Constraints = BuildConstraints(propSchema), EnumValues = typeInfo is { IsEnum: true, EnumValues.Length: > 0 } ? typeInfo.EnumValues : [], Union = typeInfo.IsUnion ? BuildUnionDisplay(propSchema, typeInfo, expansion) : null, - ArrayItemTypeName = string.IsNullOrEmpty(typeInfo.ArrayItemType) ? null : typeInfo.ArrayItemType, + // Type annotation already reads "array of …"; skip the redundant "Array of:" row. + ArrayItemTypeName = null, TypeLink = BuildTypeLink(typeInfo, expansion), IsCollapsible = expansion.IsCollapsible, DefaultExpanded = expansion.DefaultExpanded, @@ -693,11 +693,20 @@ private static TypeAnnotation BuildAnnotation(TypeInfo typeInfo, bool hasActualP if (typeInfo.IsArray) { - spans.Add(new TypeSpan("[] ", "array-icon")); + spans.Add(new TypeSpan("array of ", "array-keyword")); AppendArrayKeywordSpans(spans, typeInfo, hasActualProperties); + if (typeInfo.HasLink) + spans.Add(new TypeSpan("{} ", "object-icon")); + spans.Add( + new TypeSpan( + PluralizeArrayItemTypeName(typeName), + Title: string.IsNullOrEmpty(typeInfo.SchemaRef) ? null : typeInfo.SchemaRef + ) + ); + return new TypeAnnotation(spans); } - else - AppendScalarKeywordSpans(spans, typeInfo, hasActualProperties); + + AppendScalarKeywordSpans(spans, typeInfo, hasActualProperties); if (typeInfo.HasLink) spans.Add(new TypeSpan("{} ", "object-icon")); @@ -705,6 +714,16 @@ private static TypeAnnotation BuildAnnotation(TypeInfo typeInfo, bool hasActualP return new TypeAnnotation(spans); } + private static string PluralizeArrayItemTypeName(string typeName) => typeName switch + { + "string" => "strings", + "integer" => "integers", + "number" => "numbers", + "boolean" => "booleans", + "object" => "objects", + _ => typeName + }; + private static void AppendArrayKeywordSpans(List spans, TypeInfo typeInfo, bool hasActualProperties) { if (typeInfo.IsValueType && !string.IsNullOrEmpty(typeInfo.ValueTypeBase)) diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml index 2ff1a76360..73b2d8ade0 100644 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml +++ b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_PropertyItem.cshtml @@ -7,18 +7,10 @@ @Model.Name @(await RenderPartialAsync<_SchemaType, TypeAnnotation>(Model.Type)) - @if (Model.IsRequest && Model.IsRequired) + @if (Model.IsRequired) { required } - else if (!Model.IsRequest && !Model.IsRequired) - { - optional - } - @if (Model.IsRecursive) - { - @(await RenderPartialAsync<_RecursiveBadge>()) - } @if (Model.ShowDeprecatedBadge) { deprecated diff --git a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_RecursiveBadge.cshtml b/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_RecursiveBadge.cshtml deleted file mode 100644 index cd177b6d70..0000000000 --- a/src/Elastic.ApiExplorer/Components/PropertyTree/_Partials/_RecursiveBadge.cshtml +++ /dev/null @@ -1,2 +0,0 @@ -@inherits RazorSlice -recursive diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiBreadcrumb.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiBreadcrumb.cs new file mode 100644 index 0000000000..6489e32647 --- /dev/null +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiBreadcrumb.cs @@ -0,0 +1,76 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Navigation; + +namespace Elastic.ApiExplorer.Infrastructure; + +/// One crumb. is null for the current page (not a link). +public sealed record ApiBreadcrumb(string Title, string? Url) +{ + public bool IsCurrent => Url is null; +} + +/// +/// Visible crumbs plus optional overflow (EUI Page breadcrumbs, max 4). +/// When overflowing: first two, ellipsis, last two. +/// +public sealed record ApiBreadcrumbTrail( + IReadOnlyList Head, + IReadOnlyList Overflow, + IReadOnlyList Tail +) +{ + public static readonly ApiBreadcrumbTrail Empty = new([], [], []); + + public bool HasOverflow => Overflow.Count > 0; + + public bool IsEmpty => Head.Count == 0 && Tail.Count == 0; +} + +public sealed record ApiBreadcrumbsView(ApiBreadcrumbTrail Trail, string HxAttributes); + +public static class ApiBreadcrumbBuilder +{ + public const int MaxVisible = 4; + + public static ApiBreadcrumbTrail Build(INavigationItem current, string currentTitle, string? rootTitle) + { + var items = Collect(current, currentTitle, rootTitle); + if (items.Count == 0) + return ApiBreadcrumbTrail.Empty; + return Split(items); + } + + internal static IReadOnlyList Collect(INavigationItem current, string currentTitle, string? rootTitle) + { + var items = new List(); + foreach (var parent in current.GetParents().Reverse()) + { + if (parent.Hidden) + continue; + if (string.Equals(parent.Url, current.Url, StringComparison.Ordinal)) + continue; + if (string.Equals(parent.NavigationTitle, currentTitle, StringComparison.OrdinalIgnoreCase)) + continue; + + var title = parent.Parent is null && !string.IsNullOrWhiteSpace(rootTitle) ? rootTitle : parent.NavigationTitle; + if (string.IsNullOrWhiteSpace(title)) + continue; + items.Add(new ApiBreadcrumb(title, parent.Url)); + } + + var currentLabel = string.IsNullOrWhiteSpace(currentTitle) ? current.NavigationTitle : currentTitle; + items.Add(new ApiBreadcrumb(currentLabel, null)); + return items; + } + + internal static ApiBreadcrumbTrail Split(IReadOnlyList items) + { + if (items.Count <= MaxVisible) + return new ApiBreadcrumbTrail(items, [], []); + + return new ApiBreadcrumbTrail(items.Take(2).ToArray(), items.Skip(2).Take(items.Count - 4).ToArray(), items.TakeLast(2).ToArray()); + } +} diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiCodeSampleModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiCodeSampleModel.cs new file mode 100644 index 0000000000..1dff0ecc25 --- /dev/null +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiCodeSampleModel.cs @@ -0,0 +1,10 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.ApiExplorer.Model; + +namespace Elastic.ApiExplorer.Infrastructure; + +/// Multi-language code sample widget with a header language selector. +public record ApiCodeSampleModel(string IdPrefix, IReadOnlyList Samples); diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index de6ff3bf15..d3fb39d99c 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -22,7 +22,18 @@ public record ApiTocItem(string Heading, string Slug, int Level = 2); public record ApiLayoutViewModel : GlobalLayoutViewModel { public required IReadOnlyList TocItems { get; init; } + + /// When set, operation pages render examples in the right rail instead of the in-page TOC. + public OperationExamplesPanelModel? ExamplesPanel { get; init; } + + public required ApiBreadcrumbTrail Breadcrumbs { get; init; } public IReadOnlyList VersionSwitcherItems { get; init; } = []; + + /// + /// Preload hint for API links. Body already hx-boosts into #main-container, + /// so the examples rail swaps with the article without a dedicated OOB provider. + /// + public string HxAttributes => $" preload=\"{Htmx.Preload}\""; } public abstract class ApiViewModel(ApiRenderContext context) @@ -49,6 +60,9 @@ public abstract class ApiViewModel(ApiRenderContext context) /// When set, drives for this page (e.g. intro/outro markdown). Does not affect which stays as the API product name. protected virtual string? LayoutPageTitle => null; + /// Last breadcrumb label. Defaults to or the nav title. + protected virtual string BreadcrumbCurrentTitle => LayoutPageTitle ?? CurrentNavigationItem.NavigationTitle; + private string? GetGitHubDocsUrl() { var repo = BuildContext.Git.RepositoryName; @@ -74,15 +88,17 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() Previous = null, Next = null, NavigationHtml = NavigationHtml, + NavigationActiveUrl = CurrentNavigationItem.Url, UrlPathPrefix = BuildContext.UrlPathPrefix, AllowIndexing = BuildContext.AllowIndexing, CanonicalBaseUrl = BuildContext.CanonicalBaseUrl, - GoogleTagManager = new GoogleTagManagerConfiguration(), - Optimizely = new OptimizelyConfiguration(), + GoogleTagManager = BuildContext.GoogleTagManager, + Optimizely = BuildContext.Optimizely, Features = new FeatureFlags([]), StaticFileContentHashProvider = StaticFileContentHashProvider, BuildType = BuildContext.BuildType, TocItems = GetTocItems(), + Breadcrumbs = ApiBreadcrumbBuilder.Build(CurrentNavigationItem, BreadcrumbCurrentTitle, Document.Info?.Title), VersionSwitcherItems = RenderContext.VersionSwitcherItems, // Header properties for isolated mode HeaderTitle = docTitle, diff --git a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs index 476123f164..07deda8a53 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingNavigationItem.cs @@ -106,9 +106,12 @@ LandingNavigationItem parent classification, rootNavigation, parent -), IRootNavigationItem +), IRootNavigationItem, ISidebarHeadingNavigationItem { - /// Section titles from x-tagGroups are not their own page; the sidebar link targets the main API overview for the product, not a tag (or the first child) page. + /// + /// Classifications have no dedicated page. Kept as the product overview URL for any code that still + /// reads ; Nav V2 renders these as non-clickable sidebar headings. + /// public override string Url => rootNavigation.Index.Url; /// @@ -152,7 +155,7 @@ public class EndpointNavigationItem( ApiEndpoint endpoint, IRootNavigationItem rootNavigation, INodeNavigationItem parent -) : IApiGroupingNavigationItem, IEndpointOrOperationNavigationItem +) : IApiGroupingNavigationItem, IEndpointOrOperationNavigationItem, IMultiOperationNavigationItem { /// public string Url => NavigationItems.First().Url; diff --git a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml index bbfcd9fb03..5ee3f66186 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/LandingView.cshtml @@ -5,6 +5,9 @@ @functions { public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); } +@{ + var apiHxAttrs = LayoutModel.HxAttributes; +}

@Model.ApiInfo.Title

@Model.RenderMarkdown(Model.ApiInfo.Description)

@@ -22,19 +25,19 @@ break; case OverviewRowKind.TagHeading: -

@row.Title

+

@row.Title

break; case OverviewRowKind.Endpoint: @(row.Title) - @(await RenderPartialAsync<_OperationUrlList, IReadOnlyCollection>(row.Operations)) + @(await RenderPartialAsync<_OperationUrlList, OperationUrlListModel>(new OperationUrlListModel(row.Operations, apiHxAttrs))) break; case OverviewRowKind.Operation: @(row.Title) - @(await RenderPartialAsync<_OperationUrlList, IReadOnlyCollection>(row.Operations)) + @(await RenderPartialAsync<_OperationUrlList, OperationUrlListModel>(new OperationUrlListModel(row.Operations, apiHxAttrs))) break; case OverviewRowKind.SchemaCategoryHeading: @@ -44,13 +47,13 @@ break; case OverviewRowKind.Schema: - @(row.Title) + @(row.Title) @row.SchemaId break; case OverviewRowKind.MarkdownPage: - @(row.Title) + @(row.Title) Additional documentation break; diff --git a/src/Elastic.ApiExplorer/Landing/LandingViewModel.cs b/src/Elastic.ApiExplorer/Landing/LandingViewModel.cs index 02ec37e098..be39902829 100644 --- a/src/Elastic.ApiExplorer/Landing/LandingViewModel.cs +++ b/src/Elastic.ApiExplorer/Landing/LandingViewModel.cs @@ -16,4 +16,6 @@ public class LandingViewModel(ApiRenderContext context) : ApiViewModel(context) /// Flattened overview table rows; built before the slice renders. public required IReadOnlyList OverviewRows { get; init; } + + protected override string BreadcrumbCurrentTitle => ApiInfo.Title ?? CurrentNavigationItem.NavigationTitle; } diff --git a/src/Elastic.ApiExplorer/Landing/OperationUrlListModel.cs b/src/Elastic.ApiExplorer/Landing/OperationUrlListModel.cs new file mode 100644 index 0000000000..1d34937e0a --- /dev/null +++ b/src/Elastic.ApiExplorer/Landing/OperationUrlListModel.cs @@ -0,0 +1,9 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.ApiExplorer.Operations; + +namespace Elastic.ApiExplorer.Landing; + +public record OperationUrlListModel(IReadOnlyCollection Operations, string HtmxAttributes); diff --git a/src/Elastic.ApiExplorer/Landing/TagLandingView.cshtml b/src/Elastic.ApiExplorer/Landing/TagLandingView.cshtml index 80a6e938e9..9715400109 100644 --- a/src/Elastic.ApiExplorer/Landing/TagLandingView.cshtml +++ b/src/Elastic.ApiExplorer/Landing/TagLandingView.cshtml @@ -5,6 +5,9 @@ @functions { public ApiLayoutViewModel LayoutModel => Model.CreateGlobalLayoutModel(); } +@{ + var apiHxAttrs = LayoutModel.HxAttributes; +}

@Model.Tag.DisplayName

@if (!string.Equals(Model.Tag.Name, Model.Tag.DisplayName, StringComparison.Ordinal)) @@ -36,13 +39,13 @@ case OverviewRowKind.Endpoint: @(row.Title) - @(await RenderPartialAsync<_OperationUrlList, IReadOnlyCollection>(row.Operations)) + @(await RenderPartialAsync<_OperationUrlList, OperationUrlListModel>(new OperationUrlListModel(row.Operations, apiHxAttrs))) break; case OverviewRowKind.Operation: @(row.Title) - @(await RenderPartialAsync<_OperationUrlList, IReadOnlyCollection>(row.Operations)) + @(await RenderPartialAsync<_OperationUrlList, OperationUrlListModel>(new OperationUrlListModel(row.Operations, apiHxAttrs))) break; } diff --git a/src/Elastic.ApiExplorer/Landing/_Partials/_OperationUrlList.cshtml b/src/Elastic.ApiExplorer/Landing/_Partials/_OperationUrlList.cshtml index 95603d437f..d698d3eee0 100644 --- a/src/Elastic.ApiExplorer/Landing/_Partials/_OperationUrlList.cshtml +++ b/src/Elastic.ApiExplorer/Landing/_Partials/_OperationUrlList.cshtml @@ -1,11 +1,11 @@ -@inherits RazorSlice> +@inherits RazorSlice
    - @foreach (var overload in Model) + @foreach (var overload in Model.Operations) { var method = overload.Model.OperationType.ToString().ToLowerInvariant();
  • - - @method.ToUpperInvariant() + + @(await RenderPartialAsync<_HttpMethodBadge, string>(method)) @overload.Model.Route
  • diff --git a/src/Elastic.ApiExplorer/Model/CodeSample.cs b/src/Elastic.ApiExplorer/Model/CodeSample.cs index 092be1c281..4208790629 100644 --- a/src/Elastic.ApiExplorer/Model/CodeSample.cs +++ b/src/Elastic.ApiExplorer/Model/CodeSample.cs @@ -14,7 +14,7 @@ public record CodeSample(string Language, string Source, string HighlightClass) private static readonly Dictionary LanguageHighlightMap = new(StringComparer.OrdinalIgnoreCase) { ["Console"] = "language-console", - ["curl"] = "language-bash", + ["curl"] = "language-curl", ["Python"] = "language-python", ["JavaScript"] = "language-javascript", ["Ruby"] = "language-ruby", @@ -25,6 +25,20 @@ public record CodeSample(string Language, string Source, string HighlightClass) public static string GetHighlightClass(string language) => LanguageHighlightMap.GetValueOrDefault(language, $"language-{language.ToLowerInvariant()}"); + /// + /// Picks a highlight language for OpenAPI example bodies. Only real JSON objects/arrays + /// use language-json (Figma Card/Code token colors); SSE and other payloads stay plaintext + /// so hljs does not invent misleading token colors. + /// + public static string HighlightClassForExampleBody(string? source) + { + if (string.IsNullOrWhiteSpace(source)) + return "language-plaintext"; + + var span = source.AsSpan().TrimStart(); + return span.Length > 0 && (span[0] == '{' || span[0] == '[') ? "language-json" : "language-plaintext"; + } + /// Maps a hljs language-* class to the outer Myst-style wrapper, e.g. language-json to highlight-json. public static string GetHighlightGroupClass(string? highlightClass) { diff --git a/src/Elastic.ApiExplorer/Model/CurlSourceFormatter.cs b/src/Elastic.ApiExplorer/Model/CurlSourceFormatter.cs new file mode 100644 index 0000000000..751424c972 --- /dev/null +++ b/src/Elastic.ApiExplorer/Model/CurlSourceFormatter.cs @@ -0,0 +1,189 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Elastic.ApiExplorer.Model; + +/// +/// Rewrites single-line curl samples into a readable multi-line form +/// (method + URL on the first line, one flag per subsequent line). +/// +public static class CurlSourceFormatter +{ + private static readonly JsonSerializerOptions PrettyJson = new() { WriteIndented = true }; + + public static string Format(string source) + { + var trimmed = source.Trim(); + if (trimmed.Length == 0) + return source; + + // Already wrapped / multi-line — leave author formatting alone. + if (trimmed.Contains('\n', StringComparison.Ordinal)) + return source; + + if (!trimmed.StartsWith("curl", StringComparison.OrdinalIgnoreCase)) + return source; + + var tokens = Tokenize(trimmed); + if (tokens.Count == 0 || !tokens[0].Equals("curl", StringComparison.OrdinalIgnoreCase)) + return source; + + string? method = null; + string? url = null; + var flags = new List<(string Flag, string? Value)>(); + + for (var i = 1; i < tokens.Count; i++) + { + var token = tokens[i]; + if (IsFlag(token)) + { + var flag = token; + string? value = null; + if (i + 1 < tokens.Count && !IsFlag(tokens[i + 1])) + { + value = tokens[i + 1]; + i++; + } + + if (IsMethodFlag(flag) && value is not null) + method = StripQuotes(value); + else if (IsUrlFlag(flag) && value is not null) + url = value; + else if (IsDataFlag(flag) && value is not null) + flags.Add((flag, PrettyPrintDataArgument(value))); + else + flags.Add((flag, value)); + } + else if (url is null && LooksLikeUrl(token)) + url = token; + else + flags.Add((token, null)); + } + + var sb = new StringBuilder(); + _ = sb.Append("curl"); + if (method is not null) + _ = sb.Append(" -X ").Append(method); + if (url is not null) + _ = sb.Append(' ').Append(EnsureQuoted(url)); + + for (var i = 0; i < flags.Count; i++) + { + var (flag, value) = flags[i]; + _ = sb.Append(" \\\n ").Append(flag); + if (value is not null) + { + if (value.Contains('\n', StringComparison.Ordinal)) + { + // Multi-line -d JSON: put the payload on following indented lines + _ = sb.Append(' ').Append(value); + } + else + _ = sb.Append(' ').Append(value); + } + } + + return sb.ToString(); + } + + private static bool IsFlag(string token) => token.StartsWith('-') && token.Length > 1; + + private static bool IsMethodFlag(string flag) => flag is "-X" or "--request"; + + private static bool IsUrlFlag(string flag) => flag is "--url"; + + private static bool IsDataFlag(string flag) => flag is "-d" or "--data" or "--data-raw" or "--data-binary" or "--data-urlencode"; + + private static bool LooksLikeUrl(string token) + { + var bare = StripQuotes(token); + return bare.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || bare.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || bare.StartsWith('$') // "$ELASTICSEARCH_URL/..." + + || bare.Contains("/_", StringComparison.Ordinal) + || bare.Contains("/.", StringComparison.Ordinal); + } + + private static string StripQuotes(string token) + { + if (token.Length >= 2 && ((token[0] == '"' && token[^1] == '"') || (token[0] == '\'' && token[^1] == '\''))) + return token[1..^1]; + return token; + } + + private static string EnsureQuoted(string token) + { + if (token.Length >= 2 && ((token[0] == '"' && token[^1] == '"') || (token[0] == '\'' && token[^1] == '\''))) + return token; + return $"\"{token}\""; + } + + private static string PrettyPrintDataArgument(string token) + { + var quote = token.Length >= 2 && (token[0] == '\'' || token[0] == '"') ? token[0] : '"'; + var inner = StripQuotes(token); + try + { + var node = JsonNode.Parse(inner); + if (node is null) + return token; + var pretty = node.ToJsonString(PrettyJson); + // Keep the original quote style; indent continuation of the payload. + return quote + pretty.Replace("\n", "\n ", StringComparison.Ordinal) + quote; + } + catch (JsonException) + { + return token; + } + } + + private static List Tokenize(string source) + { + var tokens = new List(); + var i = 0; + while (i < source.Length) + { + while (i < source.Length && char.IsWhiteSpace(source[i])) + i++; + if (i >= source.Length) + break; + + if (source[i] is '"' or '\'') + { + var quote = source[i]; + var start = i; + i++; + while (i < source.Length) + { + if (source[i] == '\\' && i + 1 < source.Length) + { + i += 2; + continue; + } + if (source[i] == quote) + { + i++; + break; + } + i++; + } + tokens.Add(source[start..i]); + } + else + { + var start = i; + while (i < source.Length && !char.IsWhiteSpace(source[i])) + i++; + tokens.Add(source[start..i]); + } + } + + return tokens; + } +} diff --git a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs index be747d10d8..81116b2b0a 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs @@ -145,7 +145,10 @@ public static IReadOnlyList ParseCodeSamples(OpenApiOperation operat if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(source)) continue; - samples.Add(new CodeSample(lang, source, CodeSample.GetHighlightClass(lang))); + var displaySource = string.Equals(lang, "curl", StringComparison.OrdinalIgnoreCase) + ? CurlSourceFormatter.Format(source) + : source; + samples.Add(new CodeSample(lang, displaySource, CodeSample.GetHighlightClass(lang))); } // Console first when present, then preserve spec order diff --git a/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs b/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs index f7f893702d..a07bd53d31 100644 --- a/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs +++ b/src/Elastic.ApiExplorer/Model/SchemaHelpers.cs @@ -22,80 +22,70 @@ public static class SchemaHelpers /// /// Types that are known to be value types (resolve to primitives like string). /// - public static readonly HashSet KnownValueTypes = - [ - with(StringComparer.OrdinalIgnoreCase), - "Field", - "Fields", - "Id", - "Ids", - "IndexName", - "Indices", - "Name", - "Names", - "Routing", - "VersionNumber", - "SequenceNumber", - "PropertyName", - "RelationName", - "TaskId", - "ScrollId", - "SuggestionName", - "Duration", - "DateMath", - "Fuzziness", - "GeoHashPrecision", - "Distance", - "TimeOfDay", - "MinimumShouldMatch", - "Script", - "ByteSize", - "Percentage", - "Stringifiedboolean", - "ExpandWildcards", - "float", - "Stringifiedinteger", - // Numeric value types - "uint", - "ulong", - "long", - "int", - "short", - "ushort", - "byte", - "sbyte", - "double", - "decimal" - ]; + public static readonly HashSet KnownValueTypes = new( + [ + "Field", + "Fields", + "Id", + "Ids", + "IndexName", + "Indices", + "Name", + "Names", + "Routing", + "VersionNumber", + "SequenceNumber", + "PropertyName", + "RelationName", + "TaskId", + "ScrollId", + "SuggestionName", + "Duration", + "DateMath", + "Fuzziness", + "GeoHashPrecision", + "Distance", + "TimeOfDay", + "MinimumShouldMatch", + "Script", + "ByteSize", + "Percentage", + "Stringifiedboolean", + "ExpandWildcards", + "float", + "Stringifiedinteger", + "uint", + "ulong", + "long", + "int", + "short", + "ushort", + "byte", + "sbyte", + "double", + "decimal" + ], + StringComparer.OrdinalIgnoreCase + ); /// /// Types that have dedicated pages we can link to. /// Only container types get their own pages - individual queries/aggregations are rendered inline. /// - public static readonly HashSet LinkedTypes = - [ - with(StringComparer.OrdinalIgnoreCase), - "QueryContainer", - "AggregationContainer", - "Aggregate" - ]; + public static readonly HashSet LinkedTypes = new( + ["QueryContainer", "AggregationContainer", "Aggregate"], + StringComparer.OrdinalIgnoreCase + ); /// /// Primitive/generic type names that are not named schema types. /// These should not be considered for recursive type detection since they /// represent generic types rather than specific schema references. /// - public static readonly HashSet PrimitiveTypeNames = - [ - with(StringComparer.OrdinalIgnoreCase), - "boolean", - "number", - "string", - "integer", - "object", - "null", - "array" - ]; + public static readonly HashSet PrimitiveTypeNames = new( + ["boolean", "number", "string", "integer", "object", "null", "array"], + StringComparer.OrdinalIgnoreCase + ); /// /// Gets the URL for a container type's dedicated page under the given API root diff --git a/src/Elastic.ApiExplorer/Model/SyntheticCodeSamples.cs b/src/Elastic.ApiExplorer/Model/SyntheticCodeSamples.cs new file mode 100644 index 0000000000..eb2bd03b39 --- /dev/null +++ b/src/Elastic.ApiExplorer/Model/SyntheticCodeSamples.cs @@ -0,0 +1,94 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Model; + +/// +/// Builds minimal Console/curl samples from an operation when the OpenAPI document +/// does not declare x-codeSamples, so the examples rail is never empty. +/// +public static class SyntheticCodeSamples +{ + public static IReadOnlyList Create( + HttpMethod method, + string route, + OpenApiOperation operation, + IList? servers + ) + { + var pathWithQuery = BuildPathWithRequiredQuery(route, operation); + var methodLabel = method.Method.ToUpperInvariant(); + + var consoleSource = $"{methodLabel} {pathWithQuery}"; + var curlSource = BuildCurl(methodLabel, pathWithQuery, operation, servers); + + return [ + new CodeSample("Console", consoleSource, CodeSample.GetHighlightClass("Console")), + new CodeSample("curl", CurlSourceFormatter.Format(curlSource), CodeSample.GetHighlightClass("curl")) + ]; + } + + private static string BuildPathWithRequiredQuery(string route, OpenApiOperation operation) + { + var path = string.IsNullOrEmpty(route) ? "/" : route.StartsWith('/') ? route : "/" + route; + + var requiredQuery = (operation.Parameters ?? []) + .Where(static p => p.In == ParameterLocation.Query && p.Required) + .Select(static p => p.Name) + .Where(static name => !string.IsNullOrEmpty(name)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (requiredQuery.Length == 0) + return path; + + var query = string.Join('&', requiredQuery.Select(static name => $"{name}={{{name}}}")); + return $"{path}?{query}"; + } + + private static string BuildCurl(string methodLabel, string pathWithQuery, OpenApiOperation operation, IList? servers) + { + var url = BuildRequestUrl(pathWithQuery, servers); + var sb = new StringBuilder(); + _ = sb.Append("curl -X ").Append(methodLabel).Append(" \"").Append(url).Append('"'); + + foreach (var header in RequiredHeaders(operation)) + _ = sb.Append(" -H \"").Append(header.Name).Append(": ").Append(HeaderExampleValue(header)).Append('"'); + + return sb.ToString(); + } + + private static string BuildRequestUrl(string pathWithQuery, IList? servers) + { + var serverUrl = servers?.FirstOrDefault()?.Url?.Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(serverUrl)) + return pathWithQuery; + + return serverUrl + pathWithQuery; + } + + private static IEnumerable RequiredHeaders(OpenApiOperation operation) => + (operation.Parameters ?? []).Where(static p => p.In == ParameterLocation.Header && p.Required).OrderBy( + static p => p.Name, + StringComparer.OrdinalIgnoreCase + ); + + private static string HeaderExampleValue(IOpenApiParameter header) + { + if (header.Schema?.Example is { } example) + { + var text = example.ToString()?.Trim('"'); + if (!string.IsNullOrEmpty(text)) + return text; + } + + if (string.Equals(header.Name, "kbn-xsrf", StringComparison.OrdinalIgnoreCase)) + return "true"; + + return "string"; + } +} diff --git a/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs index e304f0045a..ea92b3f642 100644 --- a/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs +++ b/src/Elastic.ApiExplorer/Operations/OpenApiXReqAuthParser.cs @@ -11,10 +11,48 @@ namespace Elastic.ApiExplorer.Operations; +/// One Prerequisites row: a mono label plus optional type-style badge (from Label: `value`). +public sealed record PrerequisiteRow(string Label, string? Badge); + public static class OpenApiXReqAuthParser { public const string ExtensionKey = "x-req-auth"; + public static IReadOnlyList? TryGetPrerequisiteRows( + OpenApiOperation operation, + ILogger? log, + string? route, + string? operationId + ) + { + var lines = TryGetPrerequisiteLines(operation, log, route, operationId); + if (lines is null) + return null; + + var rows = new List(lines.Count); + foreach (var line in lines) + rows.Add(ParsePrerequisiteRow(line)); + return rows; + } + + internal static PrerequisiteRow ParsePrerequisiteRow(string line) + { + var trimmed = line.Trim(); + var colon = trimmed.IndexOf(':'); + if (colon <= 0) + return new PrerequisiteRow(trimmed, null); + + var rest = trimmed[(colon + 1)..].Trim(); + if (rest.Length < 3 || rest[0] != '`' || rest[^1] != '`') + return new PrerequisiteRow(trimmed, null); + + var badge = rest[1..^1]; + if (badge.Length == 0 || badge.Contains('`', StringComparison.Ordinal)) + return new PrerequisiteRow(trimmed, null); + + return new PrerequisiteRow(trimmed[..colon].Trim(), badge); + } + public static IReadOnlyList? TryGetPrerequisiteLines( OpenApiOperation operation, ILogger? log, diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index b6c2709124..fcdaf136b4 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -19,8 +19,10 @@ public record ApiOperation( string Route, IOpenApiPathItem Path, string ApiName -) : IApiModel +) : IApiModel, IHttpMethodNavigationModel { + string IHttpMethodNavigationModel.HttpMethod => OperationType.Method.ToLowerInvariant(); + public async Task RenderAsync(FileSystemStream stream, ApiRenderContext context, Cancel ctx = default) { var viewModel = new OperationViewModel(context) { Operation = this, Page = OperationPageModel.Create(this, context) }; diff --git a/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs b/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs index 1e5f144934..4b4ea0721d 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationPageModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Elastic.ApiExplorer.Components.PropertyTree; using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Landing; @@ -15,10 +16,55 @@ namespace Elastic.ApiExplorer.Operations; /// A request/response example with its markdown description prerendered. -public record ExampleDisplay(string Title, HtmlString? DescriptionHtml, string? JsonValue, string? ExternalValue); +public record ExampleDisplay( + string Title, + HtmlString? DescriptionHtml, + string? JsonValue, + string? ExternalValue, + string? StatusCode = null +); + +/// One response body example tagged with its HTTP status code for the examples rail. +public record ExampleResponse +{ + public required string StatusCode { get; init; } + public string? JsonValue { get; init; } + public string? ExternalValue { get; init; } -/// Model for the _ExamplesSection partial. -public record ExamplesSection(SectionHeader Header, IReadOnlyList Examples); + /// + /// When true, the OpenAPI response declares no content (e.g. 204). The rail shows + /// "No body" instead of "No example". + /// + public bool IsNoBody { get; init; } + + public bool HasExampleBody => JsonValue is not null || !string.IsNullOrEmpty(ExternalValue); +} + +/// +/// One named example scenario for the right rail: optional multi-language code samples, +/// request body, and one or more response bodies (by status code) grouped under a shared title. +/// +public record ExampleScenario +{ + public required string Title { get; init; } + public required string TabId { get; init; } + public HtmlString? DescriptionHtml { get; init; } + public string? RequestJson { get; init; } + public string? RequestExternalValue { get; init; } + public IReadOnlyList Responses { get; init; } = []; + public IReadOnlyList CodeSamples { get; init; } = []; + + /// Request JSON is omitted when code samples already embed the request body. + public bool ShowRequest => (RequestJson is not null || !string.IsNullOrEmpty(RequestExternalValue)) && CodeSamples.Count == 0; + + public bool ShowResponse => Responses.Count > 0; +} + +/// Right-rail examples panel for operation pages (Scalar-style layout). +public record OperationExamplesPanelModel +{ + public required IReadOnlyList Scenarios { get; init; } +} /// A query string parameter with its structural display data precomputed. public record ApiQueryParameter @@ -30,16 +76,17 @@ public record ApiQueryParameter public required IReadOnlyList Constraints { get; init; } public required IReadOnlyList EnumValues { get; init; } public required IReadOnlyList UnionOptions { get; init; } - public required HtmlString DescriptionHtml { get; init; } + public required string? Description { get; init; } } -/// A path parameter with its effective description precomputed. +/// A path parameter with its effective description after supplemental overrides. public record ApiPathParameter { public required IOpenApiParameter Parameter { get; init; } - public required HtmlString DescriptionHtml { get; init; } + public required string? Description { get; init; } public string? Name => Parameter.Name; + public bool Required => Parameter.Required; public bool? Deprecated => Parameter.Deprecated; } @@ -79,13 +126,14 @@ public record ApiResponse /// Everything structural an operation page renders, precomputed before the view runs. /// Scalar values (summary, descriptions, parameter names) are read off the raw operation in the view. /// -public record OperationPageModel +public partial record OperationPageModel { public required AvailabilityBadgeData? Availability { get; init; } public required bool IsBeta { get; init; } public required ExternalDocLink? ExternalDocs { get; init; } public required IList? Servers { get; init; } public required IReadOnlyCollection Overloads { get; init; } + public bool HasMultipleOverloads => Overloads.Count > 1; public required IReadOnlyList PathParameters { get; init; } public required IReadOnlyList QueryParameters { get; init; } public required string? DescriptionMarkdown { get; init; } @@ -94,13 +142,9 @@ public record OperationPageModel public required ApiPropertyList? RequestProperties { get; init; } public required TypeAnnotation? RequestType { get; init; } public required IReadOnlyList Responses { get; init; } - public required IReadOnlyList CodeSamples { get; init; } - public required IReadOnlyList RequestExamples { get; init; } - public required IReadOnlyList ResponseExamples { get; init; } - public required bool ShowRequestExamples { get; init; } - public required bool ShowResponseExamples { get; init; } + public required IReadOnlyList Scenarios { get; init; } - /// Anchor of the first examples section; null when the page has no examples at all. + /// Anchor of the examples rail; null when the page has no examples at all. public required string? ExamplesAnchor { get; init; } public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderContext context) @@ -119,15 +163,14 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont var builder = new ApiPropertyTreeBuilder(document, options); var codeSamples = OpenApiExtensionReader.ParseCodeSamples(operation); - var requestExamples = operation.RequestBody?.Content?.FirstOrDefault().Value?.Examples; - var successResponse = operation.Responses?.FirstOrDefault(r => r.Key.StartsWith('2')).Value; - var responseExamples = successResponse?.Content?.FirstOrDefault().Value?.Examples; + var servers = operation.Servers is { Count: > 0 } ? operation.Servers : document.Servers; + if (codeSamples.Count == 0) + codeSamples = SyntheticCodeSamples.Create(apiOperation.OperationType, apiOperation.Route, operation, servers); - var showRequestExamples = requestExamples is { Count: > 0 } && !(requestExamples.Count == 1 && codeSamples.Count > 0); - var showResponseExamples = responseExamples is { Count: > 0 }; - var examplesAnchor = codeSamples.Count > 0 - ? "code-examples" - : requestExamples is { Count: > 0 } ? "request-examples" : responseExamples is { Count: > 0 } ? "response-examples" : null; + var requestExamples = MapExamples(operation.RequestBody?.Content?.FirstOrDefault().Value?.Examples, options.RenderMarkdown); + var responseExamples = MapResponseExamples(operation.Responses, options.RenderMarkdown); + var scenarios = EnsureResponseTabs(BuildExampleScenarios(requestExamples, responseExamples, codeSamples), operation.Responses); + var examplesAnchor = scenarios.Count > 0 ? "examples" : null; var requestContentEntry = operation.RequestBody?.Content?.FirstOrDefault(); var requestSchema = requestContentEntry?.Value?.Schema; @@ -144,7 +187,7 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont Availability = AvailabilityBadgeHelper.FromOperation(operation, context.BuildContext.VersionsConfiguration), IsBeta = OpenApiExtensionReader.IsBeta(operation), ExternalDocs = externalDocs, - Servers = operation.Servers is { Count: > 0 } ? operation.Servers : document.Servers, + Servers = servers, Overloads = ResolveOverloads(context), PathParameters = (operation.Parameters ?? []) .Where(p => p.In == ParameterLocation.Path) @@ -152,17 +195,16 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont p => new ApiPathParameter { Parameter = p, - DescriptionHtml = ApiMarkdown.Render( - context, - supplemental?.ParameterOr(p.Name ?? "", p.Description) ?? p.Description - ) + Description = supplemental?.ParameterOr(p.Name ?? "", p.Description) ?? p.Description } ) .ToArray(), QueryParameters = (operation.Parameters ?? []) .Where(p => p.In == ParameterLocation.Query) - .Select(p => BuildQueryParameter(p, analyzer, builder, context, supplemental)) + .Select(p => BuildQueryParameter(p, analyzer, builder, supplemental)) .ToArray(), + DescriptionMarkdown = supplemental?.DescriptionOr(operation.Description) ?? operation.Description, + PostSections = ApiPostSection.From(context, supplemental?.PostSections ?? []), RequestContentType = requestContentEntry?.Key ?? "application/json", RequestProperties = requestSchema is not null ? builder.BuildPropertyList( @@ -170,33 +212,357 @@ public static OperationPageModel Create(ApiOperation apiOperation, ApiRenderCont new PropertyTreeScope { Prefix = "req", IsRequest = true, DescriptionOverrides = supplemental?.RequestBodyOverrides } ) : null, - DescriptionMarkdown = supplemental?.DescriptionOr(operation.Description) ?? operation.Description, - PostSections = ApiPostSection.From(context, supplemental?.PostSections ?? []), RequestType = requestSchema is not null ? builder.Describe(requestSchema) : null, Responses = BuildResponses(operation, analyzer, builder), - CodeSamples = codeSamples, - RequestExamples = MapExamples(requestExamples, options.RenderMarkdown), - ResponseExamples = MapExamples(responseExamples, options.RenderMarkdown), - ShowRequestExamples = showRequestExamples, - ShowResponseExamples = showResponseExamples, + Scenarios = scenarios, ExamplesAnchor = examplesAnchor }; } + /// + /// Groups OpenAPI examples into rail scenarios: + /// + /// Request examples define scenario variants (the rail select). + /// Response examples whose title matches a request join that scenario. + /// Unmatched response examples (typical error statuses) are shared across + /// those request scenarios as extra status-code tabs, without overwriting a + /// scenario-specific body for the same status. + /// When there are no request examples, responses are grouped by title and + /// then collapsed into a single scenario so status tabs stay primary. + /// + /// Multi-language x-codeSamples attach to the scenario whose request body + /// matches the Console sample (or the first / a code-only scenario). + /// + public static IReadOnlyList BuildExampleScenarios( + IReadOnlyList requestExamples, + IReadOnlyList responseExamples, + IReadOnlyList codeSamples + ) + { + var scenarios = new List(); + var indexByTitle = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var example in requestExamples) + UpsertScenario(scenarios, indexByTitle, example, isRequest: true); + + var hasRequestScenarios = scenarios.Count > 0; + var sharedResponses = new List(); + + foreach (var example in responseExamples) + { + if (hasRequestScenarios && !indexByTitle.ContainsKey(example.Title)) + { + sharedResponses.Add(example); + continue; + } + + UpsertScenario(scenarios, indexByTitle, example, isRequest: false); + } + + if (hasRequestScenarios && sharedResponses.Count > 0) + { + for (var i = 0; i < scenarios.Count; i++) + scenarios[i] = scenarios[i] with { Responses = MergeSharedResponses(scenarios[i].Responses, sharedResponses) }; + } + else if (!hasRequestScenarios && scenarios.Count > 1) + scenarios = CollapseIntoSingleScenario(scenarios); + + if (codeSamples.Count == 0) + return scenarios; + + if (scenarios.Count == 0) + { + scenarios.Add(new ExampleScenario { Title = "Examples", TabId = "examples", CodeSamples = codeSamples }); + return scenarios; + } + + var matchIndex = FindScenarioForCodeSamples(scenarios, codeSamples); + scenarios[matchIndex] = scenarios[matchIndex] with { CodeSamples = codeSamples }; + return scenarios; + } + + /// + /// Adds shared (title-unmatched) response examples as status tabs, skipping any + /// status the scenario already owns so request-paired bodies win. + /// + private static IReadOnlyList MergeSharedResponses( + IReadOnlyList existing, + IReadOnlyList shared + ) + { + var merged = existing; + foreach (var example in shared) + { + var statusCode = string.IsNullOrEmpty(example.StatusCode) ? "default" : example.StatusCode; + if (merged.Any(r => string.Equals(r.StatusCode, statusCode, StringComparison.OrdinalIgnoreCase))) + continue; + merged = UpsertResponse(merged, example); + } + + return merged; + } + + /// + /// Response-only operations often name each status differently; fold them into one + /// scenario so the rail exposes status tabs instead of a scenario select. + /// + private static List CollapseIntoSingleScenario(List scenarios) + { + var responses = new List(); + foreach (var scenario in scenarios) + { + foreach (var response in scenario.Responses) + { + var alreadyPresent = responses.Any( + r => string.Equals(r.StatusCode, response.StatusCode, StringComparison.OrdinalIgnoreCase) + ); + if (alreadyPresent) + continue; + responses.Add(response); + } + } + + var ordered = responses.OrderBy(r => StatusSortKey(r.StatusCode)).ThenBy(r => r.StatusCode, StringComparer.Ordinal).ToArray(); + + return [ + new ExampleScenario + { + Title = scenarios[0].Title, + TabId = scenarios[0].TabId, + DescriptionHtml = scenarios[0].DescriptionHtml, + Responses = ordered + } + ]; + } + + private static void UpsertScenario( + List scenarios, + Dictionary indexByTitle, + ExampleDisplay example, + bool isRequest + ) + { + if (indexByTitle.TryGetValue(example.Title, out var index)) + { + var existing = scenarios[index]; + scenarios[index] = isRequest + ? existing with + { + DescriptionHtml = existing.DescriptionHtml ?? example.DescriptionHtml, + RequestJson = example.JsonValue, + RequestExternalValue = example.ExternalValue + } + : existing with + { + DescriptionHtml = existing.DescriptionHtml ?? example.DescriptionHtml, + Responses = UpsertResponse(existing.Responses, example) + }; + return; + } + + indexByTitle[example.Title] = scenarios.Count; + scenarios.Add( + isRequest + ? new ExampleScenario + { + Title = example.Title, + TabId = ToTabId(example.Title, scenarios.Count), + DescriptionHtml = example.DescriptionHtml, + RequestJson = example.JsonValue, + RequestExternalValue = example.ExternalValue + } + : new ExampleScenario + { + Title = example.Title, + TabId = ToTabId(example.Title, scenarios.Count), + DescriptionHtml = example.DescriptionHtml, + Responses = UpsertResponse([], example) + } + ); + } + + private static IReadOnlyList UpsertResponse(IReadOnlyList existing, ExampleDisplay example) + { + var statusCode = string.IsNullOrEmpty(example.StatusCode) ? "default" : example.StatusCode; + var next = new ExampleResponse { StatusCode = statusCode, JsonValue = example.JsonValue, ExternalValue = example.ExternalValue }; + var list = existing.ToList(); + var index = list.FindIndex(r => string.Equals(r.StatusCode, statusCode, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + list[index] = next; + else + list.Add(next); + + return list.OrderBy(r => StatusSortKey(r.StatusCode)).ThenBy(r => r.StatusCode, StringComparer.Ordinal).ToArray(); + } + + private static int StatusSortKey(string statusCode) => + statusCode.Length > 0 && statusCode[0] == '2' + ? 0 + : statusCode.Length > 0 && statusCode[0] == '3' + ? 1 + : statusCode.Length > 0 && statusCode[0] == '4' ? 2 : statusCode.Length > 0 && statusCode[0] == '5' ? 3 : 4; + + private static int FindScenarioForCodeSamples(IReadOnlyList scenarios, IReadOnlyList codeSamples) + { + var probe = codeSamples.FirstOrDefault(static s => string.Equals(s.Language, "Console", StringComparison.OrdinalIgnoreCase)) + ?? codeSamples[0]; + var compactProbe = Compact(probe.Source); + + for (var i = 0; i < scenarios.Count; i++) + { + if (scenarios[i].RequestJson is not { Length: > 0 } requestJson) + continue; + var compactRequest = Compact(requestJson); + if (compactRequest.Length == 0) + continue; + if (compactProbe.Contains(compactRequest, StringComparison.Ordinal)) + return i; + } + + return 0; + } + + private static string Compact(string value) => string.Concat(value.Where(static c => !char.IsWhiteSpace(c))); + + private static string ToTabId(string title, int index) + { + var chars = title.Trim().ToLowerInvariant().Select(static c => char.IsLetterOrDigit(c) ? c : '-').ToArray(); + var slug = new string(chars).Trim('-'); + while (slug.Contains("--", StringComparison.Ordinal)) + slug = slug.Replace("--", "-", StringComparison.Ordinal); + return string.IsNullOrEmpty(slug) ? $"scenario-{index}" : slug; + } + + /// + /// When scenarios have request/code samples but no response example bodies, attach + /// status-code tabs from the operation's declared responses so the rail still shows + /// "No body" / "No example" instead of omitting the response card. + /// + public static IReadOnlyList EnsureResponseTabs(IReadOnlyList scenarios, OpenApiResponses? responses) + { + if (scenarios.Count == 0 || responses is null || responses.Count == 0) + return scenarios; + + if (scenarios.Any(static s => s.Responses.Count > 0)) + return scenarios; + + var fallback = BuildStatusOnlyResponses(responses); + if (fallback.Count == 0) + return scenarios; + + return scenarios.Select(s => s with { Responses = fallback }).ToArray(); + } + + private static IReadOnlyList BuildStatusOnlyResponses(OpenApiResponses responses) => + responses + .Where(static pair => pair.Value is not null) + .Select( + static pair => new ExampleResponse + { + StatusCode = pair.Key, + IsNoBody = pair.Value.Content is null || pair.Value.Content.Count == 0 + } + ) + .OrderBy(static r => StatusSortKey(r.StatusCode)) + .ThenBy(static r => r.StatusCode, StringComparer.Ordinal) + .ToArray(); + + private static IReadOnlyList MapResponseExamples(OpenApiResponses? responses, Func renderMarkdown) + { + if (responses is null || responses.Count == 0) + return []; + + var list = new List(); + foreach (var (statusCode, response) in responses) + { + var examples = response?.Content?.FirstOrDefault().Value?.Examples; + foreach (var example in MapExamples(examples, renderMarkdown, statusCode)) + list.Add(example); + } + + return list; + } + private static IReadOnlyList MapExamples( IDictionary? examples, - Func renderMarkdown + Func renderMarkdown, + string? statusCode = null ) => examples is null ? [] - : examples.Select( - e => new ExampleDisplay( + : examples.Select(e => + { + var description = SanitizeExampleDescription(e.Value?.Description); + return new ExampleDisplay( string.IsNullOrEmpty(e.Value?.Summary) ? e.Key : e.Value.Summary, - string.IsNullOrEmpty(e.Value?.Description) ? null : renderMarkdown(e.Value.Description), + string.IsNullOrEmpty(description) ? null : renderMarkdown(description), e.Value?.Value?.ToString(), - string.IsNullOrEmpty(e.Value?.ExternalValue) ? null : e.Value.ExternalValue - ) - ).ToArray(); + string.IsNullOrEmpty(e.Value?.ExternalValue) ? null : e.Value.ExternalValue, + statusCode + ); + }).ToArray(); + + /// + /// Drops leading boilerplate that only restates the HTTP call or a generic success line + /// already visible in code samples. Keeps any trailing notes. + /// + public static string? SanitizeExampleDescription(string? description) + { + if (string.IsNullOrWhiteSpace(description)) + return null; + + var trimmed = description.Trim(); + while (true) + { + var runCommand = RunCommandBoilerplate().Match(trimmed); + if (runCommand.Success) + { + trimmed = trimmed[runCommand.Length..].TrimStart(); + continue; + } + + var successFrom = SuccessfulResponseFromBoilerplate().Match(trimmed); + if (successFrom.Success) + { + trimmed = trimmed[successFrom.Length..].TrimStart(); + continue; + } + + var exampleBody = ExampleBodyForRequestBoilerplate().Match(trimmed); + if (exampleBody.Success) + { + trimmed = trimmed[exampleBody.Length..].TrimStart(); + continue; + } + + var abbreviatedFrom = AbbreviatedResponseFromBoilerplate().Match(trimmed); + if (abbreviatedFrom.Success) + { + trimmed = trimmed[abbreviatedFrom.Length..].TrimStart(); + continue; + } + + break; + } + + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + /// Matches Run `…` …. instructional openers from elasticsearch-specification examples. + [GeneratedRegex(@"^Run\s+`[^`]+`\s+[^.]*\.\s*", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex RunCommandBoilerplate(); + + /// Matches A successful response from `METHOD path`. openers that only echo the call. + [GeneratedRegex(@"^A\s+successful\s+response\s+from\s+`[^`]+`\.\s*", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex SuccessfulResponseFromBoilerplate(); + + /// Matches An example body for a `METHOD path` request. openers that only label the JSON body. + [GeneratedRegex(@"^An\s+example\s+body\s+for\s+a\s+`[^`]+`\s+request\.\s*", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex ExampleBodyForRequestBoilerplate(); + + /// Matches An abbreviated response from `METHOD path`. openers that only echo the call. + [GeneratedRegex(@"^An\s+abbreviated\s+response\s+from\s+`[^`]+`\.\s*", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex AbbreviatedResponseFromBoilerplate(); private static IReadOnlyCollection ResolveOverloads(ApiRenderContext context) { @@ -212,7 +578,6 @@ private static ApiQueryParameter BuildQueryParameter( IOpenApiParameter parameter, SchemaAnalyzer analyzer, ApiPropertyTreeBuilder builder, - ApiRenderContext context, ApiSupplementalDoc? supplemental ) { @@ -226,10 +591,7 @@ private static ApiQueryParameter BuildQueryParameter( UnionOptions = CollectUnionOptionNames(schema, analyzer) .Select(n => new UnionBadge(n, ApiPropertyTreeBuilder.IsTypeOptionBadge(n))) .ToArray(), - DescriptionHtml = ApiMarkdown.Render( - context, - supplemental?.ParameterOr(parameter.Name ?? "", parameter.Description) ?? parameter.Description - ) + Description = supplemental?.ParameterOr(parameter.Name ?? "", parameter.Description) ?? parameter.Description }; } diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 62579f3b58..ac474b6ae3 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -7,25 +7,26 @@ @{ var operation = Model.Operation.Operation; var pageModel = Model.Page; + var endpointListingAttrs = pageModel.HasMultipleOverloads + ? new HtmlString(" role=\"radiogroup\" aria-label=\"Endpoints\"") + : HtmlString.Empty; } -
    -

    - @operation.Summary - @if (operation.Deprecated) - { - deprecated - } - @if (pageModel.IsBeta) - { - Beta - } -

    - @if (pageModel.Availability is not null) +@* Intro spans both columns (title + paths); reference column starts below. *@ +
    +

    @operation.Summary

    + @if (operation.Deprecated || pageModel.IsBeta) { -

    - @(await RenderPartialAsync<_AppliesToBadge, AppliesToBadgeDisplay>(new AppliesToBadgeDisplay(pageModel.Availability, IsInline: false))) -

    +
    + @if (operation.Deprecated) + { + deprecated + } + @if (pageModel.IsBeta) + { + Beta + } +
    } @if (pageModel.Servers is { Count: > 0 }) { @@ -44,41 +45,85 @@ } - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Paths", "paths", Model.Operation.Route))) -
      +
        @foreach (var overload in pageModel.Overloads) { var method = overload.Model.OperationType.ToString().ToLowerInvariant(); - var current = overload.Model.Route == Model.Operation.Route && overload.Model.OperationType == Model.Operation.OperationType ? "current" : ""; + var isCurrent = overload.Model.Route == Model.Operation.Route && overload.Model.OperationType == Model.Operation.OperationType; var isDeprecated = overload.Model.Operation?.Deprecated == true; -
      • - - @method.ToUpperInvariant() - @overload.Model.Route - @if (isDeprecated) - { - deprecated - } - +
      • +
      • }
      +
    +
    @{ - var prerequisiteLines = Model.RequiredAuthItems; + var prerequisites = Model.Prerequisites; } - @if (prerequisiteLines is { Count: > 0 }) + @if (!string.IsNullOrWhiteSpace(pageModel.DescriptionMarkdown)) { - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Prerequisites", "prerequisites", Model.Operation.Route))) -
      - @foreach (var line in prerequisiteLines) +
      + @(Model.RenderMarkdown(pageModel.DescriptionMarkdown)) + @if (pageModel.ExternalDocs is not null) + { + + @pageModel.ExternalDocs.LinkText + + } +
      + } + else if (pageModel.ExternalDocs is not null) + { + + @pageModel.ExternalDocs.LinkText + + } + @if (prerequisites is { Count: > 0 }) + { + @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Prerequisites", "prerequisites"))) +
      + @foreach (var row in prerequisites) { -
    • @Model.RenderMarkdown(line)
    • +
      +
      + @if (row.Badge is not null) + { + @row.Label + @row.Badge + } + else + { + @Model.RenderMarkdown(row.Label) + } +
      +
      } -
    + } @if (pageModel.PathParameters.Count > 0) { -

    Path Parameters

    + @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Parameters", "parameters")))
    @foreach (var path in pageModel.PathParameters) { @@ -86,38 +131,22 @@
    @path.Name + @if (path.Required) + { + required + } @if (path.Deprecated is true) { deprecated }
    -
    @path.DescriptionHtml
    +
    @Model.RenderMarkdown(path.Description)
    }
    } - @if (!string.IsNullOrWhiteSpace(pageModel.DescriptionMarkdown)) - { - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Description", "description", Model.Operation.Route))) -

    - @(Model.RenderMarkdown(pageModel.DescriptionMarkdown)) -

    - @if (pageModel.ExternalDocs is not null) - { - - @pageModel.ExternalDocs.LinkText - - } - } - else if (pageModel.ExternalDocs is not null) - { - - @pageModel.ExternalDocs.LinkText - - } - @if (operation.Security is { Count: > 0 }) {
    @@ -140,7 +169,7 @@ @if (pageModel.QueryParameters.Count > 0) { - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Query String Parameters", "query-params", Model.Operation.Route))) + @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Query Parameters", "query-params")))
    @foreach (var qp in pageModel.QueryParameters) { @@ -153,13 +182,17 @@ { @(await RenderPartialAsync<_SchemaType, TypeAnnotation>(qp.Type)) } + @if (qs.Required) + { + required + } @if (qs.Deprecated == true) { deprecated } -
    @qp.DescriptionHtml
    +
    @Model.RenderMarkdown(qp.Description)
    @if (qp.Type is not null) { @(await RenderPartialAsync<_ValidationConstraints, IReadOnlyList>(qp.Constraints)) @@ -195,7 +228,7 @@ } @if (operation.RequestBody is not null) { - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Request Body", "request-body", Model.Operation.Route, pageModel.RequestContentType))) + @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader("Request", "request-body", ContentTypeBadge: pageModel.RequestContentType))) if (!string.IsNullOrEmpty(operation.RequestBody.Description)) {

    @Model.RenderMarkdown(operation.RequestBody.Description)

    @@ -214,129 +247,130 @@ @if (pageModel.Responses.Count > 0) { var isSingleResponse = pageModel.Responses.Count == 1; - var singleContentType = isSingleResponse ? pageModel.Responses[0].FirstContentType : null; - - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader(isSingleResponse ? "Response" : "Responses", "responses", Model.Operation.Route, singleContentType))) - @foreach (var response in pageModel.Responses) - { -
    - @if (!isSingleResponse) - { -

    - @response.StatusCode - @if (!string.IsNullOrEmpty(response.Response.Description)) - { - @response.Response.Description - } -

    - } - @if (response.Contents.Count > 0) - { - foreach (var content in response.Contents) + var selectedResponse = pageModel.Responses[0]; + var selectedContentType = selectedResponse.FirstContentType; +
    +

    + Response + + @if (isSingleResponse) { - @if (!isSingleResponse) - { -

    Content-Type: @content.ContentType

    - } - - if (content.Properties is not null) - { - @(await RenderPartialAsync<_PropertyList, ApiPropertyList>(content.Properties)) - } - else if (content.ArrayItemProperties is not null) - { - // Array of objects - show the type annotation and render item properties -

    Response Type: @(await RenderPartialAsync<_SchemaType, TypeAnnotation>(content.Type))

    - @(await RenderPartialAsync<_PropertyList, ApiPropertyList>(content.ArrayItemProperties)) - } - else - { -

    Response Type: @(await RenderPartialAsync<_SchemaType, TypeAnnotation>(content.Type))

    - } + @selectedResponse.StatusCode } - } - @if (response.Headers.Count > 0) - { -
    -
    Response Headers
    -
    - @foreach (var header in response.Headers) + else + { + + } + + +

    + @for (var i = 0; i < pageModel.Responses.Count; i++) { - var sample = codeSamples[i]; - var tabId = $"code-sample-{i}"; - - -
    - @(await RenderPartialAsync<_ApiCodeBlock, ApiCodeBlockModel>(new ApiCodeBlockModel(sample.HighlightClass, sample.Source))) + var response = pageModel.Responses[i]; + var fieldsId = $"response-{response.StatusCode}-fields"; + }
    } - @if (pageModel.ShowRequestExamples) - { - @(await RenderPartialAsync<_ExamplesSection, ExamplesSection>(new ExamplesSection(new SectionHeader("Request Examples", "request-examples", Model.Operation.Route), pageModel.RequestExamples))) - } - @if (pageModel.ShowResponseExamples) - { - @(await RenderPartialAsync<_ExamplesSection, ExamplesSection>(new ExamplesSection(new SectionHeader("Response Examples", "response-examples", Model.Operation.Route), pageModel.ResponseExamples))) - } @foreach (var extra in pageModel.PostSections) { - @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader(extra.Heading, extra.Anchor, Model.Operation.Route))) + @(await RenderPartialAsync<_SectionHeader, SectionHeader>(new SectionHeader(extra.Heading, extra.Anchor)))
    @extra.BodyHtml
    } - @if (pageModel.ExamplesAnchor != null) - { - - - Examples - - }
    diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs index e2b476b753..8414e6f056 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs @@ -15,46 +15,42 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context /// Precomputed structural content of the page; built before the slice renders. public required OperationPageModel Page { get; init; } - public IReadOnlyList? RequiredAuthItems => - OpenApiXReqAuthParser.TryGetPrerequisiteLines( + public IReadOnlyList? Prerequisites => + OpenApiXReqAuthParser.TryGetPrerequisiteRows( Operation.Operation, RenderContext.ApiExplorerLog, Operation.Route, Operation.Operation.OperationId ); + protected override string BreadcrumbCurrentTitle => Operation.Operation.Summary ?? CurrentNavigationItem.NavigationTitle; + protected override IReadOnlyList GetTocItems() { var operation = Operation.Operation; var tocItems = new List { new("Paths", "paths") }; - if (RequiredAuthItems is { Count: > 0 }) + if (Prerequisites is { Count: > 0 }) tocItems.Add(new ApiTocItem("Prerequisites", "prerequisites")); - if (!string.IsNullOrWhiteSpace(Page.DescriptionMarkdown)) - tocItems.Add(new ApiTocItem("Description", "description")); - if (Page.QueryParameters.Count > 0) - tocItems.Add(new ApiTocItem("Query String Parameters", "query-params")); + tocItems.Add(new ApiTocItem("Query Parameters", "query-params")); if (operation.RequestBody is not null) - tocItems.Add(new ApiTocItem("Request Body", "request-body")); + tocItems.Add(new ApiTocItem("Request", "request-body")); if (operation.Responses is { Count: > 0 }) tocItems.Add(new ApiTocItem(operation.Responses.Count == 1 ? "Response" : "Responses", "responses")); - if (Page.CodeSamples.Count > 0) - tocItems.Add(new ApiTocItem("Code Examples", "code-examples")); - - if (Page.ShowRequestExamples) - tocItems.Add(new ApiTocItem("Request Examples", "request-examples")); - - if (Page.ShowResponseExamples) - tocItems.Add(new ApiTocItem("Response Examples", "response-examples")); + return tocItems; + } - foreach (var section in Page.PostSections) - tocItems.Add(new ApiTocItem(section.Heading, section.Anchor)); + public new ApiLayoutViewModel CreateGlobalLayoutModel() + { + var layout = base.CreateGlobalLayoutModel(); + if (Page.ExamplesAnchor is null) + return layout; - return tocItems; + return layout with { ExamplesPanel = new OperationExamplesPanelModel { Scenarios = Page.Scenarios } }; } } diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_EndpointCopyButton.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_EndpointCopyButton.cshtml new file mode 100644 index 0000000000..132f1a9491 --- /dev/null +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_EndpointCopyButton.cshtml @@ -0,0 +1,11 @@ +@inherits RazorSlice + diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_ExampleScenarioContent.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_ExampleScenarioContent.cshtml new file mode 100644 index 0000000000..7ebc970381 --- /dev/null +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_ExampleScenarioContent.cshtml @@ -0,0 +1,72 @@ +@using Elastic.ApiExplorer.Model +@using Elastic.ApiExplorer.Operations +@inherits RazorSlice +@* Example OpenAPI descriptions are omitted from the rail — they often restate the call + or narrate the sample; the code blocks carry the useful content. *@ + +@* Two independently styleable blocks in the rail: + 1. .api-code-sample — multi-language x-codeSamples, or fallback request JSON + 2. .example-block--response — response JSON (status-code tabs) *@ +@if (Model.CodeSamples.Count > 0) +{ + @(await RenderPartialAsync<_ApiCodeSample, ApiCodeSampleModel>(new ApiCodeSampleModel($"rail-{Model.TabId}", Model.CodeSamples))) +} +else if (Model.ShowRequest && Model.RequestJson is not null) +{ + var requestSamples = new[] + { + new CodeSample("JSON", Model.RequestJson, "language-json") + }; + @(await RenderPartialAsync<_ApiCodeSample, ApiCodeSampleModel>(new ApiCodeSampleModel($"rail-{Model.TabId}-request", requestSamples))) +} + +@if (Model.ShowRequest && !string.IsNullOrEmpty(Model.RequestExternalValue)) +{ +

    External example: @Model.RequestExternalValue

    +} + +@if (Model.ShowResponse) +{ + var responses = Model.Responses; +
    +
    +
    + @for (var i = 0; i < responses.Count; i++) + { + var response = responses[i]; + var selected = i == 0; + + } +
    +
    +
    +
    + @for (var i = 0; i < responses.Count; i++) + { + var response = responses[i]; + + } +
    +
    +} diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_ExamplesSection.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_ExamplesSection.cshtml deleted file mode 100644 index 1bc868cc02..0000000000 --- a/src/Elastic.ApiExplorer/Operations/_Partials/_ExamplesSection.cshtml +++ /dev/null @@ -1,21 +0,0 @@ -@using Elastic.ApiExplorer.Operations -@inherits RazorSlice -@(await RenderPartialAsync<_SectionHeader, SectionHeader>(Model.Header)) -@foreach (var example in Model.Examples) -{ -
    -

    @example.Title

    - @if (example.DescriptionHtml is not null) - { -
    @example.DescriptionHtml
    - } - @if (example.JsonValue is not null) - { - @(await RenderPartialAsync<_ApiCodeBlock, ApiCodeBlockModel>(new ApiCodeBlockModel("language-json", example.JsonValue))) - } - @if (!string.IsNullOrEmpty(example.ExternalValue)) - { -

    External example: @example.ExternalValue

    - } -
    -} \ No newline at end of file diff --git a/src/Elastic.ApiExplorer/_Layout.cshtml b/src/Elastic.ApiExplorer/_Layout.cshtml index 222a9f1d01..476285bb9a 100644 --- a/src/Elastic.ApiExplorer/_Layout.cshtml +++ b/src/Elastic.ApiExplorer/_Layout.cshtml @@ -11,23 +11,36 @@ else { @(await RenderPartialAsync(_IsolatedHeader.Create(Model))) } -
    -
    -
    -
    -
    -
    - - @await RenderBodyAsync() -
    -
    +@* API content grid + examples rail. Sidebar is _ApiPagesNav (main), not Nav V2 _PagesNav. *@ +
    +
    +
    + @* Two-column mode is driven by CSS :has(#api-examples-panel.api-examples-panel). *@ + @* display:grid at ≥1024px is set in api-docs.css (:has examples) — do not use lg:grid (1280). *@ +
    +
    +
    +
    + + @(await RenderPartialAsync<_ApiBreadcrumbs, ApiBreadcrumbsView>( + new ApiBreadcrumbsView(Model.Breadcrumbs, Model.HxAttributes))) + @await RenderBodyAsync() +
    +
    +
    + @* API pages never show "On this page"; right rail is examples-only. Keep both IDs for htmx swaps. *@ + @if (Model.ExamplesPanel is not null) + { + @await RenderPartialAsync(_OperationExamplesPanel.Create(Model.ExamplesPanel)) + } + else + { + + } +
    - @await RenderPartialAsync(_ApiToc.Create(Model.TocItems.ToArray())) + @await RenderPartialAsync(_ApiPagesNav.Create(Model))
    - @await RenderPartialAsync(_ApiPagesNav.Create(Model))
    @if (Model.BuildType == BuildType.Assembler) diff --git a/src/Elastic.ApiExplorer/_Partials/Layout/_ApiToc.cshtml b/src/Elastic.ApiExplorer/_Partials/Layout/_ApiToc.cshtml index 10e0801432..c28751f8b2 100644 --- a/src/Elastic.ApiExplorer/_Partials/Layout/_ApiToc.cshtml +++ b/src/Elastic.ApiExplorer/_Partials/Layout/_ApiToc.cshtml @@ -1,18 +1,18 @@ @inherits RazorSlice -