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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions docs/data/openapi/api-explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ api:

The map key is the URL suffix. This key produces `/api/doc/docs-builder-elasticsearch/`.

Each key takes a sequence with exactly one entry. That entry requires `spec:` and `product:`. `repository:` and `children:` are optional. See [Reference](#reference).
Each key takes a sequence with exactly one entry. That entry requires `spec:` and `product:`. `local_spec:`, `repository:`, and `children:` are optional. See [Reference](#reference).

::::

Expand Down Expand Up @@ -106,7 +106,8 @@ Fix the file. Then rebuild. More messages are in [Writing supplemental content](

| `docset.yml` key | Required | Description |
|---|---|---|
| `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. |
| `spec:` | yes | The hosted spec basename used to look up the remote version index, for example `elasticsearch-openapi.json`. If no `local_spec:` is set and a file with this name exists next to `docset.yml`, {{dbuild}} also uses it as an implicit local override for `main`. |
| `local_spec:` | no | Path to a local OpenAPI file, relative to `docset.yml`, that may sit anywhere in the same git checkout. When present, {{dbuild}} renders it for `main`. |
| `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/<key>/`, in declared order. See [children:](./supplemental.md#children-pages). |
Expand All @@ -115,9 +116,26 @@ Each product key must have exactly one sequence entry. That entry must have exac

### `spec:`

If the file exists on disk, {{dbuild}} uses it for the `main` moniker. Older majors still come from the version index.
The basename always looks up the version index, whether or not a local file exists. See [Remote spec resolution](#remote-spec-resolution).

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).
If you omit `local_spec:` and a file with this name exists next to `docset.yml` (or in a subfolder of it), {{dbuild}} uses that file as an implicit local override for `main`. A missing implicit file stays silent and the hosted spec is used.

### `local_spec:`

A path to a local OpenAPI file, relative to the folder that contains `docset.yml`. The file may sit anywhere in the same git checkout. An absolute path is allowed only when it stays under the checkout root.

When the file exists, {{dbuild}} uses it for the current (`main`) version. When the file is missing, {{dbuild}} emits a warning and renders the hosted spec named in `spec:`.

```yaml
api:
elasticsearch:
- spec: elasticsearch.json
local_spec: ../output/openapi/elasticsearch.json
product: elasticsearch
repository: elastic/elasticsearch-specification
```

Do not set `local_spec:` in a docset that never carries a local file — a missing implicit `spec:` file stays silent and uses the hosted spec.

### `product:`

Expand Down Expand Up @@ -169,7 +187,7 @@ A versionless product (`versioning: serverless` and similar) renders only `/api/

## Remote spec resolution

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.
A local file is in use when `local_spec:` points at an existing file, or when `local_spec:` is omitted and a file exists at the docset-relative `spec:` path. When no local file is in use, {{dbuild}} fetches `main` from a CloudFront version index. Repositories that publish OpenAPI specs share this index.

Object keys in the bucket look like this:

Expand Down
1 change: 1 addition & 0 deletions docs/documentation/isolated/configure/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Configures API Explorer sections from OpenAPI specifications. Only valid in `doc
api:
elasticsearch:
- spec: elasticsearch-openapi.json
local_spec: ../output/openapi/elasticsearch.json
product: elasticsearch
kibana:
- spec: kibana-openapi.json
Expand Down
165 changes: 131 additions & 34 deletions src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,41 +529,8 @@ IDocumentationSetContext context
return null;
}

var fullSpecPath = Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, entry.Spec));
var specFile = context.ReadFileSystem.FileInfo.New(fullSpecPath);
if (!specFile.IsSubPathOf(context.DocumentationSourceDirectory))
{
context.Collector.Write(new Diagnostic
{
Severity = Severity.Error,
File = context.ConfigurationPath.FullName,
Line = entry.SpecLine ?? entry.Line,
Column = entry.SpecColumn ?? entry.Column,
Message = $"'spec: {entry.Spec}' for API '{productKey}' escapes the documentation source directory."
});
if (!TryResolveLocalSpecFile(productKey, entry, specFileName, context, out var localSpecFile))
return null;
}

// A missing local file is expected, not an error: docsets that don't carry the spec
// locally resolve the current version from S3 via the version index instead.
IFileInfo? localSpecFile = null;
if (specFile.Exists)
{
var symlinkError = ValidateFileAccess(specFile, context.DocumentationSourceDirectory);
if (symlinkError is not null)
{
context.Collector.Write(new Diagnostic
{
Severity = Severity.Error,
File = context.ConfigurationPath.FullName,
Line = entry.SpecLine ?? entry.Line,
Column = entry.SpecColumn ?? entry.Column,
Message = $"'spec: {entry.Spec}' for API '{productKey}' is unsafe: {symlinkError}"
});
return null;
}
localSpecFile = specFile;
}

string? repository = null;
if (!string.IsNullOrWhiteSpace(entry.Repository))
Expand Down Expand Up @@ -605,6 +572,136 @@ IDocumentationSetContext context
};
}

/// <summary>
/// Resolves the optional local OAS file. <c>local_spec:</c> is bound to the git checkout.
/// When it is omitted, a file at the docset-relative <c>spec:</c> path is still an implicit
/// override. Returns <see langword="false"/> only when a declared path is unsafe or escapes
/// its trust root. A missing <c>local_spec:</c> file is a warning, not a failure.
/// </summary>
private static bool TryResolveLocalSpecFile(
string productKey,
ApiProductEntry entry,
string specFileName,
IDocumentationSetContext context,
out IFileInfo? localSpecFile
)
{
if (entry.HasLocalSpec)
return TryResolveDeclaredLocalSpec(productKey, entry, specFileName, context, out localSpecFile);

return TryResolveImplicitLocalSpec(productKey, entry, context, out localSpecFile);
}

private static bool TryResolveDeclaredLocalSpec(
string productKey,
ApiProductEntry entry,
string specFileName,
IDocumentationSetContext context,
out IFileInfo? localSpecFile
)
{
localSpecFile = null;
var declared = entry.LocalSpec!.Trim();
var fullPath = Path.IsPathRooted(declared)
? Path.GetFullPath(declared)
: Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, declared));
var specFile = context.ReadFileSystem.FileInfo.New(fullPath);
var checkout = context.DocumentationCheckoutDirectory;

if (!specFile.IsSubPathOf(checkout))
{
EmitApiDiagnostic(
context,
Severity.Error,
entry.LocalSpecLine ?? entry.Line,
entry.LocalSpecColumn ?? entry.Column,
$"'local_spec: {entry.LocalSpec}' for API '{productKey}' escapes the repository checkout."
);
return false;
}

if (!specFile.Exists)
{
EmitApiDiagnostic(
context,
Severity.Warning,
entry.LocalSpecLine ?? entry.Line,
entry.LocalSpecColumn ?? entry.Column,
$"Local OpenAPI spec '{entry.LocalSpec}' for API '{productKey}' was not found. " +
$"Rendering the hosted spec '{specFileName}' from the version index instead."
);
return true;
}

var symlinkError = ValidateFileAccess(specFile, checkout);
if (symlinkError is not null)
{
EmitApiDiagnostic(
context,
Severity.Error,
entry.LocalSpecLine ?? entry.Line,
entry.LocalSpecColumn ?? entry.Column,
$"'local_spec: {entry.LocalSpec}' for API '{productKey}' is unsafe: {symlinkError}"
);
return false;
}

localSpecFile = specFile;
return true;
}

private static bool TryResolveImplicitLocalSpec(
string productKey,
ApiProductEntry entry,
IDocumentationSetContext context,
out IFileInfo? localSpecFile
)
{
localSpecFile = null;
var fullSpecPath = Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, entry.Spec));
var specFile = context.ReadFileSystem.FileInfo.New(fullSpecPath);
if (!specFile.IsSubPathOf(context.DocumentationSourceDirectory))
{
EmitApiDiagnostic(
context,
Severity.Error,
entry.SpecLine ?? entry.Line,
entry.SpecColumn ?? entry.Column,
$"'spec: {entry.Spec}' for API '{productKey}' escapes the documentation source directory."
);
return false;
}

if (!specFile.Exists)
return true;

var symlinkError = ValidateFileAccess(specFile, context.DocumentationSourceDirectory);
if (symlinkError is not null)
{
EmitApiDiagnostic(
context,
Severity.Error,
entry.SpecLine ?? entry.Line,
entry.SpecColumn ?? entry.Column,
$"'spec: {entry.Spec}' for API '{productKey}' is unsafe: {symlinkError}"
);
return false;
}

localSpecFile = specFile;
return true;
}

private static void EmitApiDiagnostic(IDocumentationSetContext context, Severity severity, int? line, int? column, string message) =>
context.Collector.Write(new Diagnostic
{
Severity = severity,
File = context.ConfigurationPath.FullName,
Line = line,
Column = column,
Message = message
});

/// Children resolve only under 'api/&lt;key&gt;/'; escaping paths and symlinks are rejected the
/// same way branding image paths are (see <see cref="ValidateBrandingImage"/>).
private static List<IFileInfo> ResolveApiChildren(
Expand Down
46 changes: 34 additions & 12 deletions src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,38 @@ public class ApiEntryChild
/// <code>
/// api:
/// &lt;key&gt;:
/// - spec: &lt;path-or-filename&gt;
/// - spec: &lt;filename&gt;
/// local_spec: &lt;path&gt;
/// product: &lt;product-id&gt;
/// repository: &lt;org/repo&gt;
/// children:
/// - file: getting-started.md
/// </code>
/// <c>spec</c> and <c>product</c> are both required. <c>spec</c> serves two purposes at once: if a
/// file exists at that path relative to the docset, it overrides the current version for local
/// preview; regardless of whether it exists on disk, its basename is the <c>&lt;spec-name&gt;</c>
/// segment looked up in the root version index. <c>repository</c> is optional and only needed when
/// the spec-publishing repository differs from the current checkout's own GitHub remote.
/// <c>spec</c> and <c>product</c> are both required. <c>spec</c> is the hosted version-index
/// basename. Optional <c>local_spec</c> is a checkout-bounded file that overrides the current
/// (<c>main</c>) version when present. If <c>local_spec</c> is omitted and a file exists at the
/// docset-relative <c>spec</c> path, that file is still used as an implicit local override.
/// <c>repository</c> is optional and only needed when the spec-publishing repository differs
/// from the current checkout's own GitHub remote.
/// </summary>
[YamlSerializable]
public class ApiProductEntry
{
/// <summary>
/// Path to an OpenAPI specification file, relative to the docset. Required: its basename is
/// used to resolve the remote version index even when no file exists at this path locally.
/// Hosted OpenAPI spec identifier. Required: its basename is the <c>&lt;spec-name&gt;</c>
/// segment looked up in the remote version index.
/// </summary>
[YamlMember(Alias = "spec")]
public string? Spec { get; set; }

/// <summary>
/// Optional path to a local OpenAPI file, relative to the docset. May walk up into the rest
/// of the same git checkout. When the file exists it overrides <c>main</c>. When it is
/// missing the build warns and uses the hosted spec from <see cref="Spec"/>.
/// </summary>
[YamlMember(Alias = "local_spec")]
public string? LocalSpec { get; set; }

/// <summary>
/// Required product id. Must match a key in <c>products.yml</c> and binds this API to that
/// product's versioning system.
Expand Down Expand Up @@ -104,6 +114,17 @@ public class ApiProductEntry
[YamlIgnore]
public int? SpecColumn { get; set; }

/// <summary>
/// Source location of the <c>local_spec:</c> value specifically, when present. Used to
/// attribute a missing- or escaping-path diagnostic to the exact value.
/// </summary>
[YamlIgnore]
public int? LocalSpecLine { get; set; }

/// <summary>1-based column counterpart to <see cref="LocalSpecLine"/>.</summary>
[YamlIgnore]
public int? LocalSpecColumn { get; set; }

/// <summary>
/// Source location of the <c>repository:</c> value specifically, when present. Used to attribute
/// a malformed-repository diagnostic to the exact value rather than the whole entry.
Expand All @@ -116,6 +137,7 @@ public class ApiProductEntry
public int? RepositoryColumn { get; set; }

public bool HasSpec => !string.IsNullOrWhiteSpace(Spec);
public bool HasLocalSpec => !string.IsNullOrWhiteSpace(LocalSpec);
public bool HasProduct => !string.IsNullOrWhiteSpace(Product);
}

Expand Down Expand Up @@ -159,10 +181,10 @@ public class ResolvedApiConfiguration
public required string SpecFileName { get; init; }

/// <summary>
/// Local override for the current OpenAPI specification file, present only when a file exists
/// on disk at the path declared via <c>spec:</c>. Null means the current version resolves
/// remotely through the product's version index — this is expected, not an error, for any
/// docset that does not carry the spec file locally.
/// Local override for the current (<c>main</c>) OpenAPI specification. Set when
/// <c>local_spec:</c> points at an existing file, or when <c>local_spec:</c> is omitted and a
/// file exists at the docset-relative <c>spec:</c> path. Null means the current version
/// resolves remotely through the product's version index.
/// </summary>
public IFileInfo? LocalSpecFile { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ namespace Elastic.Documentation.Configuration.Toc;
/// <code>
/// api:
/// &lt;key&gt;:
/// - spec: &lt;path&gt; # optional local override
/// product: &lt;id&gt; # required
/// children: # optional
/// - spec: &lt;filename&gt; # required hosted index basename
/// local_spec: &lt;path&gt; # optional local file in the checkout
/// product: &lt;id&gt; # required
/// children: # optional
/// - file: getting-started.md
/// </code>
/// The legacy scalar ("api: key: path.json"), object ("api: key: { spec: path.json }"), and
Expand All @@ -27,11 +28,12 @@ public class ApiConfigurationConverter : IYamlTypeConverter
{
private const string ShapeGuidance = "Use the single-entry sequence form instead:\n"
+ " <key>:\n"
+ " - spec: <path> # required; its basename resolves the remote version index\n"
+ " product: <id> # required, must match a products.yml entry\n"
+ " - spec: <filename> # required; basename of the hosted version-index entry\n"
+ " local_spec: <path> # optional; local file anywhere in the same git checkout\n"
+ " product: <id> # required, must match a products.yml entry\n"
+ " repository: <org/repo> # optional; only needed if the spec is published from a\n"
+ " # different repo than the current checkout\n"
+ " children: # optional\n"
+ " children: # optional\n"
+ " - file: getting-started.md";

public bool Accepts(Type type) => type == typeof(ApiProductSequence) || type == typeof(ApiProductEntry);
Expand Down Expand Up @@ -66,7 +68,7 @@ private ApiProductEntry ReadEntry(IParser parser)
throw new YamlException(
parser.Current?.Start ?? Mark.Empty,
parser.Current?.End ?? Mark.Empty,
$"Each API entry must be a mapping with 'spec', 'product', and optional 'children' keys. {ShapeGuidance}"
$"Each API entry must be a mapping with 'spec', 'product', and optional 'local_spec' and 'children' keys. {ShapeGuidance}"
);
}

Expand Down Expand Up @@ -95,6 +97,21 @@ private ApiProductEntry ReadEntry(IParser parser)
entry.SpecColumn = (int)specStart.Value.Column;
}
break;
case "local_spec":
var localSpecStart = parser.Current?.Start;
if (parser.Current is Scalar localSpecValue)
{
entry.LocalSpec = localSpecValue.Value;
_ = parser.MoveNext();
}
else
parser.SkipThisAndNestedEvents();
if (localSpecStart.HasValue)
{
entry.LocalSpecLine = (int)localSpecStart.Value.Line;
entry.LocalSpecColumn = (int)localSpecStart.Value.Column;
}
break;
case "product":
var productStart = parser.Current?.Start;
if (parser.Current is Scalar productValue)
Expand Down
Loading
Loading