From 18e2537d64afbfe9b7943918364190e10a8ed4e6 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 28 Aug 2026 07:44:30 +0200 Subject: [PATCH] Add local_spec so API Explorer can use a checkout-local OAS file Generated specs often live outside docs/ and use a different filename than the hosted version-index key. Split those two sources so local preview can prefer the file on disk and fall back to CloudFront. Co-Authored-By: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- docs/data/openapi/api-explorer.md | 77 ++++++-- .../documentation/isolated/configure/index.md | 1 + .../Builder/ConfigurationFile.cs | 165 ++++++++++++++---- .../Toc/ApiConfiguration.cs | 46 +++-- .../Toc/ApiConfigurationConverter.cs | 31 +++- .../IDocumentationContext.cs | 7 + .../ApiConfigurationTests.cs | 140 ++++++++++++++- .../ConfigurationFileExcludeTests.cs | 1 + .../ConfigurationFileReleaseNotesTests.cs | 1 + ...ConfigurationFileStorybookRegistryTests.cs | 1 + .../CrossLinkRegistryTests.cs | 1 + .../DocumentationSetFileTests.cs | 20 +++ .../TestDocumentationSetContext.cs | 1 + .../Framework/CrossLinkResolverAssertions.fs | 1 + 14 files changed, 419 insertions(+), 74 deletions(-) diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index a9c117035f..ed6683ce05 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -13,8 +13,8 @@ This feature is still under development and the functionality described on this ## 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:`: +single-entry sequence with a required `spec:` and `product:`, and optional `local_spec:`, +`repository:`, and `children:`: ```yaml api: @@ -32,14 +32,33 @@ The `api` key is only valid in `docset.yml`. You can't use it in `toc.yml` files ### `spec:` (required) -A path to an OpenAPI spec file, relative to the folder that contains `docset.yml`. `spec:` serves -two purposes at once: +The hosted spec name in the remote version index. Use the basename only, for example +`elasticsearch-openapi.json`. {{dbuild}} looks up this API by that basename, whether or not a +local file exists. See [Remote spec resolution](#remote-spec-resolution). -- 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). +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`. + +### `local_spec:` (optional) + +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:` (required) @@ -115,9 +134,12 @@ specs per product are not currently supported. ## 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. +When no local file is in use, {{dbuild}} resolves the current (`main`) version remotely. +It uses a CloudFront-backed version index shared by every Elastic repository that publishes +OpenAPI specs. + +A local file is in use when `local_spec:` points at an existing file. A local file is also +in use when `local_spec:` is omitted and a file exists at the docset-relative `spec:` path. ### How specs are published @@ -195,11 +217,31 @@ Run `docs-builder serve` (without `--watch`) and open any path above. ## Place your spec files -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. +`spec:` names the hosted file in the version index. It does not have to exist on disk. -For example, if your content set is structured like this: +To preview a local file, set `local_spec:` to a path relative to `docset.yml`. That path may +walk up into the rest of the same git checkout: + +``` +repo/ + docs/ + docset.yml + output/ + openapi/ + elasticsearch.json +``` + +```yaml +api: + elasticsearch: + - spec: elasticsearch.json + local_spec: ../output/openapi/elasticsearch.json + product: elasticsearch + repository: elastic/elasticsearch-specification +``` + +You can also keep the spec next to `docset.yml` and omit `local_spec:`. {{dbuild}} still uses +that file when it exists: ``` docs/ @@ -207,11 +249,8 @@ docs/ elasticsearch-openapi.json kibana-openapi.json index.md - ... ``` -Your `docset.yml` references the specs as follows: - ```yaml api: elasticsearch: diff --git a/docs/documentation/isolated/configure/index.md b/docs/documentation/isolated/configure/index.md index 524a416034..d7ae280834 100644 --- a/docs/documentation/isolated/configure/index.md +++ b/docs/documentation/isolated/configure/index.md @@ -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 diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 9618625573..8136f7d504 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -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)) @@ -605,6 +572,136 @@ IDocumentationSetContext context }; } + /// + /// Resolves the optional local OAS file. local_spec: is bound to the git checkout. + /// When it is omitted, a file at the docset-relative spec: path is still an implicit + /// override. Returns only when a declared path is unsafe or escapes + /// its trust root. A missing local_spec: file is a warning, not a failure. + /// + 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/<key>/'; escaping paths and symlinks are rejected the /// same way branding image paths are (see ). private static List ResolveApiChildren( diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs index 4fa32b036c..c5537ccf7a 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs @@ -25,28 +25,38 @@ public class ApiEntryChild /// /// api: /// <key>: -/// - spec: <path-or-filename> +/// - spec: <filename> +/// local_spec: <path> /// product: <product-id> /// repository: <org/repo> /// children: /// - file: getting-started.md /// -/// spec and product are both required. spec 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 <spec-name> -/// segment looked up in the root version index. repository is optional and only needed when -/// the spec-publishing repository differs from the current checkout's own GitHub remote. +/// spec and product are both required. spec is the hosted version-index +/// basename. Optional local_spec is a checkout-bounded file that overrides the current +/// (main) version when present. If local_spec is omitted and a file exists at the +/// docset-relative spec path, that file is still used as an implicit local override. +/// repository is optional and only needed when the spec-publishing repository differs +/// from the current checkout's own GitHub remote. /// [YamlSerializable] public class ApiProductEntry { /// - /// 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 <spec-name> + /// segment looked up in the remote version index. /// [YamlMember(Alias = "spec")] public string? Spec { get; set; } + /// + /// 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 main. When it is + /// missing the build warns and uses the hosted spec from . + /// + [YamlMember(Alias = "local_spec")] + public string? LocalSpec { get; set; } + /// /// Required product id. Must match a key in products.yml and binds this API to that /// product's versioning system. @@ -104,6 +114,17 @@ public class ApiProductEntry [YamlIgnore] public int? SpecColumn { get; set; } + /// + /// Source location of the local_spec: value specifically, when present. Used to + /// attribute a missing- or escaping-path diagnostic to the exact value. + /// + [YamlIgnore] + public int? LocalSpecLine { get; set; } + + /// 1-based column counterpart to . + [YamlIgnore] + public int? LocalSpecColumn { get; set; } + /// /// Source location of the repository: value specifically, when present. Used to attribute /// a malformed-repository diagnostic to the exact value rather than the whole entry. @@ -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); } @@ -159,10 +181,10 @@ public class ResolvedApiConfiguration public required string SpecFileName { get; init; } /// - /// Local override for the current OpenAPI specification file, present only when a file exists - /// on disk at the path declared via spec:. 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 (main) OpenAPI specification. Set when + /// local_spec: points at an existing file, or when local_spec: is omitted and a + /// file exists at the docset-relative spec: path. Null means the current version + /// resolves remotely through the product's version index. /// public IFileInfo? LocalSpecFile { get; init; } diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs index 973a3c816b..423a918792 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfigurationConverter.cs @@ -14,9 +14,10 @@ namespace Elastic.Documentation.Configuration.Toc; /// /// api: /// <key>: -/// - spec: <path> # optional local override -/// product: <id> # required -/// children: # optional +/// - spec: <filename> # required hosted index basename +/// local_spec: <path> # optional local file in the checkout +/// product: <id> # required +/// children: # optional /// - file: getting-started.md /// /// The legacy scalar ("api: key: path.json"), object ("api: key: { spec: path.json }"), and @@ -27,11 +28,12 @@ public class ApiConfigurationConverter : IYamlTypeConverter { private const string ShapeGuidance = "Use the single-entry sequence form instead:\n" + " :\n" - + " - spec: # required; its basename resolves the remote version index\n" - + " product: # required, must match a products.yml entry\n" + + " - spec: # required; basename of the hosted version-index entry\n" + + " local_spec: # optional; local file anywhere in the same git checkout\n" + + " product: # required, must match a products.yml entry\n" + " repository: # 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); @@ -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}" ); } @@ -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) diff --git a/src/Elastic.Documentation/IDocumentationContext.cs b/src/Elastic.Documentation/IDocumentationContext.cs index 043f1b428c..a2d2a22789 100644 --- a/src/Elastic.Documentation/IDocumentationContext.cs +++ b/src/Elastic.Documentation/IDocumentationContext.cs @@ -21,6 +21,13 @@ public interface IDocumentationSetContext : IDocumentationContext { IDocumentationFileSystem ReadFileSystem { get; } IDirectoryInfo DocumentationSourceDirectory { get; } + + /// + /// The git checkout root. Local OpenAPI paths declared via local_spec: must stay + /// under this directory. + /// + IDirectoryInfo DocumentationCheckoutDirectory { get; } + GitCheckoutInformation Git { get; } /// Environment variables used to resolve env-dependent config values; injectable so tests are deterministic. diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index d270c2c9f3..014ac54177 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -23,10 +23,22 @@ public class ApiProductEntryTests [Fact] public void HasSpec_And_HasProduct_ReflectPresence() { - var entry = new ApiProductEntry { Spec = "api.json", Product = "elasticsearch" }; + var entry = new ApiProductEntry { Spec = "api.json", Product = "elasticsearch", LocalSpec = "../output/api.json" }; entry.HasSpec.Should().BeTrue(); entry.HasProduct.Should().BeTrue(); + entry.HasLocalSpec.Should().BeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void HasLocalSpec_FalseWhenBlank(string? localSpec) + { + var entry = new ApiProductEntry { Spec = "api.json", Product = "elasticsearch", LocalSpec = localSpec }; + + entry.HasLocalSpec.Should().BeFalse(); } [Theory] @@ -218,6 +230,38 @@ public void RepositoryOverride_IsOptional() sequence.SingleEntry!.Repository.Should().BeNull(); } + [Fact] + public void AcceptsLocalSpecPath() + { + const string yaml = + """ + - spec: elasticsearch.json + local_spec: ../output/openapi/bundled.json + product: elasticsearch + """; + + var sequence = _deserializer.Deserialize(yaml); + var entry = sequence.SingleEntry!; + + entry.Spec.Should().Be("elasticsearch.json"); + entry.LocalSpec.Should().Be("../output/openapi/bundled.json"); + entry.HasLocalSpec.Should().BeTrue(); + entry.LocalSpecLine.Should().Be(2); + } + + [Fact] + public void LocalSpec_IsOptional() + { + const string yaml = """ + - spec: api.json + product: elasticsearch + """; + + var sequence = _deserializer.Deserialize(yaml); + + sequence.SingleEntry!.HasLocalSpec.Should().BeFalse(); + } + [Fact] public void RejectsLegacyScalarShape() { @@ -313,6 +357,7 @@ public void ResolvesSpec_WhenLocalFileAbsent_ForRemoteResolution() var (config, collector) = CreateConfiguration(docSetFile, withLocalSpecFile: false); collector.Errors.Should().Be(0); + collector.Warnings.Should().Be(0); var resolved = config.ApiConfigurations!["elasticsearch"]; resolved.SpecFileName.Should().Be("elasticsearch-openapi.json"); resolved.LocalSpecFile.Should().BeNull(); @@ -357,6 +402,93 @@ public void EmitsError_WhenSpecMissing() config.ApiConfigurations.Should().BeNull(); } + [Fact] + public void ResolvesLocalSpec_WhenFileIsOutsideDocsFolderButInsideCheckout() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch.json", + LocalSpec = "../output/elasticsearch.json", + Product = "elasticsearch" + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, extraCheckoutFiles: ["output/elasticsearch.json"]); + + collector.Errors.Should().Be(0); + collector.Warnings.Should().Be(0); + var resolved = config.ApiConfigurations!["elasticsearch"]; + resolved.SpecFileName.Should().Be("elasticsearch.json"); + resolved.LocalSpecFile.Should().NotBeNull(); + resolved.LocalSpecFile!.Name.Should().Be("elasticsearch.json"); + resolved.LocalSpecFile.Directory!.Name.Should().Be("output"); + } + + [Fact] + public void Warns_WhenLocalSpecIsDeclaredButMissing() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch.json", + LocalSpec = "../output/elasticsearch.json", + Product = "elasticsearch" + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, withLocalSpecFile: false); + + collector.Errors.Should().Be(0); + collector.Warnings.Should().Be(1); + var resolved = config.ApiConfigurations!["elasticsearch"]; + resolved.SpecFileName.Should().Be("elasticsearch.json"); + resolved.LocalSpecFile.Should().BeNull(); + } + + [Fact] + public void EmitsError_WhenLocalSpecEscapesCheckout() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry { Spec = "elasticsearch.json", LocalSpec = "../../outside.json", Product = "elasticsearch" } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile); + + collector.Errors.Should().Be(1); + config.ApiConfigurations.Should().BeNull(); + } + [Fact] public void EmitsError_WhenSpecEscapesDocumentationSourceDirectory() { @@ -701,7 +833,8 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true, - string[]? extraMarkdownFiles = null + string[]? extraMarkdownFiles = null, + string[]? extraCheckoutFiles = null ) { var collector = new DiagnosticsCollector([]); @@ -717,6 +850,8 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create files[Path.Join(root, "docs", "elasticsearch-openapi.json")] = new MockFileData("{}"); foreach (var name in extraMarkdownFiles ?? []) files[Path.Join(root, "docs", "api", "elasticsearch", name)] = new MockFileData("# extra"); + foreach (var relative in extraCheckoutFiles ?? []) + files[Path.Join(root, relative)] = new MockFileData("{}"); var fileSystem = new MockFileSystem(files, root); var configPath = fileSystem.FileInfo.New(configFilePath); @@ -758,6 +893,7 @@ IDirectoryInfo documentationSourceDirectory public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => fileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs index 2119594556..2420db132f 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileExcludeTests.cs @@ -84,6 +84,7 @@ IDirectoryInfo documentationSourceDirectory public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => documentationSourceDirectory; public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index d05d6bc6aa..c57b43c003 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -157,6 +157,7 @@ IDirectoryInfo documentationSourceDirectory public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => documentationSourceDirectory; public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); public IEnvironmentVariables Environment { get; } = new DeterministicEnvironment(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs index 114bbf54c1..71d9cd91cc 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileStorybookRegistryTests.cs @@ -116,6 +116,7 @@ IEnvironmentVariables environment public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => documentationSourceDirectory; public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); public IEnvironmentVariables Environment => environment; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs index ef45d5c2c6..bed29810c4 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/CrossLinkRegistryTests.cs @@ -140,6 +140,7 @@ IDirectoryInfo documentationSourceDirectory public IFileInfo ConfigurationPath => configurationPath; public BuildType BuildType => BuildType.Isolated; public IDirectoryInfo DocumentationSourceDirectory => documentationSourceDirectory; + public IDirectoryInfo DocumentationCheckoutDirectory => documentationSourceDirectory; public GitCheckoutInformation Git => GitCheckoutInformationFactory.Create(documentationSourceDirectory, fileSystem); public IEnvironmentVariables Environment => SystemEnvironmentVariables.Instance; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs index 7dc9e333b6..bb31783d3d 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/DocumentationSetFileTests.cs @@ -103,6 +103,26 @@ public void DeserializesApiConfiguration() result.Api["kibana"].SingleEntry!.Spec.Should().Be("kibana-openapi.json"); } + [Fact] + public void DeserializesApiLocalSpec() + { + var yaml = + """ + project: 'test-project' + api: + elasticsearch: + - spec: elasticsearch.json + local_spec: ../output/openapi/elasticsearch.json + product: elasticsearch + """; + + var result = Deserialize(yaml); + var entry = result.Api["elasticsearch"].SingleEntry!; + + entry.Spec.Should().Be("elasticsearch.json"); + entry.LocalSpec.Should().Be("../output/openapi/elasticsearch.json"); + } + [Fact] public void DeserializesFileReference() { diff --git a/tests/Navigation.Tests/TestDocumentationSetContext.cs b/tests/Navigation.Tests/TestDocumentationSetContext.cs index 90c7ab9868..73aa56cb12 100644 --- a/tests/Navigation.Tests/TestDocumentationSetContext.cs +++ b/tests/Navigation.Tests/TestDocumentationSetContext.cs @@ -104,6 +104,7 @@ public TestDocumentationSetContext( public DocumentationWriteFileSystem WriteFileSystem { get; } public IDirectoryInfo OutputDirectory { get; } public IDirectoryInfo DocumentationSourceDirectory { get; } + public IDirectoryInfo DocumentationCheckoutDirectory => DocumentationSourceDirectory; public GitCheckoutInformation Git { get; } public IFileInfo ConfigurationPath { get; } public IEnvironmentVariables Environment { get; init; } = SystemEnvironmentVariables.Instance; diff --git a/tests/authoring/Framework/CrossLinkResolverAssertions.fs b/tests/authoring/Framework/CrossLinkResolverAssertions.fs index 7d58587158..6edad1adde 100644 --- a/tests/authoring/Framework/CrossLinkResolverAssertions.fs +++ b/tests/authoring/Framework/CrossLinkResolverAssertions.fs @@ -32,6 +32,7 @@ module CrossLinkResolverAssertions = { new IDocumentationSetContext with member _.Collector = collector member _.DocumentationSourceDirectory = mockFileSystem.DirectoryInfo.New("/docs") + member _.DocumentationCheckoutDirectory = mockFileSystem.DirectoryInfo.New("/docs") member _.Git = GitCheckoutInformation.Unavailable member _.ReadFileSystem = DocumentationFileSystem.Resolve(mockFileSystem.DirectoryInfo.New("/docs"), DocumentationScopeOptions(Inner = (mockFileSystem :> IFileSystem), ConfigurationFile = "/docs/docset.yml")) :> IDocumentationFileSystem member _.WriteFileSystem = DocumentationWriteFileSystem(mockFileSystem.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName), null, mockFileSystem)