diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 949301a6da..acf2f9e8e1 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -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). :::: @@ -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//`, in declared order. See [children:](./supplemental.md#children-pages). | @@ -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:` @@ -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: 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)