diff --git a/Directory.Packages.props b/Directory.Packages.props
index deb03b526..40a57e8b4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,7 +29,7 @@
-
+
diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs
index 2aba29981..978a1f1fe 100644
--- a/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs
+++ b/src/Elastic.Documentation/AppliesTo/ApplicableTo.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information
using System.Collections;
+using System.Text;
using System.Text.Json.Serialization;
using Elastic.Documentation.Diagnostics;
using YamlDotNet.Serialization;
@@ -34,6 +35,7 @@ public interface IApplicableToElement
}
[YamlSerializable]
+[JsonConverter(typeof(ApplicableToJsonConverter))]
public record ApplicableTo
{
[YamlMember(Alias = "stack")]
@@ -61,6 +63,58 @@ public record ApplicableTo
Deployment = DeploymentApplicability.All,
Product = AppliesCollection.GenerallyAvailable
};
+
+ public static ApplicableTo Default { get; } = new()
+ {
+ Stack = new AppliesCollection([new Applicability { Version = new SemVersion(9, 0, 0), Lifecycle = ProductLifecycle.GenerallyAvailable }]),
+ Serverless = ServerlessProjectApplicability.All
+ };
+
+ ///
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ var hasContent = false;
+
+ if (Stack is not null)
+ {
+ _ = sb.Append("stack: ").Append(Stack);
+ hasContent = true;
+ }
+
+ if (Deployment is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("deployment: ").Append(Deployment);
+ hasContent = true;
+ }
+
+ if (Serverless is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("serverless: ").Append(Serverless);
+ hasContent = true;
+ }
+
+ if (Product is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("product: ").Append(Product);
+ hasContent = true;
+ }
+
+ if (ProductApplicability is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("products: ").Append(ProductApplicability);
+ }
+
+ return sb.ToString();
+ }
}
[YamlSerializable]
@@ -85,6 +139,44 @@ public record DeploymentApplicability
Ess = AppliesCollection.GenerallyAvailable,
Self = AppliesCollection.GenerallyAvailable
};
+
+ ///
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ var hasContent = false;
+
+ if (Self is not null)
+ {
+ _ = sb.Append("self=").Append(Self);
+ hasContent = true;
+ }
+
+ if (Ece is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("ece=").Append(Ece);
+ hasContent = true;
+ }
+
+ if (Eck is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("eck=").Append(Eck);
+ hasContent = true;
+ }
+
+ if (Ess is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("ess=").Append(Ess);
+ }
+
+ return sb.ToString();
+ }
}
[YamlSerializable]
@@ -113,6 +205,36 @@ public record ServerlessProjectApplicability
Observability = AppliesCollection.GenerallyAvailable,
Security = AppliesCollection.GenerallyAvailable
};
+
+ ///
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ var hasContent = false;
+
+ if (Elasticsearch is not null)
+ {
+ _ = sb.Append("elasticsearch=").Append(Elasticsearch);
+ hasContent = true;
+ }
+
+ if (Observability is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("observability=").Append(Observability);
+ hasContent = true;
+ }
+
+ if (Security is not null)
+ {
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append("security=").Append(Security);
+ }
+
+ return sb.ToString();
+ }
}
[YamlSerializable]
@@ -183,4 +305,46 @@ public record ProductApplicability
[YamlMember(Alias = "edot-collector")]
public AppliesCollection? EdotCollector { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ var hasContent = false;
+
+ void AppendProduct(string name, AppliesCollection? value)
+ {
+ if (value is null)
+ return;
+ if (hasContent)
+ _ = sb.Append(", ");
+ _ = sb.Append(name).Append('=').Append(value);
+ hasContent = true;
+ }
+
+ AppendProduct("ecctl", Ecctl);
+ AppendProduct("curator", Curator);
+ AppendProduct("apm-agent-android", ApmAgentAndroid);
+ AppendProduct("apm-agent-dotnet", ApmAgentDotnet);
+ AppendProduct("apm-agent-go", ApmAgentGo);
+ AppendProduct("apm-agent-ios", ApmAgentIos);
+ AppendProduct("apm-agent-java", ApmAgentJava);
+ AppendProduct("apm-agent-node", ApmAgentNode);
+ AppendProduct("apm-agent-php", ApmAgentPhp);
+ AppendProduct("apm-agent-python", ApmAgentPython);
+ AppendProduct("apm-agent-ruby", ApmAgentRuby);
+ AppendProduct("apm-agent-rum-js", ApmAgentRumJs);
+ AppendProduct("edot-ios", EdotIos);
+ AppendProduct("edot-android", EdotAndroid);
+ AppendProduct("edot-dotnet", EdotDotnet);
+ AppendProduct("edot-java", EdotJava);
+ AppendProduct("edot-node", EdotNode);
+ AppendProduct("edot-php", EdotPhp);
+ AppendProduct("edot-python", EdotPython);
+ AppendProduct("edot-cf-aws", EdotCfAws);
+ AppendProduct("edot-cf-azure", EdotCfAzure);
+ AppendProduct("edot-collector", EdotCollector);
+
+ return sb.ToString();
+ }
}
diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs
new file mode 100644
index 000000000..d3779e525
--- /dev/null
+++ b/src/Elastic.Documentation/AppliesTo/ApplicableToJsonConverter.cs
@@ -0,0 +1,263 @@
+// 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.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using YamlDotNet.Serialization;
+
+namespace Elastic.Documentation.AppliesTo;
+
+///
+/// JSON converter for ApplicableTo that serializes to a flat array of objects with:
+/// - type: stack, deployment, serverless, or product
+/// - sub-type: the property name (e.g., "self", "ece", "elasticsearch", "ecctl")
+/// - lifecycle: the lifecycle value (if applicable)
+/// - version: the version value (if applicable)
+///
+public class ApplicableToJsonConverter : JsonConverter
+{
+ public override ApplicableTo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ return null;
+
+ if (reader.TokenType != JsonTokenType.StartArray)
+ throw new JsonException("Expected array");
+
+ var result = new ApplicableTo();
+ var deploymentProps = new Dictionary>();
+ var serverlessProps = new Dictionary>();
+ var productProps = new Dictionary>();
+ var stackItems = new List();
+ var productItems = new List();
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException("Expected object");
+
+ string? type = null;
+ string? subType = null;
+ var lifecycle = ProductLifecycle.GenerallyAvailable;
+ SemVersion? version = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ throw new JsonException("Expected property name");
+
+ var propertyName = reader.GetString();
+ _ = reader.Read();
+
+ switch (propertyName)
+ {
+ case "type":
+ type = reader.GetString();
+ break;
+ case "sub_type":
+ subType = reader.GetString();
+ break;
+ case "lifecycle":
+ var lifecycleStr = reader.GetString();
+ if (lifecycleStr != null)
+ lifecycle = ParseLifecycle(lifecycleStr);
+ break;
+ case "version":
+ var versionStr = reader.GetString();
+ if (versionStr != null && SemVersionConverter.TryParse(versionStr, out var v))
+ version = v;
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(subType))
+ throw new JsonException("Missing type or sub-type");
+
+ var applicability = new Applicability { Lifecycle = lifecycle, Version = version };
+
+ switch (type)
+ {
+ case "stack":
+ stackItems.Add(applicability);
+ break;
+ case "deployment":
+ if (!deploymentProps.ContainsKey(subType))
+ deploymentProps[subType] = [];
+ deploymentProps[subType].Add(applicability);
+ break;
+ case "serverless":
+ if (!serverlessProps.ContainsKey(subType))
+ serverlessProps[subType] = [];
+ serverlessProps[subType].Add(applicability);
+ break;
+ case "product" when subType == "product":
+ productItems.Add(applicability);
+ break;
+ case "product":
+ if (!productProps.ContainsKey(subType))
+ productProps[subType] = [];
+ productProps[subType].Add(applicability);
+ break;
+ }
+ }
+
+ // Create Stack collection
+ if (stackItems.Count > 0)
+ result.Stack = new AppliesCollection(stackItems.ToArray());
+
+ // Create Product collection
+ if (productItems.Count > 0)
+ result.Product = new AppliesCollection(productItems.ToArray());
+
+ // Reconstruct DeploymentApplicability
+ if (deploymentProps.Count > 0)
+ {
+ result.Deployment = new DeploymentApplicability
+ {
+ Self = deploymentProps.TryGetValue("self", out var self) ? new AppliesCollection(self.ToArray()) : null,
+ Ece = deploymentProps.TryGetValue("ece", out var ece) ? new AppliesCollection(ece.ToArray()) : null,
+ Eck = deploymentProps.TryGetValue("eck", out var eck) ? new AppliesCollection(eck.ToArray()) : null,
+ Ess = deploymentProps.TryGetValue("ess", out var ess) ? new AppliesCollection(ess.ToArray()) : null
+ };
+ }
+
+ // Reconstruct ServerlessProjectApplicability
+ if (serverlessProps.Count > 0)
+ {
+ result.Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = serverlessProps.TryGetValue("elasticsearch", out var es) ? new AppliesCollection(es.ToArray()) : null,
+ Observability = serverlessProps.TryGetValue("observability", out var obs) ? new AppliesCollection(obs.ToArray()) : null,
+ Security = serverlessProps.TryGetValue("security", out var sec) ? new AppliesCollection(sec.ToArray()) : null
+ };
+ }
+
+ // Reconstruct ProductApplicability
+ if (productProps.Count > 0)
+ {
+ var productApplicability = new ProductApplicability();
+ var productType = typeof(ProductApplicability);
+
+ foreach (var (key, items) in productProps)
+ {
+ // Find the property by YamlMember alias
+ var property = productType.GetProperties()
+ .FirstOrDefault(p => p.GetCustomAttribute()?.Alias == key);
+
+ property?.SetValue(productApplicability, new AppliesCollection(items.ToArray()));
+ }
+
+ result.ProductApplicability = productApplicability;
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, ApplicableTo value, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+
+ // Stack
+ if (value.Stack != null)
+ WriteApplicabilityEntries(writer, "stack", "stack", value.Stack);
+
+ // Deployment
+ if (value.Deployment != null)
+ {
+ if (value.Deployment.Self != null)
+ WriteApplicabilityEntries(writer, "deployment", "self", value.Deployment.Self);
+ if (value.Deployment.Ece != null)
+ WriteApplicabilityEntries(writer, "deployment", "ece", value.Deployment.Ece);
+ if (value.Deployment.Eck != null)
+ WriteApplicabilityEntries(writer, "deployment", "eck", value.Deployment.Eck);
+ if (value.Deployment.Ess != null)
+ WriteApplicabilityEntries(writer, "deployment", "ess", value.Deployment.Ess);
+ }
+
+ // Serverless
+ if (value.Serverless != null)
+ {
+ if (value.Serverless.Elasticsearch != null)
+ WriteApplicabilityEntries(writer, "serverless", "elasticsearch", value.Serverless.Elasticsearch);
+ if (value.Serverless.Observability != null)
+ WriteApplicabilityEntries(writer, "serverless", "observability", value.Serverless.Observability);
+ if (value.Serverless.Security != null)
+ WriteApplicabilityEntries(writer, "serverless", "security", value.Serverless.Security);
+ }
+
+ // Product (simple)
+ if (value.Product != null)
+ WriteApplicabilityEntries(writer, "product", "product", value.Product);
+
+ // ProductApplicability (specific products)
+ if (value.ProductApplicability != null)
+ {
+ var productType = typeof(ProductApplicability);
+ foreach (var property in productType.GetProperties())
+ {
+ var yamlAlias = property.GetCustomAttribute()?.Alias;
+ if (yamlAlias != null)
+ {
+ if (property.GetValue(value.ProductApplicability) is AppliesCollection propertyValue)
+ WriteApplicabilityEntries(writer, "product", yamlAlias, propertyValue);
+ }
+ }
+ }
+
+ writer.WriteEndArray();
+ }
+
+ private static ProductLifecycle ParseLifecycle(string lifecycleStr) => lifecycleStr.ToLowerInvariant() switch
+ {
+ "preview" => ProductLifecycle.TechnicalPreview,
+ "beta" => ProductLifecycle.Beta,
+ "ga" => ProductLifecycle.GenerallyAvailable,
+ "deprecated" => ProductLifecycle.Deprecated,
+ "removed" => ProductLifecycle.Removed,
+ "unavailable" => ProductLifecycle.Unavailable,
+ "development" => ProductLifecycle.Development,
+ "planned" => ProductLifecycle.Planned,
+ "discontinued" => ProductLifecycle.Discontinued,
+ _ => ProductLifecycle.GenerallyAvailable
+ };
+
+ private static void WriteApplicabilityEntries(Utf8JsonWriter writer, string type, string subType, AppliesCollection collection)
+ {
+ foreach (var applicability in collection)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("type", type);
+ writer.WriteString("sub_type", subType);
+
+ // Write lifecycle
+ var lifecycleName = applicability.Lifecycle switch
+ {
+ ProductLifecycle.TechnicalPreview => "preview",
+ ProductLifecycle.Beta => "beta",
+ ProductLifecycle.GenerallyAvailable => "ga",
+ ProductLifecycle.Deprecated => "deprecated",
+ ProductLifecycle.Removed => "removed",
+ ProductLifecycle.Unavailable => "unavailable",
+ ProductLifecycle.Development => "development",
+ ProductLifecycle.Planned => "planned",
+ ProductLifecycle.Discontinued => "discontinued",
+ _ => "ga"
+ };
+ writer.WriteString("lifecycle", lifecycleName);
+
+ // Write the version
+ if (applicability.Version is not null)
+ writer.WriteString("version", applicability.Version.ToString());
+
+ writer.WriteEndObject();
+ }
+ }
+}
diff --git a/src/Elastic.Documentation/Search/DocumentationDocument.cs b/src/Elastic.Documentation/Search/DocumentationDocument.cs
index 8a0eb6781..8470fc3b5 100644
--- a/src/Elastic.Documentation/Search/DocumentationDocument.cs
+++ b/src/Elastic.Documentation/Search/DocumentationDocument.cs
@@ -4,7 +4,6 @@
using System.Text.Json.Serialization;
using Elastic.Documentation.AppliesTo;
-using Elastic.Documentation.Extensions;
namespace Elastic.Documentation.Search;
diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs
index 199724929..3068e8f59 100644
--- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs
+++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchExporter.cs
@@ -93,8 +93,8 @@ Func createOptions
options.ExportBufferCallback = () =>
{
var count = Interlocked.Increment(ref i);
- _logger.LogInformation("Exported {Count} documents to Elasticsearch index {Format}",
- count * endpoint.BufferSize, string.Format(options.IndexFormat, "latest"));
+ _logger.LogInformation("Exported {Count} documents to Elasticsearch index {IndexName}",
+ count * endpoint.BufferSize, Channel?.IndexName ?? string.Format(options.IndexFormat, "latest"));
};
options.ExportExceptionCallback = e =>
{
@@ -103,7 +103,7 @@ Func createOptions
};
options.ServerRejectionCallback = items => _logger.LogInformation("Server rejection: {Rejection}", items.First().Item2);
Channel = createChannel(options);
- _logger.LogInformation($"Bootstrapping {nameof(SemanticIndexChannel)} Elasticsearch target for indexing");
+ _logger.LogInformation("Created {Channel} Elasticsearch target for indexing", typeof(TChannel).Name);
}
public async ValueTask StopAsync(Cancel ctx = default)
@@ -193,6 +193,34 @@ protected static string CreateMapping(string? inferenceId) =>
"prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" }
}
},
+ "applies_to" : {
+ "type" : "nested",
+ "properties" : {
+ "type" : { "type" : "keyword" },
+ "sub-type" : { "type" : "keyword" },
+ "lifecycle" : { "type" : "keyword" },
+ "version" : { "type" : "version" }
+ }
+ },
+ "parents" : {
+ "type" : "object",
+ "properties" : {
+ "url" : {
+ "type": "keyword",
+ "fields": {
+ "match": { "type": "text" },
+ "prefix": { "type": "text", "analyzer" : "hierarchy_analyzer" }
+ }
+ },
+ "title": {
+ "type": "text",
+ "search_analyzer": "synonyms_analyzer",
+ "fields": {
+ "keyword": { "type": "keyword" }
+ }
+ }
+ }
+ },
"hash" : { "type" : "keyword" },
"title": {
"type": "text",
diff --git a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs
index 64c3198e0..bba070ce4 100644
--- a/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs
+++ b/src/Elastic.Markdown/Exporters/Elasticsearch/ElasticsearchMarkdownExporter.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information
using System.IO.Abstractions;
+using Elastic.Documentation.AppliesTo;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Search;
@@ -33,6 +34,8 @@ public class ElasticsearchMarkdownExporter : IMarkdownExporter, IDisposable
private readonly DateTimeOffset _batchIndexDate = DateTimeOffset.UtcNow;
private readonly DistributedTransport _transport;
private IngestStrategy _indexStrategy;
+ private string _currentLexicalHash = string.Empty;
+ private string _currentSemanticHash = string.Empty;
public ElasticsearchMarkdownExporter(
ILoggerFactory logFactory,
@@ -80,26 +83,53 @@ string indexNamespace
///
public async ValueTask StartAsync(Cancel ctx = default)
{
+ _currentLexicalHash = await _lexicalChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty;
+ _currentSemanticHash = await _semanticChannel.Channel.GetIndexTemplateHashAsync(ctx) ?? string.Empty;
+
_ = await _lexicalChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx);
- var semanticIndex = _semanticChannel.Channel.IndexName;
- var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest");
- var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx);
- if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode)
+ // if the previous hash does not match the current hash, we know already we want to multiplex to a new index
+ if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash)
+ _indexStrategy = IngestStrategy.Multiplex;
+
+ if (!_endpoint.NoSemantic)
{
- _logger.LogInformation("No semantic index exists yet, creating index {Index} for semantic search", semanticIndex);
- _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx);
- var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx);
- if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode)
- throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}");
- _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx);
- if (!_endpoint.ForceReindex)
+ var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest");
+ var semanticIndexAvailable = await _transport.HeadAsync(semanticWriteAlias, ctx);
+ if (!semanticIndexAvailable.ApiCallDetails.HasSuccessfulStatusCode && _endpoint is { ForceReindex: false, NoSemantic: false })
{
_indexStrategy = IngestStrategy.Multiplex;
- _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticIndex);
+ _logger.LogInformation("Index strategy set to multiplex because {SemanticIndex} does not exist, pass --force-reindex to always use reindex", semanticWriteAlias);
}
+
+ //try re-use index if we are re-indexing. Multiplex should always go to a new index
+ _semanticChannel.Channel.Options.TryReuseIndex = _indexStrategy == IngestStrategy.Reindex;
+ _ = await _semanticChannel.Channel.BootstrapElasticsearchAsync(BootstrapMethod.Failure, null, ctx);
}
+
+ var lexicalIndexExists = await IndexExists(_lexicalChannel.Channel.IndexName) ? "existing" : "new";
+ var semanticIndexExists = await IndexExists(_semanticChannel.Channel.IndexName) ? "existing" : "new";
+ if (_currentLexicalHash != _lexicalChannel.Channel.ChannelHash)
+ {
+ _indexStrategy = IngestStrategy.Multiplex;
+ _logger.LogInformation("Multiplexing lexical new index: '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'",
+ _lexicalChannel.Channel.IndexName, _currentLexicalHash, _lexicalChannel.Channel.ChannelHash);
+ }
+ else
+ _logger.LogInformation("Targeting {State} lexical: '{Index}'", lexicalIndexExists, _lexicalChannel.Channel.IndexName);
+
+ if (!_endpoint.NoSemantic && _currentSemanticHash != _semanticChannel.Channel.ChannelHash)
+ {
+ _indexStrategy = IngestStrategy.Multiplex;
+ _logger.LogInformation("Multiplexing new index '{Index}' since current hash on server '{HashCurrent}' does not match new '{HashNew}'",
+ _semanticChannel.Channel.IndexName, _currentSemanticHash, _semanticChannel.Channel.ChannelHash);
+ }
+ else if (!_endpoint.NoSemantic)
+ _logger.LogInformation("Targeting {State} semantical: '{Index}'", semanticIndexExists, _semanticChannel.Channel.IndexName);
+
_logger.LogInformation("Using {IndexStrategy} to sync lexical index to semantic index", _indexStrategy.ToStringFast(true));
+
+ async ValueTask IndexExists(string name) => (await _transport.HeadAsync(name, ctx)).ApiCallDetails.HasSuccessfulStatusCode;
}
private async ValueTask CountAsync(string index, string body, Cancel ctx = default)
@@ -113,7 +143,6 @@ public async ValueTask StopAsync(Cancel ctx = default)
{
var semanticWriteAlias = string.Format(_semanticChannel.Channel.Options.IndexFormat, "latest");
var lexicalWriteAlias = string.Format(_lexicalChannel.Channel.Options.IndexFormat, "latest");
- var semanticIndex = _semanticChannel.Channel.IndexName;
var stopped = await _lexicalChannel.StopAsync(ctx);
if (!stopped)
@@ -125,23 +154,28 @@ public async ValueTask StopAsync(Cancel ctx = default)
{
if (!_endpoint.NoSemantic)
_ = await _semanticChannel.StopAsync(ctx);
- else
- _logger.LogInformation("--no-semantic was specified when doing multiplex writes, not rolling over {SemanticIndex}", semanticIndex);
// cleanup lexical index of old data
await DoDeleteByQuery(lexicalWriteAlias, ctx);
+ // need to refresh the lexical index to ensure that the delete by query is available
_ = await _lexicalChannel.RefreshAsync(ctx);
- _logger.LogInformation("Finish sync to semantic index using {IndexStrategy} strategy", _indexStrategy.ToStringFast(true));
await QueryDocumentCounts(ctx);
+ // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
+ if (_endpoint.NoSemantic)
+ _logger.LogInformation("Finish indexing {IndexStrategy} strategy", _indexStrategy.ToStringFast(true));
+ else
+ _logger.LogInformation("Finish syncing to semantic in {IndexStrategy} strategy", _indexStrategy.ToStringFast(true));
return;
}
if (_endpoint.NoSemantic)
{
- _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", semanticIndex);
+ _logger.LogInformation("--no-semantic was specified so exiting early before reindexing to {Index}", lexicalWriteAlias);
return;
}
+ var semanticIndex = _semanticChannel.Channel.IndexName;
+ // check if the alias exists
var semanticIndexHead = await _transport.HeadAsync(semanticWriteAlias, ctx);
if (!semanticIndexHead.ApiCallDetails.HasSuccessfulStatusCode)
{
@@ -150,14 +184,14 @@ public async ValueTask StopAsync(Cancel ctx = default)
var semanticIndexPut = await _transport.PutAsync(semanticIndex, PostData.String("{}"), ctx);
if (!semanticIndexPut.ApiCallDetails.HasSuccessfulStatusCode)
throw new Exception($"Failed to create index {semanticIndex}: {semanticIndexPut}");
- _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx);
}
+ var destinationIndex = _semanticChannel.Channel.IndexName;
- _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias);
+ _logger.LogInformation("_reindex updates: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex);
var request = PostData.String(@"
{
""dest"": {
- ""index"": """ + semanticWriteAlias + @"""
+ ""index"": """ + destinationIndex + @"""
},
""source"": {
""index"": """ + lexicalWriteAlias + @""",
@@ -171,13 +205,13 @@ public async ValueTask StopAsync(Cancel ctx = default)
}
}
}");
- await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "updates", ctx);
+ await DoReindex(request, lexicalWriteAlias, destinationIndex, "updates", ctx);
- _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, semanticWriteAlias);
+ _logger.LogInformation("_reindex deletions: '{SourceIndex}' => '{DestinationIndex}'", lexicalWriteAlias, destinationIndex);
request = PostData.String(@"
{
""dest"": {
- ""index"": """ + semanticWriteAlias + @"""
+ ""index"": """ + destinationIndex + @"""
},
""script"": {
""source"": ""ctx.op = \""delete\""""
@@ -194,10 +228,13 @@ public async ValueTask StopAsync(Cancel ctx = default)
}
}
}");
- await DoReindex(request, lexicalWriteAlias, semanticWriteAlias, "deletions", ctx);
+ await DoReindex(request, lexicalWriteAlias, destinationIndex, "deletions", ctx);
await DoDeleteByQuery(lexicalWriteAlias, ctx);
+ _ = await _lexicalChannel.Channel.ApplyLatestAliasAsync(ctx);
+ _ = await _semanticChannel.Channel.ApplyAliasesAsync(ctx);
+
_ = await _lexicalChannel.RefreshAsync(ctx);
_ = await _semanticChannel.RefreshAsync(ctx);
@@ -275,7 +312,7 @@ private async ValueTask DoDeleteByQuery(string lexicalWriteAlias, Cancel ctx)
private async ValueTask DoReindex(PostData request, string lexicalWriteAlias, string semanticWriteAlias, string typeOfSync, Cancel ctx)
{
- var reindexUrl = "/_reindex?wait_for_completion=false&require_alias=true&scroll=10m";
+ var reindexUrl = "/_reindex?wait_for_completion=false&scroll=10m";
var reindexNewChanges = await _transport.PostAsync(reindexUrl, request, ctx);
var taskId = reindexNewChanges.Body.Get("task");
if (string.IsNullOrWhiteSpace(taskId))
@@ -336,6 +373,10 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext,
? body[..Math.Min(body.Length, 400)] + " " + string.Join(" \n- ", headings)
: string.Empty;
+ // this is temporary until https://github.com/elastic/docs-builder/pull/2070 lands
+ // this PR will add a service for us to resolve to a versioning scheme.
+ var appliesTo = fileContext.SourceFile.YamlFrontMatter?.AppliesTo ?? ApplicableTo.Default;
+
var doc = new DocumentationDocument
{
Url = url,
@@ -344,7 +385,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext,
StrippedBody = body.StripMarkdown(),
Description = fileContext.SourceFile.YamlFrontMatter?.Description,
Abstract = @abstract,
- Applies = fileContext.SourceFile.YamlFrontMatter?.AppliesTo,
+ Applies = appliesTo,
UrlSegmentCount = url.Split('/', StringSplitOptions.RemoveEmptyEntries).Length,
Parents = navigation.GetParentsOfMarkdownFile(file).Select(i => new ParentDocument
{
@@ -357,7 +398,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext,
var semanticHash = _semanticChannel.Channel.ChannelHash;
var lexicalHash = _lexicalChannel.Channel.ChannelHash;
var hash = HashedBulkUpdate.CreateHash(semanticHash, lexicalHash,
- doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h))
+ doc.Url, doc.Body ?? string.Empty, string.Join(",", doc.Headings.OrderBy(h => h)), doc.Url
);
doc.Hash = hash;
doc.LastUpdated = _batchIndexDate;
diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs
new file mode 100644
index 000000000..3b22299f8
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterRoundTripTests.cs
@@ -0,0 +1,380 @@
+// 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.Json;
+using Elastic.Documentation;
+using Elastic.Documentation.AppliesTo;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.AppliesTo;
+
+public class ApplicableToJsonConverterRoundTripTests
+{
+ private readonly JsonSerializerOptions _options = new() { WriteIndented = true };
+
+ [Fact]
+ public void RoundTripStackSimple()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = AppliesCollection.GenerallyAvailable
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().NotBeNull();
+ deserialized.Stack.Should().BeEquivalentTo(original.Stack);
+ }
+
+ [Fact]
+ public void RoundTripStackWithVersion()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = new SemVersion(8, 0, 0) },
+ new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(7, 17, 0) }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().NotBeNull();
+ deserialized.Stack.Should().BeEquivalentTo(original.Stack);
+ }
+
+ [Fact]
+ public void RoundTripDeploymentAllProperties()
+ {
+ var original = new ApplicableTo
+ {
+ Deployment = new DeploymentApplicability
+ {
+ Self = AppliesCollection.GenerallyAvailable,
+ Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]),
+ Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]),
+ Ess = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Deployment.Should().NotBeNull();
+ deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self);
+ deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece);
+ deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck);
+ deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess);
+ }
+
+ [Fact]
+ public void RoundTripServerlessAllProperties()
+ {
+ var original = new ApplicableTo
+ {
+ Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = AppliesCollection.GenerallyAvailable,
+ Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]),
+ Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Serverless.Should().NotBeNull();
+ deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch);
+ deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability);
+ deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security);
+ }
+
+ [Fact]
+ public void RoundTripProductSimple()
+ {
+ var original = new ApplicableTo
+ {
+ Product = AppliesCollection.GenerallyAvailable
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Product.Should().NotBeNull();
+ deserialized.Product.Should().BeEquivalentTo(original.Product);
+ }
+
+ [Fact]
+ public void RoundTripProductApplicabilitySingleProduct()
+ {
+ var original = new ApplicableTo
+ {
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.ProductApplicability.Should().NotBeNull();
+ deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl);
+ }
+
+ [Fact]
+ public void RoundTripProductApplicabilityMultipleProducts()
+ {
+ var original = new ApplicableTo
+ {
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = AppliesCollection.GenerallyAvailable,
+ Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]),
+ ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]),
+ EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.ProductApplicability.Should().NotBeNull();
+ deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl);
+ deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator);
+ deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet);
+ deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet);
+ }
+
+ [Fact]
+ public void RoundTripAllProductApplicabilityProperties()
+ {
+ var original = new ApplicableTo
+ {
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = AppliesCollection.GenerallyAvailable,
+ Curator = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"5.0.0" }]),
+ ApmAgentAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }]),
+ ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }]),
+ ApmAgentGo = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"2.0.0" }]),
+ ApmAgentIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.5.0" }]),
+ ApmAgentJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.30.0" }]),
+ ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]),
+ ApmAgentPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.8.0" }]),
+ ApmAgentPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"6.0.0" }]),
+ ApmAgentRuby = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"4.0.0" }]),
+ ApmAgentRumJs = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"5.0.0" }]),
+ EdotIos = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]),
+ EdotAndroid = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.8.0" }]),
+ EdotDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.9.0" }]),
+ EdotJava = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.7.0" }]),
+ EdotNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.6.0" }]),
+ EdotPhp = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.5.0" }]),
+ EdotPython = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"0.4.0" }]),
+ EdotCfAws = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.3.0" }]),
+ EdotCfAzure = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"0.2.0" }]),
+ EdotCollector = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.0.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.ProductApplicability.Should().NotBeNull();
+ deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl);
+ deserialized.ProductApplicability.Curator.Should().BeEquivalentTo(original.ProductApplicability.Curator);
+ deserialized.ProductApplicability.ApmAgentAndroid.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentAndroid);
+ deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet);
+ deserialized.ProductApplicability.ApmAgentGo.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentGo);
+ deserialized.ProductApplicability.ApmAgentIos.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentIos);
+ deserialized.ProductApplicability.ApmAgentJava.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentJava);
+ deserialized.ProductApplicability.ApmAgentNode.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentNode);
+ deserialized.ProductApplicability.ApmAgentPhp.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPhp);
+ deserialized.ProductApplicability.ApmAgentPython.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentPython);
+ deserialized.ProductApplicability.ApmAgentRuby.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRuby);
+ deserialized.ProductApplicability.ApmAgentRumJs.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentRumJs);
+ deserialized.ProductApplicability.EdotIos.Should().BeEquivalentTo(original.ProductApplicability.EdotIos);
+ deserialized.ProductApplicability.EdotAndroid.Should().BeEquivalentTo(original.ProductApplicability.EdotAndroid);
+ deserialized.ProductApplicability.EdotDotnet.Should().BeEquivalentTo(original.ProductApplicability.EdotDotnet);
+ deserialized.ProductApplicability.EdotJava.Should().BeEquivalentTo(original.ProductApplicability.EdotJava);
+ deserialized.ProductApplicability.EdotNode.Should().BeEquivalentTo(original.ProductApplicability.EdotNode);
+ deserialized.ProductApplicability.EdotPhp.Should().BeEquivalentTo(original.ProductApplicability.EdotPhp);
+ deserialized.ProductApplicability.EdotPython.Should().BeEquivalentTo(original.ProductApplicability.EdotPython);
+ deserialized.ProductApplicability.EdotCfAws.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAws);
+ deserialized.ProductApplicability.EdotCfAzure.Should().BeEquivalentTo(original.ProductApplicability.EdotCfAzure);
+ deserialized.ProductApplicability.EdotCollector.Should().BeEquivalentTo(original.ProductApplicability.EdotCollector);
+ }
+
+ [Fact]
+ public void RoundTripComplexAllFieldsPopulated()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" },
+ new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" }
+ ]),
+ Deployment = new DeploymentApplicability
+ {
+ Self = AppliesCollection.GenerallyAvailable,
+ Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"3.0.0" }]),
+ Eck = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }]),
+ Ess = AppliesCollection.GenerallyAvailable
+ },
+ Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = AppliesCollection.GenerallyAvailable,
+ Observability = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = AllVersions.Instance }]),
+ Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }])
+ },
+ Product = AppliesCollection.GenerallyAvailable,
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = AppliesCollection.GenerallyAvailable,
+ ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.2.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().BeEquivalentTo(original.Stack);
+ deserialized.Deployment.Should().NotBeNull();
+ deserialized.Deployment!.Self.Should().BeEquivalentTo(original.Deployment!.Self);
+ deserialized.Deployment.Ece.Should().BeEquivalentTo(original.Deployment.Ece);
+ deserialized.Deployment.Eck.Should().BeEquivalentTo(original.Deployment.Eck);
+ deserialized.Deployment.Ess.Should().BeEquivalentTo(original.Deployment.Ess);
+ deserialized.Serverless.Should().NotBeNull();
+ deserialized.Serverless!.Elasticsearch.Should().BeEquivalentTo(original.Serverless!.Elasticsearch);
+ deserialized.Serverless.Observability.Should().BeEquivalentTo(original.Serverless.Observability);
+ deserialized.Serverless.Security.Should().BeEquivalentTo(original.Serverless.Security);
+ deserialized.Product.Should().BeEquivalentTo(original.Product);
+ deserialized.ProductApplicability.Should().NotBeNull();
+ deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl);
+ deserialized.ProductApplicability.ApmAgentDotnet.Should().BeEquivalentTo(original.ProductApplicability.ApmAgentDotnet);
+ }
+
+ [Fact]
+ public void RoundTripAllLifecycles()
+ {
+ var lifecycles = Enum.GetValues();
+ var applicabilities = lifecycles.Select(lc =>
+ new Applicability { Lifecycle = lc, Version = (SemVersion)"1.0.0" }
+ ).ToArray();
+
+ var original = new ApplicableTo
+ {
+ Stack = new AppliesCollection(applicabilities)
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().NotBeNull();
+ deserialized.Stack.Should().BeEquivalentTo(original.Stack);
+ }
+
+ [Fact]
+ public void RoundTripMultipleApplicabilitiesInCollection()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" },
+ new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" },
+ new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"7.16.0" },
+ new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"6.0.0" }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().NotBeNull();
+ deserialized.Stack.Should().HaveCount(4);
+ deserialized.Stack.Should().BeEquivalentTo(original.Stack);
+ }
+
+ [Fact]
+ public void RoundTripEmptyApplicableTo()
+ {
+ var original = new ApplicableTo();
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().BeNull();
+ deserialized.Deployment.Should().BeNull();
+ deserialized.Serverless.Should().BeNull();
+ deserialized.Product.Should().BeNull();
+ deserialized.ProductApplicability.Should().BeNull();
+ }
+
+ [Fact]
+ public void RoundTripNullReturnsNull()
+ {
+ ApplicableTo? original = null;
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().BeNull();
+ }
+
+ [Fact]
+ public void RoundTripAllVersionsSerializesAsSemanticVersion()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = AllVersions.Instance }])
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ json.Should().Contain("\"version\": \"9999.9999.9999\"");
+
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+ deserialized.Should().NotBeNull();
+ deserialized!.Stack.Should().NotBeNull();
+ deserialized.Stack!.First().Version.Should().Be(AllVersions.Instance);
+ }
+
+ [Fact]
+ public void RoundTripProductAndProductApplicabilityBothPresent()
+ {
+ var original = new ApplicableTo
+ {
+ Product = AppliesCollection.GenerallyAvailable,
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"1.0.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized!.Product.Should().BeEquivalentTo(original.Product);
+ deserialized.ProductApplicability.Should().NotBeNull();
+ deserialized.ProductApplicability!.Ecctl.Should().BeEquivalentTo(original.ProductApplicability!.Ecctl);
+ }
+}
diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs
new file mode 100644
index 000000000..fbff52703
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/AppliesTo/ApplicableToJsonConverterSerializationTests.cs
@@ -0,0 +1,394 @@
+// 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.Json;
+using Elastic.Documentation;
+using Elastic.Documentation.AppliesTo;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.AppliesTo;
+
+public class ApplicableToJsonConverterSerializationTests
+{
+ private readonly JsonSerializerOptions _options = new()
+ {
+ WriteIndented = true
+ };
+
+ [Fact]
+ public void SerializeStackProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Stack = AppliesCollection.GenerallyAvailable
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "stack",
+ "sub_type": "stack",
+ "lifecycle": "ga",
+ "version": "9999.9999.9999"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeStackWithVersionProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Stack = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Beta,
+ Version = (SemVersion)"8.0.0"
+ }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "stack",
+ "sub_type": "stack",
+ "lifecycle": "beta",
+ "version": "8.0.0"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeMultipleApplicabilitiesProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.GenerallyAvailable,
+ Version = (SemVersion)"8.0.0"
+ },
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Beta,
+ Version = (SemVersion)"7.17.0"
+ }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "stack",
+ "sub_type": "stack",
+ "lifecycle": "ga",
+ "version": "8.0.0"
+ },
+ {
+ "type": "stack",
+ "sub_type": "stack",
+ "lifecycle": "beta",
+ "version": "7.17.0"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeDeploymentProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Deployment = new DeploymentApplicability
+ {
+ Ece = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.GenerallyAvailable,
+ Version = (SemVersion)"3.0.0"
+ }
+ ]),
+ Ess = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "deployment",
+ "sub_type": "ece",
+ "lifecycle": "ga",
+ "version": "3.0.0"
+ },
+ {
+ "type": "deployment",
+ "sub_type": "ess",
+ "lifecycle": "ga",
+ "version": "9999.9999.9999"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeServerlessProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Beta,
+ Version = (SemVersion)"1.0.0"
+ }
+ ]),
+ Security = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "serverless",
+ "sub_type": "elasticsearch",
+ "lifecycle": "beta",
+ "version": "1.0.0"
+ },
+ {
+ "type": "serverless",
+ "sub_type": "security",
+ "lifecycle": "ga",
+ "version": "9999.9999.9999"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeProductProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Product = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.TechnicalPreview,
+ Version = (SemVersion)"0.5.0"
+ }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "product",
+ "sub_type": "product",
+ "lifecycle": "preview",
+ "version": "0.5.0"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeProductApplicabilityProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ ProductApplicability = new ProductApplicability
+ {
+ Ecctl = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Deprecated,
+ Version = (SemVersion)"5.0.0"
+ }
+ ]),
+ ApmAgentDotnet = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // language=json
+ json.Should().Be(
+ """
+ [
+ {
+ "type": "product",
+ "sub_type": "ecctl",
+ "lifecycle": "deprecated",
+ "version": "5.0.0"
+ },
+ {
+ "type": "product",
+ "sub_type": "apm-agent-dotnet",
+ "lifecycle": "ga",
+ "version": "9999.9999.9999"
+ }
+ ]
+ """);
+ }
+
+ [Fact]
+ public void SerializeAllLifecyclesProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.TechnicalPreview,
+ Version = (SemVersion)"1.0.0"
+ },
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Beta,
+ Version = (SemVersion)"1.0.0"
+ },
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.GenerallyAvailable,
+ Version = (SemVersion)"1.0.0"
+ },
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Deprecated,
+ Version = (SemVersion)"1.0.0"
+ },
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Removed,
+ Version = (SemVersion)"1.0.0"
+ }
+ ])
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ json.Should().Contain("\"lifecycle\": \"preview\"");
+ json.Should().Contain("\"lifecycle\": \"beta\"");
+ json.Should().Contain("\"lifecycle\": \"ga\"");
+ json.Should().Contain("\"lifecycle\": \"deprecated\"");
+ json.Should().Contain("\"lifecycle\": \"removed\"");
+ }
+
+ [Fact]
+ public void SerializeComplexProducesCorrectJson()
+ {
+ var applicableTo = new ApplicableTo
+ {
+ Stack = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.GenerallyAvailable,
+ Version = (SemVersion)"8.0.0"
+ }
+ ]),
+ Deployment = new DeploymentApplicability
+ {
+ Ece = AppliesCollection.GenerallyAvailable
+ },
+ Product = AppliesCollection.GenerallyAvailable
+ };
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ // Verify it's an array with 3 items
+ var jsonDoc = JsonDocument.Parse(json);
+ jsonDoc.RootElement.GetArrayLength().Should().Be(3);
+
+ // Verify each type is present
+ json.Should().Contain("\"type\": \"stack\"");
+ json.Should().Contain("\"type\": \"deployment\"");
+ json.Should().Contain("\"type\": \"product\"");
+
+ // Verify sub_types
+ json.Should().Contain("\"sub_type\": \"stack\"");
+ json.Should().Contain("\"sub_type\": \"ece\"");
+ json.Should().Contain("\"sub_type\": \"product\"");
+ }
+
+ [Fact]
+ public void SerializeEmptyProducesEmptyArray()
+ {
+ var applicableTo = new ApplicableTo();
+
+ var json = JsonSerializer.Serialize(applicableTo, _options);
+
+ json.Should().Be("[]");
+ }
+
+ [Fact]
+ public void SerializeValidatesJsonStructure()
+ {
+ var original = new ApplicableTo
+ {
+ Stack = AppliesCollection.GenerallyAvailable,
+ Deployment = new DeploymentApplicability
+ {
+ Ece = new AppliesCollection([
+ new Applicability
+ {
+ Lifecycle = ProductLifecycle.Beta,
+ Version = (SemVersion)"3.0.0"
+ }
+ ])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.ValueKind.Should().Be(JsonValueKind.Array);
+ var array = root.EnumerateArray().ToList();
+
+ array.Should().HaveCount(2); // Stack + Deployment.Ece
+
+ var stackEntry = array[0];
+ stackEntry.GetProperty("type").GetString().Should().Be("stack");
+ stackEntry.GetProperty("sub_type").GetString().Should().Be("stack");
+ stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga");
+ stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999");
+
+ var deploymentEntry = array[1];
+ deploymentEntry.GetProperty("type").GetString().Should().Be("deployment");
+ deploymentEntry.GetProperty("sub_type").GetString().Should().Be("ece");
+ deploymentEntry.GetProperty("lifecycle").GetString().Should().Be("beta");
+ deploymentEntry.GetProperty("version").GetString().Should().Be("3.0.0");
+ }
+}
diff --git a/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs
new file mode 100644
index 000000000..61dc00e80
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/AppliesTo/ProductApplicabilityToStringTests.cs
@@ -0,0 +1,112 @@
+// 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.Reflection;
+using Elastic.Documentation;
+using Elastic.Documentation.AppliesTo;
+using FluentAssertions;
+using YamlDotNet.Serialization;
+
+namespace Elastic.Markdown.Tests.AppliesTo;
+
+public class ProductApplicabilityToStringTests
+{
+ [Fact]
+ public void ProductApplicabilityToStringIncludesAllProperties()
+ {
+ // Create a ProductApplicability with all properties set
+ var productApplicability = new ProductApplicability();
+ var productType = typeof(ProductApplicability);
+ var properties = productType.GetProperties()
+ .Where(p => p.GetCustomAttribute() != null)
+ .ToList();
+
+ // Set all properties to a test value
+ var testValue = AppliesCollection.GenerallyAvailable;
+ foreach (var property in properties)
+ {
+ property.SetValue(productApplicability, testValue);
+ }
+
+ // Get the ToString output
+ var result = productApplicability.ToString();
+
+ // Verify that each property's YAML alias appears in the output
+ foreach (var property in properties)
+ {
+ var yamlAlias = property.GetCustomAttribute()!.Alias;
+ result.Should().Contain($"{yamlAlias}=",
+ $"ToString should include the property {property.Name} with alias '{yamlAlias}'");
+ }
+
+ // Verify we have the expected number of properties
+ properties.Should().HaveCount(22, "ProductApplicability should have exactly 22 product properties");
+ }
+
+ [Fact]
+ public void ProductApplicabilityToStringWithSomePropertiesOnlyIncludesSetProperties()
+ {
+ var productApplicability = new ProductApplicability
+ {
+ ApmAgentDotnet = AppliesCollection.GenerallyAvailable,
+ Ecctl = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = new SemVersion(1, 0, 0) }])
+ };
+
+ var result = productApplicability.ToString();
+
+ // Should include set properties
+ result.Should().Contain("apm-agent-dotnet=");
+ result.Should().Contain("ecctl=");
+
+ // Should not include unset properties
+ result.Should().NotContain("apm-agent-node=");
+ result.Should().NotContain("curator=");
+ }
+
+ [Fact]
+ public void ProductApplicabilityToStringEmptyReturnsEmptyString()
+ {
+ var productApplicability = new ProductApplicability();
+
+ var result = productApplicability.ToString();
+
+ result.Should().Be("");
+ }
+
+ [Fact]
+ public void ProductApplicabilityToStringPropertyOrderMatchesReflectionOrder()
+ {
+ // This test ensures that properties appear in the order they are defined
+ var productApplicability = new ProductApplicability
+ {
+ Ecctl = AppliesCollection.GenerallyAvailable,
+ Curator = AppliesCollection.GenerallyAvailable,
+ ApmAgentAndroid = AppliesCollection.GenerallyAvailable
+ };
+
+ var result = productApplicability.ToString();
+
+ // Get the properties in reflection order
+ var productType = typeof(ProductApplicability);
+ var properties = productType.GetProperties()
+ .Where(p => p.GetCustomAttribute() != null)
+ .Select(p => p.GetCustomAttribute()!.Alias)
+ .ToList();
+
+ // Find positions in the string
+ var positions = new Dictionary();
+ foreach (var alias in new[] { "ecctl", "curator", "apm-agent-android" })
+ {
+ var index = result.IndexOf($"{alias}=", StringComparison.Ordinal);
+ if (index >= 0)
+ positions[alias] = index;
+ }
+
+ // Verify that the properties appear in the correct order
+ positions["ecctl"].Should().BeLessThan(positions["curator"],
+ "ecctl should appear before curator");
+ positions["curator"].Should().BeLessThan(positions["apm-agent-android"],
+ "curator should appear before apm-agent-android");
+ }
+}
diff --git a/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs
new file mode 100644
index 000000000..b3258a994
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/Search/DocumentationDocumentSerializationTests.cs
@@ -0,0 +1,351 @@
+// 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.Globalization;
+using System.Text.Json;
+using Elastic.Documentation;
+using Elastic.Documentation.AppliesTo;
+using Elastic.Documentation.Search;
+using Elastic.Documentation.Serialization;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.Search;
+
+public class DocumentationDocumentSerializationTests
+{
+ private readonly JsonSerializerOptions _options = new(SourceGenerationContext.Default.Options);
+
+ [Fact]
+ public void SerializeDocumentWithStackAppliesToProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/page",
+ Title = "Test Page",
+ Applies = new ApplicableTo
+ {
+ Stack = AppliesCollection.GenerallyAvailable
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ // Verify applies_to existing
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ appliesTo.ValueKind.Should().Be(JsonValueKind.Array);
+
+ // Verify structure
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(1);
+
+ var stackEntry = appliesArray[0];
+ stackEntry.GetProperty("type").GetString().Should().Be("stack");
+ stackEntry.GetProperty("sub_type").GetString().Should().Be("stack");
+ stackEntry.GetProperty("lifecycle").GetString().Should().Be("ga");
+ stackEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithDeploymentAppliesToProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/deployment",
+ Title = "Deployment Test",
+ Applies = new ApplicableTo
+ {
+ Deployment = new DeploymentApplicability
+ {
+ Ess = AppliesCollection.GenerallyAvailable,
+ Ece = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"3.5.0" }])
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(2);
+
+ // Verify ESS entry
+ var essEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ess");
+ essEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ essEntry.GetProperty("type").GetString().Should().Be("deployment");
+ essEntry.GetProperty("lifecycle").GetString().Should().Be("ga");
+ essEntry.GetProperty("version").GetString().Should().Be("9999.9999.9999");
+
+ // Verify ECE entry
+ var eceEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "ece");
+ eceEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ eceEntry.GetProperty("type").GetString().Should().Be("deployment");
+ eceEntry.GetProperty("lifecycle").GetString().Should().Be("beta");
+ eceEntry.GetProperty("version").GetString().Should().Be("3.5.0");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithServerlessAppliesToProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/serverless",
+ Title = "Serverless Test",
+ Applies = new ApplicableTo
+ {
+ Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]),
+ Security = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.TechnicalPreview, Version = (SemVersion)"1.0.0" }])
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(2);
+
+ // Verify elasticsearch entry
+ var esEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "elasticsearch");
+ esEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ esEntry.GetProperty("type").GetString().Should().Be("serverless");
+ esEntry.GetProperty("lifecycle").GetString().Should().Be("ga");
+ esEntry.GetProperty("version").GetString().Should().Be("8.0.0");
+
+ // Verify security entry
+ var secEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "security");
+ secEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ secEntry.GetProperty("type").GetString().Should().Be("serverless");
+ secEntry.GetProperty("lifecycle").GetString().Should().Be("preview");
+ secEntry.GetProperty("version").GetString().Should().Be("1.0.0");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithProductAppliesToProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/product",
+ Title = "Product Test",
+ Applies = new ApplicableTo
+ {
+ Product = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"2.0.0" }])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(1);
+
+ var productEntry = appliesArray[0];
+ productEntry.GetProperty("type").GetString().Should().Be("product");
+ productEntry.GetProperty("sub_type").GetString().Should().Be("product");
+ productEntry.GetProperty("lifecycle").GetString().Should().Be("beta");
+ productEntry.GetProperty("version").GetString().Should().Be("2.0.0");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithProductApplicabilityProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/apm",
+ Title = "APM Test",
+ Applies = new ApplicableTo
+ {
+ ProductApplicability = new ProductApplicability
+ {
+ ApmAgentDotnet = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"1.5.0" }]),
+ ApmAgentNode = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"2.0.0" }])
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(2);
+
+ // Verify apm-agent-dotnet entry
+ var dotnetEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-dotnet");
+ dotnetEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ dotnetEntry.GetProperty("type").GetString().Should().Be("product");
+ dotnetEntry.GetProperty("lifecycle").GetString().Should().Be("ga");
+ dotnetEntry.GetProperty("version").GetString().Should().Be("1.5.0");
+
+ // Verify apm-agent-node entry
+ var nodeEntry = appliesArray.FirstOrDefault(e => e.GetProperty("sub_type").GetString() == "apm-agent-node");
+ nodeEntry.ValueKind.Should().NotBe(JsonValueKind.Undefined);
+ nodeEntry.GetProperty("type").GetString().Should().Be("product");
+ nodeEntry.GetProperty("lifecycle").GetString().Should().Be("deprecated");
+ nodeEntry.GetProperty("version").GetString().Should().Be("2.0.0");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithComplexAppliesToProducesCorrectJson()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/complex",
+ Title = "Complex Test",
+ Applies = new ApplicableTo
+ {
+ Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" }]),
+ Deployment = new DeploymentApplicability
+ {
+ Ess = AppliesCollection.GenerallyAvailable
+ },
+ Serverless = new ServerlessProjectApplicability
+ {
+ Elasticsearch = AppliesCollection.GenerallyAvailable
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(3);
+
+ // Verify we have all three types
+ appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "stack");
+ appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "deployment");
+ appliesArray.Should().Contain(e => e.GetProperty("type").GetString() == "serverless");
+ }
+
+ [Fact]
+ public void SerializeDocumentWithNullAppliesToOmitsField()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/no-applies",
+ Title = "No Applies Test",
+ Applies = null
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ // With default JSON options, null values might be omitted or serialized as null
+ // Let's check both possibilities
+ if (root.TryGetProperty("applies_to", out var appliesTo))
+ appliesTo.ValueKind.Should().Be(JsonValueKind.Null);
+ else
+ {
+ // Field is omitted, which is also acceptable
+ true.Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public void SerializeDocumentWithEmptyAppliesToProducesEmptyArray()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/empty-applies",
+ Title = "Empty Applies Test",
+ Applies = new ApplicableTo()
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ appliesTo.ValueKind.Should().Be(JsonValueKind.Array);
+ appliesTo.GetArrayLength().Should().Be(0);
+ }
+
+ [Fact]
+ public void RoundTripDocumentWithAppliesToPreservesData()
+ {
+ var original = new DocumentationDocument
+ {
+ Url = "/test/roundtrip",
+ Title = "Round Trip Test",
+ Hash = "abc123",
+ BatchIndexDate = DateTimeOffset.Parse("2024-01-15T10:00:00Z", CultureInfo.InvariantCulture),
+ LastUpdated = DateTimeOffset.Parse("2024-01-15T09:00:00Z", CultureInfo.InvariantCulture),
+ Applies = new ApplicableTo
+ {
+ Stack = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.5.0" }]),
+ Deployment = new DeploymentApplicability
+ {
+ Ess = new AppliesCollection([new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"8.6.0" }])
+ }
+ },
+ Headings = ["Introduction", "Getting Started"],
+ Links = ["/link1", "/link2"],
+ Body = "Test body content",
+ StrippedBody = "Test body content",
+ Description = "Test description"
+ };
+
+ var json = JsonSerializer.Serialize(original, _options);
+ var deserialized = JsonSerializer.Deserialize(json, _options);
+
+ deserialized.Should().NotBeNull();
+ deserialized.Url.Should().Be(original.Url);
+ deserialized.Title.Should().Be(original.Title);
+ deserialized.Applies.Should().NotBeNull();
+ deserialized.Applies!.Stack.Should().BeEquivalentTo(original.Applies!.Stack);
+ deserialized.Applies.Deployment.Should().NotBeNull();
+ deserialized.Applies.Deployment!.Ess.Should().BeEquivalentTo(original.Applies.Deployment!.Ess);
+ }
+
+ [Fact]
+ public void SerializeDocumentWithMultipleApplicabilitiesPerTypeProducesMultipleArrayEntries()
+ {
+ var doc = new DocumentationDocument
+ {
+ Url = "/test/multiple",
+ Title = "Multiple Test",
+ Applies = new ApplicableTo
+ {
+ Stack = new AppliesCollection(
+ [
+ new Applicability { Lifecycle = ProductLifecycle.GenerallyAvailable, Version = (SemVersion)"8.0.0" },
+ new Applicability { Lifecycle = ProductLifecycle.Beta, Version = (SemVersion)"7.17.0" },
+ new Applicability { Lifecycle = ProductLifecycle.Deprecated, Version = (SemVersion)"7.0.0" }
+ ])
+ }
+ };
+
+ var json = JsonSerializer.Serialize(doc, _options);
+ var jsonDoc = JsonDocument.Parse(json);
+ var root = jsonDoc.RootElement;
+
+ root.TryGetProperty("applies_to", out var appliesTo).Should().BeTrue();
+ var appliesArray = appliesTo.EnumerateArray().ToList();
+ appliesArray.Should().HaveCount(3);
+
+ // All should be stack type
+ appliesArray.Should().OnlyContain(e => e.GetProperty("type").GetString() == "stack");
+ appliesArray.Should().OnlyContain(e => e.GetProperty("sub_type").GetString() == "stack");
+
+ // Verify different lifecycle values
+ var lifecycles = appliesArray.Select(e => e.GetProperty("lifecycle").GetString()).ToList();
+ lifecycles.Should().Contain("ga");
+ lifecycles.Should().Contain("beta");
+ lifecycles.Should().Contain("deprecated");
+ }
+}