diff --git a/config/assembler.yml b/config/assembler.yml index a53be2be09..e833d5fe1f 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -58,6 +58,7 @@ environments: enabled: false feature_flags: NAVIGATION_PREVIEW: true + ASSEMBLER_API_EXPLORER: true air-gapped: uri: http://localhost:8080 path_prefix: docs diff --git a/docs/_docset.yml b/docs/_docset.yml index 0f27dcbb98..354382c7f5 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -38,6 +38,18 @@ subs: features: primary-nav: false +# Local fixture for ApiExplorer and supplemental files. The URL key must not collide +# with docs-content's `elasticsearch` key: assembler preview still builds this checkout +# even though the repo is `skip: true` in assembler.yml. +# `repository:` matches the version-index publisher; without it, lookup uses this +# checkout (`elastic/docs-builder`) and `--strict` fails. Isolated serve (no +# `--watch`): /api/doc/docs-builder-elasticsearch/ +api: + docs-builder-elasticsearch: + - spec: elasticsearch.json + product: elasticsearch + repository: elastic/elasticsearch-specification + cta: docs-builder: button: diff --git a/docs/api/docs-builder-elasticsearch/op-async-search-get.md b/docs/api/docs-builder-elasticsearch/op-async-search-get.md new file mode 100644 index 0000000000..fc77406fa1 --- /dev/null +++ b/docs/api/docs-builder-elasticsearch/op-async-search-get.md @@ -0,0 +1,5 @@ +# Async search get supplemental fixture + +This file is a local fixture for docs-builder development. + +Discovery should associate it with the `async-search-get` operation. diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 63ab48bef3..fbf12daf8f 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -226,15 +226,18 @@ api: ## When the API Explorer runs -The API Explorer generates documentation in two scenarios: +The API Explorer generates documentation in these scenarios: - **`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. +- **Assembler builds**: API docs are generated when the `ASSEMBLER_API_EXPLORER` feature flag is on. That flag is on for the `staging` and `preview` environments. Production stays off until cutover. :::{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. ::: +This repository's own `_docset.yml` declares a local `docs-builder-elasticsearch` API that reads `elasticsearch.json` and sets `repository: elastic/elasticsearch-specification`. Use that entry to preview ApiExplorer and supplemental files during isolated `docs-builder serve` (open `/api/doc/docs-builder-elasticsearch/`). The key is not `elasticsearch`, so assembler preview does not collide with docs-content. + ## Link to API pages in navigation You can reference API pages in your `toc.yml` or `docset.yml` navigation using cross-link syntax: diff --git a/docs/elasticsearch-openapi-docs.json b/docs/elasticsearch.json similarity index 100% rename from docs/elasticsearch-openapi-docs.json rename to docs/elasticsearch.json diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs index 7401b2fb52..bf95efbce9 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiUrlBuilder.cs @@ -42,11 +42,14 @@ public static string OperationMoniker(string? operationId, string route) public static string SchemaMoniker(string schemaId) => schemaId.Replace('.', '-').ToLowerInvariant(); - /// Deterministic URL leaf for .../group/{segment} from the canonical tag name. - public static string TagMoniker(string? tagName) + /// + /// URL slug for a tag, without the endpoint- prefix. Spaces become hyphens and the + /// result is lowercased; underscores are kept. Empty or whitespace names become unknown. + /// + public static string TagSlug(string? tagName) { if (string.IsNullOrWhiteSpace(tagName)) - return "endpoint-unknown"; + return "unknown"; var s = tagName.Trim(); s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries)); @@ -56,12 +59,12 @@ public static string TagMoniker(string? tagName) s = s.Replace("/", "-", StringComparison.Ordinal); s = s.Replace(" ", "-", StringComparison.Ordinal); s = s.ToLowerInvariant(); - if (string.IsNullOrEmpty(s)) - return "endpoint-unknown"; - - return $"endpoint-{s}"; + return string.IsNullOrEmpty(s) ? "unknown" : s; } + /// Deterministic URL leaf for .../group/{segment} from the canonical tag name. + public static string TagMoniker(string? tagName) => $"endpoint-{TagSlug(tagName)}"; + [GeneratedRegex(@"\s*\(([^)]+)\)")] private static partial Regex ParentheticalSuffixPattern(); } diff --git a/src/Elastic.ApiExplorer/OpenApiGenerator.cs b/src/Elastic.ApiExplorer/OpenApiGenerator.cs index 1508f00e69..5f8d664a1f 100644 --- a/src/Elastic.ApiExplorer/OpenApiGenerator.cs +++ b/src/Elastic.ApiExplorer/OpenApiGenerator.cs @@ -9,6 +9,7 @@ using Elastic.ApiExplorer.Model; using Elastic.ApiExplorer.Navigation; using Elastic.ApiExplorer.Operations; +using Elastic.ApiExplorer.Supplemental; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Products; @@ -228,6 +229,7 @@ private async Task GenerateApiProduct( IReadOnlyList versionSwitcherItems, Cancel ctx) { + _ = DiscoverSupplemental(openApiDocument, apiConfig); var navigation = CreateNavigation(prefix, openApiDocument, apiConfig); _logger.LogInformation("Generating OpenApiDocument {Title}", openApiDocument.Info?.Title ?? ""); @@ -245,6 +247,27 @@ private async Task GenerateApiProduct( await RenderNavigationItems(renderContext, navigationRenderer, navigation, ctx).ConfigureAwait(false); } + /// + /// Associates op-*.md / tag-*.md files under api/<key>/ with this + /// document. Merge into page models is a later change; this call logs and exposes the result. + /// + internal ApiSupplementalDiscoveryResult DiscoverSupplemental( + OpenApiDocument openApiDocument, + ResolvedApiConfiguration? apiConfig) + { + var result = ApiSupplementalDiscovery.Discover(apiConfig?.ApiContentDirectory, openApiDocument); + if (result.Operations.Count == 0 && result.Tags.Count == 0 && result.Unmatched.Count == 0) + return result; + + _logger.LogInformation( + "API '{ApiKey}' supplemental files: {Operations} operations, {Tags} tags, {Unmatched} unmatched", + apiConfig?.ProductKey ?? "unknown", + result.Operations.Count, + result.Tags.Count, + result.Unmatched.Count); + return result; + } + private async Task RenderNavigationItems( ApiRenderContext renderContext, IsolatedBuildNavigationHtmlWriter navigationRenderer, diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs new file mode 100644 index 0000000000..3274231572 --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalDiscovery.cs @@ -0,0 +1,182 @@ +// 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.IO.Abstractions; +using Elastic.ApiExplorer.Infrastructure; +using Microsoft.OpenApi; + +namespace Elastic.ApiExplorer.Supplemental; + +public sealed record TagSlugCollision(string Slug, IReadOnlyList TagNames); + +public sealed record ApiSupplementalVersionedFile(IFileInfo File, ApiSupplementalFileName Name); + +public sealed class ApiSupplementalDiscoveryResult +{ + public required IReadOnlyDictionary Operations { get; init; } + public required IReadOnlyDictionary Tags { get; init; } + public required IReadOnlyList Unmatched { get; init; } + public required IReadOnlyList Ignored { get; init; } + public required IReadOnlyList VersionSuffixed { get; init; } + public required IReadOnlyList TagSlugCollisions { get; init; } +} + +/// +/// Discovers top-level op-*.md / tag-*.md files under api/<key>/. +/// Does not emit diagnostics; unmatched convention files are returned for later validation. +/// +public static class ApiSupplementalDiscovery +{ + public static ApiSupplementalDiscoveryResult Discover( + IDirectoryInfo? folder, + IReadOnlyCollection operationIds, + IReadOnlyCollection tagNames) + { + var (tagBySlug, collisions) = IndexTags(tagNames); + return MatchFiles(folder, operationIds.ToHashSet(StringComparer.Ordinal), tagBySlug, collisions); + } + + public static ApiSupplementalDiscoveryResult Discover(IDirectoryInfo? folder, OpenApiDocument document) + { + var (operationIds, tagNames) = CollectEntities(document); + var (tagBySlug, collisions) = IndexTags(tagNames); + return MatchFiles(folder, operationIds, tagBySlug, collisions); + } + + private static ApiSupplementalDiscoveryResult MatchFiles( + IDirectoryInfo? folder, + HashSet operationIds, + Dictionary tagBySlug, + IReadOnlyList collisions) + { + if (folder is null || !folder.Exists) + return NoFiles(collisions); + + var operations = new Dictionary(StringComparer.Ordinal); + var tags = new Dictionary(StringComparer.Ordinal); + var unmatched = new List(); + var ignored = new List(); + var versionSuffixed = new List(); + + foreach (var file in folder.EnumerateFiles("*.md")) + { + if (!ApiSupplementalName.TryParse(file.Name, out var name)) + { + ignored.Add(file); + continue; + } + + if (name.IsVersionSuffixed) + { + versionSuffixed.Add(new ApiSupplementalVersionedFile(file, name)); + continue; + } + + if (name.Kind == ApiSupplementalKind.Operation) + { + if (operationIds.Contains(name.Stem) && operations.TryAdd(name.Stem, file)) + continue; + unmatched.Add(file); + continue; + } + + if (tagBySlug.TryGetValue(name.Stem, out var tagName) && tags.TryAdd(tagName, file)) + continue; + + unmatched.Add(file); + } + + return new ApiSupplementalDiscoveryResult + { + Operations = operations, + Tags = tags, + Unmatched = unmatched, + Ignored = ignored, + VersionSuffixed = versionSuffixed, + TagSlugCollisions = collisions + }; + } + + private static (HashSet OperationIds, HashSet TagNames) CollectEntities(OpenApiDocument document) + { + var operations = new HashSet(StringComparer.Ordinal); + var tags = new HashSet(StringComparer.Ordinal); + + if (document.Tags is not null) + { + foreach (var tag in document.Tags) + { + if (!string.IsNullOrEmpty(tag.Name)) + _ = tags.Add(tag.Name); + } + } + + foreach (var path in document.Paths ?? []) + { + if (path.Value.Operations is null) + continue; + + foreach (var operation in path.Value.Operations.Values) + { + if (!string.IsNullOrWhiteSpace(operation.OperationId)) + _ = operations.Add(operation.OperationId); + + if (operation.Tags is null) + continue; + + foreach (var tagRef in operation.Tags) + { + var name = OperationTagName(tagRef); + if (!string.IsNullOrEmpty(name)) + _ = tags.Add(name); + } + } + } + + return (operations, tags); + } + + private static string? OperationTagName(OpenApiTagReference tagRef) => + !string.IsNullOrEmpty(tagRef.Name) ? tagRef.Name : tagRef.Reference?.Id; + + private static (Dictionary UniqueBySlug, IReadOnlyList Collisions) IndexTags( + IReadOnlyCollection tagNames) + { + var bySlug = new Dictionary>(StringComparer.Ordinal); + foreach (var tagName in tagNames) + { + var slug = ApiUrlBuilder.TagSlug(tagName); + if (!bySlug.TryGetValue(slug, out var names)) + { + names = []; + bySlug[slug] = names; + } + + if (!names.Contains(tagName, StringComparer.Ordinal)) + names.Add(tagName); + } + + var unique = new Dictionary(StringComparer.Ordinal); + var collisions = new List(); + foreach (var (slug, names) in bySlug) + { + if (names.Count == 1) + unique[slug] = names[0]; + else + collisions.Add(new TagSlugCollision(slug, names)); + } + + return (unique, collisions); + } + + private static ApiSupplementalDiscoveryResult NoFiles(IReadOnlyList collisions) => new() + { + Operations = new Dictionary(), + Tags = new Dictionary(), + Unmatched = [], + Ignored = [], + VersionSuffixed = [], + TagSlugCollisions = collisions + }; +} diff --git a/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs new file mode 100644 index 0000000000..991cffe1aa --- /dev/null +++ b/src/Elastic.ApiExplorer/Supplemental/ApiSupplementalName.cs @@ -0,0 +1,57 @@ +// 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.RegularExpressions; + +namespace Elastic.ApiExplorer.Supplemental; + +public enum ApiSupplementalKind +{ + Operation, + Tag +} + +public readonly record struct ApiSupplementalFileName( + ApiSupplementalKind Kind, + string Stem, + int? VersionMajor) +{ + public bool IsVersionSuffixed => VersionMajor is not null; +} + +/// +/// Parses op-*.md / tag-*.md filenames. Operation stems are the spec +/// operationId with no rewriting. Tag stems are . +/// +public static partial class ApiSupplementalName +{ + public static bool TryParse(string fileName, out ApiSupplementalFileName parsed) + { + parsed = default; + var match = FileNamePattern().Match(fileName); + if (!match.Success) + return false; + + var kind = match.Groups[1].Value.Equals("op", StringComparison.OrdinalIgnoreCase) + ? ApiSupplementalKind.Operation + : ApiSupplementalKind.Tag; + var stem = match.Groups[2].Value; + if (stem.Length == 0) + return false; + + int? version = null; + if (match.Groups[3].Success) + { + if (!int.TryParse(match.Groups[3].Value, out var major)) + return false; + version = major; + } + + parsed = new ApiSupplementalFileName(kind, stem, version); + return true; + } + + [GeneratedRegex(@"^(op|tag)-(.+?)(?:\.v(\d+))?\.md$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FileNamePattern(); +} diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index dd9b9a9e1d..94f71f9aaf 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -563,7 +563,9 @@ private static bool IsValidProductId(string product) => repository = candidate; } - var children = ResolveApiChildren(productKey, entry.Children, context); + var apiContentDirectory = context.ReadFileSystem.DirectoryInfo.New( + Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey)); + var children = ResolveApiChildren(productKey, entry.Children, context, apiContentDirectory); return new ResolvedApiConfiguration { @@ -572,20 +574,22 @@ private static bool IsValidProductId(string product) => SpecFileName = specFileName, LocalSpecFile = localSpecFile, Repository = repository, - Children = children + Children = children, + ApiContentDirectory = apiContentDirectory }; } /// Children resolve only under 'api/<key>/'; escaping paths and symlinks are rejected the /// same way branding image paths are (see ). - private static List ResolveApiChildren(string productKey, List children, IDocumentationSetContext context) + private static List ResolveApiChildren( + string productKey, + List children, + IDocumentationSetContext context, + IDirectoryInfo childrenDirectory) { if (children.Count == 0) return []; - var childrenDirectory = context.ReadFileSystem.DirectoryInfo.New( - Path.Join(context.DocumentationSourceDirectory.FullName, "api", productKey)); - var resolved = new List(); foreach (var child in children) { @@ -620,12 +624,24 @@ private static List ResolveApiChildren(string productKey, List + childFile.Directory is not null + && string.Equals(childFile.Directory.FullName, apiDirectory.FullName, StringComparison.OrdinalIgnoreCase) + && ResolvedApiConfiguration.IsSupplementalFileName(childFile.Name); + private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context) { DocSetRegistry entryRegistry; diff --git a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs index 43158b5d47..ebdd052e78 100644 --- a/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Toc/ApiConfiguration.cs @@ -180,11 +180,60 @@ public class ResolvedApiConfiguration public List Children { get; init; } = []; /// - /// Gets all child Markdown file paths that should be excluded from normal HTML generation. + /// The api/<key>/ directory for this product, whether or not it exists yet. + /// Supplemental op-*.md / tag-*.md files are discovered from here. + /// + public IDirectoryInfo? ApiContentDirectory { get; init; } + + /// + /// Whether is an auto-discovered supplemental file + /// (op-*.md or tag-*.md), including version-suffixed names. + /// + public static bool IsSupplementalFileName(string fileName) + { + var name = Path.GetFileName(fileName); + return name.StartsWith("op-", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("tag-", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Markdown paths that must not be rendered by the normal HTML pipeline: + /// explicit children: pages and convention supplemental files. /// public IEnumerable GetMarkdownPathsToExclude(string documentationSourceDirectoryFullName) { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in Children) - yield return Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); + { + var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName); + if (seen.Add(relative)) + yield return relative; + } + + foreach (var file in EnumerateApiMarkdownFiles()) + { + if (!IsSupplementalFileName(file.Name)) + continue; + var relative = ToRelativeMarkdownPath(file, documentationSourceDirectoryFullName); + if (seen.Add(relative)) + yield return relative; + } } + + /// Top-level Markdown files under , when the folder exists. + public IEnumerable EnumerateApiMarkdownFiles() + { + if (ApiContentDirectory is not { } dir) + yield break; + + dir.Refresh(); + if (!dir.Exists) + yield break; + + foreach (var file in dir.EnumerateFiles("*.md")) + yield return file; + } + + private static string ToRelativeMarkdownPath(IFileInfo file, string documentationSourceDirectoryFullName) => + Path.GetRelativePath(documentationSourceDirectoryFullName, file.FullName).Replace(Path.DirectorySeparatorChar, '/'); } diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 9853605938..2d3a37558a 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -53,6 +53,7 @@ public partial class DocumentationGenerator public DocumentationSet DocumentationSet { get; } public BuildContext Context { get; } public IMarkdownStringRenderer MarkdownStringRenderer => HtmlWriter; + private HashSet ApiMarkdownExcludePaths { get; } public DocumentationGenerator( DocumentationSet docSet, @@ -76,6 +77,7 @@ public DocumentationGenerator( DocumentationSet = docSet; PositionalNavigation = positionalNavigation ?? docSet; Context = docSet.Context; + ApiMarkdownExcludePaths = BuildApiMarkdownExcludePaths(); // Use the provided inferrer or create a default one _documentInferrer = documentInferrer ?? new DocumentInferrerService( @@ -502,26 +504,29 @@ public async Task RenderLayout(MarkdownFile markdown, Cancel ctx) } /// - /// Checks if a file path is registered as an explicit children: page in any API - /// configuration. These files render via the API pipeline rather than normal HTML generation. + /// True when the file is an API children: page or a convention supplemental file. + /// Those files skip the normal HTML pipeline. /// private bool IsApiMarkdownFile(string relativePath) { var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + return ApiMarkdownExcludePaths.Contains(normalized); + } - if (Context.Configuration.ApiConfigurations == null) - return false; + private HashSet BuildApiMarkdownExcludePaths() + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Context.Configuration.ApiConfigurations is null) + return set; + var docsRoot = Context.DocumentationSourceDirectory.FullName; foreach (var apiConfig in Context.Configuration.ApiConfigurations.Values) { - foreach (var childPath in apiConfig.GetMarkdownPathsToExclude(Context.DocumentationSourceDirectory.FullName)) - { - if (string.Equals(normalized, childPath, StringComparison.OrdinalIgnoreCase)) - return true; - } + foreach (var path in apiConfig.GetMarkdownPathsToExclude(docsRoot)) + _ = set.Add(path); } - return false; + return set; } } diff --git a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs index 06130da5be..6ba4d442b8 100644 --- a/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs +++ b/src/services/Elastic.Documentation.Assembler/Building/SitemapBuilder.cs @@ -30,7 +30,7 @@ public static SitemapResult Generate( IDirectoryInfo outputFolder ) { - // API pages are generated only on staging (assembler-api-explorer flag) and /docs/api/* is still + // API pages are generated on staging and preview (assembler-api-explorer flag). /docs/api/* is still // proxied to bump.sh at the edge (#725). Keep them out of the sitemap until cutover. var filtered = entries .Where(e => !e.Key.StartsWith("/docs/api/", StringComparison.Ordinal)) diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 7e667d6600..77428c8ad4 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -60,7 +60,7 @@ bool isWatchBuild // Track OpenAPI spec file modification times to detect changes private readonly Dictionary _openApiSpecLastModified = []; - // Track intro/outro markdown file modification times to detect changes + // Track API markdown modification times so serve reloads on overlay and children: edits. private readonly Dictionary _apiMarkdownFilesLastModified = []; private volatile bool _apiReferencesStale = true; @@ -71,8 +71,13 @@ public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) { // Content-only changes (e.g. .md edits) don't need a full rebuild: // RenderLayout -> ParseFullAsync reads fresh content from disk on each request. + // API overlay files are an exception: they are baked into generated HTML, so mark + // refs stale and let EnsureApiReferencesAsync re-check timestamps on the next /api request. if (!reloadConfiguration && _cachedCrossLinks is not null) + { + _apiReferencesStale = true; return; + } SourcePath.Refresh(); OutputPath.Refresh(); @@ -151,35 +156,55 @@ private bool HaveOpenApiSpecsChanged(ConfigurationFile config) if (config.ApiConfigurations is null) return false; - // First run - no timestamps yet if (_openApiSpecLastModified.Count == 0 && _apiMarkdownFilesLastModified.Count == 0) return true; foreach (var apiConfig in config.ApiConfigurations.Values) { - // The local spec override, when present. A spec with no local file resolves - // remotely and has nothing on disk to watch here. if (apiConfig.LocalSpecFile is { } specFile) { specFile.Refresh(); if (!_openApiSpecLastModified.TryGetValue(specFile.FullName, out var lastModified)) - return true; // New file + return true; if (specFile.LastWriteTimeUtc > lastModified) - return true; // File modified + return true; } + } + + return HaveApiMarkdownFilesChanged(config); + } + + private bool HaveApiMarkdownFilesChanged(ConfigurationFile config) + { + var current = CurrentApiMarkdownTimestamps(config); + if (current.Count != _apiMarkdownFilesLastModified.Count) + return true; + + foreach (var (path, time) in current) + { + if (!_apiMarkdownFilesLastModified.TryGetValue(path, out var lastModified) || time > lastModified) + return true; + } + + return false; + } + + private static Dictionary CurrentApiMarkdownTimestamps(ConfigurationFile config) + { + var current = new Dictionary(StringComparer.Ordinal); + if (config.ApiConfigurations is null) + return current; - // Explicit children declared via 'children:' - foreach (var childFile in apiConfig.Children) + foreach (var apiConfig in config.ApiConfigurations.Values) + { + foreach (var file in apiConfig.EnumerateApiMarkdownFiles().Concat(apiConfig.Children)) { - childFile.Refresh(); - if (!_apiMarkdownFilesLastModified.TryGetValue(childFile.FullName, out var lastModified)) - return true; // New file - if (childFile.LastWriteTimeUtc > lastModified) - return true; // File modified + file.Refresh(); + current[file.FullName] = file.LastWriteTimeUtc; } } - return false; + return current; } private void UpdateOpenApiSpecTimestamps(ConfigurationFile config) @@ -197,13 +222,10 @@ private void UpdateOpenApiSpecTimestamps(ConfigurationFile config) specFile.Refresh(); _openApiSpecLastModified[specFile.FullName] = specFile.LastWriteTimeUtc; } - - foreach (var childFile in apiConfig.Children) - { - childFile.Refresh(); - _apiMarkdownFilesLastModified[childFile.FullName] = childFile.LastWriteTimeUtc; - } } + + foreach (var (path, time) in CurrentApiMarkdownTimestamps(config)) + _apiMarkdownFilesLastModified[path] = time; } public async Task ReloadApiReferences(Cancel ctx) => await ReloadApiReferences(_generator.MarkdownStringRenderer, ctx); diff --git a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs index 22c4dba606..17205421f3 100644 --- a/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs +++ b/tests-integration/Elastic.Documentation.IntegrationTests/AssemblerConfigurationTests.cs @@ -96,11 +96,18 @@ public void ReadsContentSource() } [Fact] - public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() + public void StagingEnvironment_EnablesAssemblerApiExplorerFlag() => + AssertEnvironmentEnablesAssemblerApiExplorer("staging"); + + [Fact] + public void PreviewEnvironment_EnablesAssemblerApiExplorerFlag() => + AssertEnvironmentEnablesAssemblerApiExplorer("preview"); + + private void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) { - var staging = Context.Configuration.Environments["staging"]; + var environment = Context.Configuration.Environments[environmentName]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") + environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") .WhoseValue.Should().BeTrue(); } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs index 6bad9da9f8..a7e8404e7f 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorCurrentSpecResolutionTests.cs @@ -57,7 +57,7 @@ public async Task ResolveDocumentsForProduct_VersionlessLocalSpec_RendersLocalFi }; var context = CreateContext(collector, versionless, products); var localFile = new FileSystem().FileInfo.New( - Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json")); var expectedDocument = SpecDocument(); var reader = A.Fake(); A.CallTo(() => reader.ReadAsync(localFile)).Returns(expectedDocument); diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs index 7eff1c5823..1f85d086fa 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMultiVersionTests.cs @@ -137,7 +137,7 @@ public async Task ResolveDocumentsForProduct_LocalMainAndRemoteHistoricalVersion var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9); var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack)); var context = CreateContext(collector, stack, ProductsFor(product), GitForElasticsearch()); - var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json")); + var localFile = new FileSystem().FileInfo.New(Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json")); var localDocument = SpecDocument("Elasticsearch local main"); using var versionIndexClient = new VersionIndexClient(BaseUri, MultiVersionHandler(), sleep: (_, _) => Task.CompletedTask); var reader = A.Fake(); diff --git a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs index 88672a7060..635386e235 100644 --- a/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ReaderTests.cs @@ -21,7 +21,7 @@ public class ReaderTests private static IFileInfo LocalSpecFile() { var fileSystem = new FileSystem(); - var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch-openapi-docs.json"); + var path = fileSystem.Path.Combine(Paths.WorkingDirectoryRoot.FullName, "docs", "elasticsearch.json"); return fileSystem.FileInfo.New(path); } diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs new file mode 100644 index 0000000000..64c5929da9 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalDiscoveryTests.cs @@ -0,0 +1,188 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.ApiExplorer.Model; +using Elastic.ApiExplorer.Supplemental; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class ApiSupplementalDiscoveryTests +{ + private const string Folder = "/docs/api/fixture"; + + [Fact] + public void Discover_MissingFolder_ReturnsEmpty() + { + var fs = new MockFileSystem(); + var folder = fs.DirectoryInfo.New("/docs/api/missing"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], ["search"]); + + result.Operations.Should().BeEmpty(); + result.Tags.Should().BeEmpty(); + result.Unmatched.Should().BeEmpty(); + result.Ignored.Should().BeEmpty(); + result.VersionSuffixed.Should().BeEmpty(); + } + + [Fact] + public void Discover_NullFolder_ReturnsEmpty() + { + var result = ApiSupplementalDiscovery.Discover(null, ["search"], ["search"]); + + result.Operations.Should().BeEmpty(); + } + + [Fact] + public void Discover_MatchesExactOperationId() + { + var folder = FolderWith( + "op-search.md", + "op-getAlertingHealth.md", + "op-cluster-health.md"); + + var result = ApiSupplementalDiscovery.Discover( + folder, + ["search", "getAlertingHealth", "cluster.health"], + []); + + result.Operations.Keys.Should().BeEquivalentTo("search", "getAlertingHealth"); + result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-cluster-health.md"); + } + + [Fact] + public void Discover_DoesNotMatchDifferentCasing() + { + var folder = FolderWith("op-getalertinghealth.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["getAlertingHealth"], []); + + result.Operations.Should().BeEmpty(); + result.Unmatched.Select(f => f.Name).Should().ContainSingle().Which.Should().Be("op-getalertinghealth.md"); + } + + [Fact] + public void Discover_MatchesTagUrlSlug() + { + var folder = FolderWith( + "tag-ml-anomaly.md", + "tag-health_report.md", + "tag-apm-agent-configuration.md"); + + var result = ApiSupplementalDiscovery.Discover( + folder, + [], + ["ml anomaly", "health_report", "APM agent configuration"]); + + result.Tags.Should().ContainKey("ml anomaly"); + result.Tags.Should().ContainKey("health_report"); + result.Tags.Should().ContainKey("APM agent configuration"); + result.Unmatched.Should().BeEmpty(); + } + + [Fact] + public void Discover_IgnoresNonConventionFiles() + { + var folder = FolderWith("random-notes.md", "getting-started.md", "op-search.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Ignored.Select(f => f.Name).Should().BeEquivalentTo("random-notes.md", "getting-started.md"); + result.Operations.Should().ContainKey("search"); + } + + [Fact] + public void Discover_UnmatchedConventionFile_IsNotAnError() + { + var folder = FolderWith("op-does-not-exist.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Unmatched.Should().ContainSingle(f => f.Name == "op-does-not-exist.md"); + result.Operations.Should().BeEmpty(); + } + + [Fact] + public void Discover_VersionSuffixedFile_IsClassifiedSeparately() + { + var folder = FolderWith("op-search.v8.md", "op-search.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, ["search"], []); + + result.Operations.Should().ContainKey("search"); + result.VersionSuffixed.Should().ContainSingle(v => v.File.Name == "op-search.v8.md" && v.Name.VersionMajor == 8); + result.Unmatched.Should().BeEmpty(); + } + + [Fact] + public void Discover_TagSlugCollision_IsRecordedAndFileUnmatched() + { + var folder = FolderWith("tag-foo-bar.md"); + + var result = ApiSupplementalDiscovery.Discover(folder, [], ["foo bar", "foo-bar"]); + + result.TagSlugCollisions.Should().ContainSingle(c => c.Slug == "foo-bar"); + result.TagSlugCollisions[0].TagNames.Should().BeEquivalentTo("foo bar", "foo-bar"); + result.Tags.Should().BeEmpty(); + result.Unmatched.Should().ContainSingle(f => f.Name == "tag-foo-bar.md"); + } + + [Fact] + public async Task Discover_FixtureDocument_MatchesSearchAndDocsGet() + { + var folder = FolderWith("op-search.md", "op-docs-get.md", "tag-search.md", "random-notes.md"); + var path = Path.Combine(AppContext.BaseDirectory, "TestData", "api-explorer-fixture.json"); + var document = await OpenApiReader.Instance.ReadAsync(new System.IO.Abstractions.FileSystem().FileInfo.New(path)) + ?? throw new InvalidOperationException("Could not read fixture spec"); + + var result = ApiSupplementalDiscovery.Discover(folder, document); + + result.Operations.Should().ContainKey("search"); + result.Operations.Should().ContainKey("docs-get"); + result.Tags.Should().ContainKey("search"); + result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md"); + } + + [Fact] + public async Task Discover_InlineOperationTagsWithoutDocumentTags_MatchTagFile() + { + var folder = FolderWith("tag-search.md"); + var specJson = /*lang=json,strict*/ """ + { + "openapi": "3.0.3", + "info": { "title": "t", "version": "1" }, + "paths": { + "/search": { + "get": { + "operationId": "search", + "tags": ["search"] + } + } + } + } + """; + var fs = new MockFileSystem(new Dictionary + { + ["/spec.json"] = new MockFileData(specJson) + }); + var document = await OpenApiReader.Instance.ReadAsync(fs.FileInfo.New("/spec.json")) + ?? throw new InvalidOperationException("Could not read spec"); + + var result = ApiSupplementalDiscovery.Discover(folder, document); + + result.Tags.Should().ContainKey("search"); + result.Unmatched.Should().BeEmpty(); + } + + private static System.IO.Abstractions.IDirectoryInfo FolderWith(params string[] fileNames) + { + var files = fileNames.ToDictionary( + name => $"{Folder}/{name}", + _ => new MockFileData("# supplemental")); + var fs = new MockFileSystem(files); + return fs.DirectoryInfo.New(Folder); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs new file mode 100644 index 0000000000..1521604e44 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalNameTests.cs @@ -0,0 +1,59 @@ +// 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 AwesomeAssertions; +using Elastic.ApiExplorer.Infrastructure; +using Elastic.ApiExplorer.Supplemental; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class ApiSupplementalNameTests +{ + [Theory] + [InlineData("op-search.md", ApiSupplementalKind.Operation, "search", null)] + [InlineData("op-getAlertingHealth.md", ApiSupplementalKind.Operation, "getAlertingHealth", null)] + [InlineData("op-search.v8.md", ApiSupplementalKind.Operation, "search", 8)] + [InlineData("tag-ml-anomaly.md", ApiSupplementalKind.Tag, "ml-anomaly", null)] + [InlineData("tag-health_report.md", ApiSupplementalKind.Tag, "health_report", null)] + [InlineData("tag-apm-agent-configuration.v9.md", ApiSupplementalKind.Tag, "apm-agent-configuration", 9)] + public void TryParse_ConventionFile_ReturnsKindStemAndVersion( + string fileName, ApiSupplementalKind kind, string stem, int? version) + { + ApiSupplementalName.TryParse(fileName, out var parsed).Should().BeTrue(); + parsed.Kind.Should().Be(kind); + parsed.Stem.Should().Be(stem); + parsed.VersionMajor.Should().Be(version); + parsed.IsVersionSuffixed.Should().Be(version is not null); + } + + [Theory] + [InlineData("random-notes.md")] + [InlineData("getting-started.md")] + [InlineData("index.md")] + [InlineData("op-.md")] + [InlineData("search.md")] + [InlineData("op-search.txt")] + public void TryParse_NonConventionFile_ReturnsFalse(string fileName) + { + ApiSupplementalName.TryParse(fileName, out _).Should().BeFalse(); + } + + [Theory] + [InlineData("APM agent configuration", "apm-agent-configuration")] + [InlineData("health_report", "health_report")] + [InlineData("ml anomaly", "ml-anomaly")] + public void TagSlug_MatchesExpectedFileStem(string tagName, string expectedStem) + { + ApiUrlBuilder.TagSlug(tagName).Should().Be(expectedStem); + ApiUrlBuilder.TagMoniker(tagName).Should().Be($"endpoint-{expectedStem}"); + } + + [Fact] + public void TagSlug_EmptyName_IsUnknown() + { + ApiUrlBuilder.TagSlug("").Should().Be("unknown"); + ApiUrlBuilder.TagMoniker("").Should().Be("endpoint-unknown"); + ApiUrlBuilder.TagMoniker(null).Should().Be("endpoint-unknown"); + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs new file mode 100644 index 0000000000..08acc1614d --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/OpenApiGeneratorSupplementalTests.cs @@ -0,0 +1,46 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.ApiExplorer.Supplemental; +using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Elastic.ApiExplorer.Tests.Supplemental; + +public class OpenApiGeneratorSupplementalTests(ApiExplorerFixture fixture) : IClassFixture +{ + [Fact] + public void DiscoverSupplemental_MatchesFixtureFilesAndLeavesHtmlUnchanged() + { + var folder = "/docs/api/fixture"; + var fs = new MockFileSystem(new Dictionary + { + [$"{folder}/op-search.md"] = new("# search"), + [$"{folder}/tag-search.md"] = new("# tag"), + [$"{folder}/random-notes.md"] = new("# notes"), + [$"{folder}/op-nope.md"] = new("# unmatched") + }); + var apiConfig = new ResolvedApiConfiguration + { + ProductKey = "fixture", + Product = new Product { Id = "elasticsearch", DisplayName = "Elasticsearch" }, + SpecFileName = "api-explorer-fixture.json", + ApiContentDirectory = fs.DirectoryInfo.New(folder) + }; + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, fixture.Context, PassthroughMarkdownRenderer.Instance); + var result = generator.DiscoverSupplemental(fixture.Document, apiConfig); + + result.Operations.Should().ContainKey("search"); + result.Tags.Should().ContainKey("search"); + result.Ignored.Should().ContainSingle(f => f.Name == "random-notes.md"); + result.Unmatched.Should().ContainSingle(f => f.Name == "op-nope.md"); + + var navigation = generator.CreateNavigation("fixture", fixture.Document, apiConfig); + navigation.Should().NotBeNull(); + } +} diff --git a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs index eb034e3a48..2b85421268 100644 --- a/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/FeatureFlagsTests.cs @@ -50,16 +50,23 @@ public void AssemblerApiExplorerEnabled_EnvironmentVariableOverridesYaml() } [Fact] - public void StagingEnvironment_EnablesAssemblerApiExplorer() + public void StagingEnvironment_EnablesAssemblerApiExplorer() => + AssertEnvironmentEnablesAssemblerApiExplorer("staging"); + + [Fact] + public void PreviewEnvironment_EnablesAssemblerApiExplorer() => + AssertEnvironmentEnablesAssemblerApiExplorer("preview"); + + private static void AssertEnvironmentEnablesAssemblerApiExplorer(string environmentName) { var config = AssemblyConfiguration.Create(new ConfigurationFileProvider(new TestLoggerFactory(null), new ConfigurationFileSystem())); - var staging = config.Environments["staging"]; + var environment = config.Environments[environmentName]; - staging.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") + environment.FeatureFlags.Should().ContainKey("ASSEMBLER_API_EXPLORER") .WhoseValue.Should().BeTrue(); var features = new FeatureFlags([]); - foreach (var (key, value) in staging.FeatureFlags) + foreach (var (key, value) in environment.FeatureFlags) features.Set(key, value); features.AssemblerApiExplorerEnabled.Should().BeTrue(); } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs index 14bb6a8923..862882d862 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ApiConfigurationTests.cs @@ -588,10 +588,125 @@ public void EmitsError_WhenChildPathEscapesApiKeyDirectory() config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty(); } + [Fact] + public void EmitsError_WhenChildFileUsesSupplementalName() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "op-search.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["op-search.md"]); + + collector.Errors.Should().Be(1); + config.ApiConfigurations!["elasticsearch"].Children.Should().BeEmpty(); + } + + [Fact] + public void AcceptsNestedChildWhoseBasenameLooksSupplemental() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "guides/op-overview.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile, extraMarkdownFiles: ["guides/op-overview.md"]); + + collector.Errors.Should().Be(0); + config.ApiConfigurations!["elasticsearch"].Children.Should().ContainSingle(f => f.Name == "op-overview.md"); + } + + [Fact] + public void GetMarkdownPathsToExclude_IncludesChildrenAndSupplementalFiles() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = + [ + new ApiProductEntry + { + Spec = "elasticsearch-openapi.json", + Product = "elasticsearch", + Children = [new ApiEntryChild { File = "getting-started.md" }] + } + ] + } + } + }; + + var (config, collector) = CreateConfiguration( + docSetFile, + extraMarkdownFiles: ["op-search.md", "tag-documents.md", "random-notes.md"]); + + collector.Errors.Should().Be(0); + var docsRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "docs"); + var excluded = config.ApiConfigurations!["elasticsearch"] + .GetMarkdownPathsToExclude(docsRoot) + .ToArray(); + + excluded.Should().Contain("api/elasticsearch/getting-started.md"); + excluded.Should().Contain("api/elasticsearch/op-search.md"); + excluded.Should().Contain("api/elasticsearch/tag-documents.md"); + excluded.Should().NotContain("api/elasticsearch/random-notes.md"); + } + + [Fact] + public void ApiContentDirectory_IsSetToApiKeyFolder() + { + var docSetFile = new DocumentationSetFile + { + Api = new Dictionary + { + ["elasticsearch"] = new() + { + Entries = [new ApiProductEntry { Spec = "elasticsearch-openapi.json", Product = "elasticsearch" }] + } + } + }; + + var (config, collector) = CreateConfiguration(docSetFile); + + collector.Errors.Should().Be(0); + config.ApiConfigurations!["elasticsearch"].ApiContentDirectory.Should().NotBeNull(); + config.ApiConfigurations["elasticsearch"].ApiContentDirectory!.Name.Should().Be("elasticsearch"); + } + private static readonly string[] DefaultProductIds = ["elasticsearch", "kibana"]; private static (ConfigurationFile Config, DiagnosticsCollector Collector) CreateConfiguration( - DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true) + DocumentationSetFile docSet, string[]? extraProducts = null, bool withLocalSpecFile = true, string[]? extraMarkdownFiles = null) { var collector = new DiagnosticsCollector([]); var root = Paths.WorkingDirectoryRoot.FullName; @@ -604,6 +719,8 @@ private static (ConfigurationFile Config, DiagnosticsCollector Collector) Create }; if (withLocalSpecFile) 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"); var fileSystem = new MockFileSystem(files, root); var configPath = fileSystem.FileInfo.New(configFilePath); diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index 2a8cb49247..3a8a9d67dd 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -48,7 +48,12 @@ public void PhysicalDocsetFileCanBeDeserialized() docSet.Subs.Should().NotBeEmpty(); docSet.Subs.Should().ContainKey("dbuild").WhoseValue.Should().Be("docs-builder"); - docSet.Api.Should().BeNullOrEmpty("API declarations live in docs-content for assembler builds"); + docSet.Api.Should().ContainKey("docs-builder-elasticsearch"); + var apiEntry = docSet.Api["docs-builder-elasticsearch"].SingleEntry; + apiEntry.Should().NotBeNull(); + apiEntry.Spec.Should().Be("elasticsearch.json"); + apiEntry.Product.Should().Be("elasticsearch"); + apiEntry.Repository.Should().Be("elastic/elasticsearch-specification"); docSet.TableOfContents.Should().NotBeEmpty();