From 63ea62039b70c0a6e6bcf1220cced8474b184ce2 Mon Sep 17 00:00:00 2001 From: "Konstantin S." Date: Thu, 9 Apr 2026 16:47:51 +0400 Subject: [PATCH 1/4] feat(docs): generate SDK snippets from OpenAPI examples --- .../AutoSDK.CLI/Commands/GenerateCommand.cs | 14 + .../Sources/Sources.Snippets.cs | 680 ++++++++++++++++++ src/libs/AutoSDK.Docs/AutoSDK.Docs.csproj | 4 + src/libs/AutoSDK.Docs/DocsConfig.cs | 2 + src/libs/AutoSDK.Docs/DocsSynchronizer.cs | 84 ++- .../Models/GeneratedSdkSnippetManifest.cs | 18 + .../AutoSDK.IntegrationTests.Cli/CliTests.cs | 104 +++ src/tests/AutoSDK.UnitTests/DocsSyncTests.cs | 75 ++ .../SnippetGenerationTests.cs | 148 ++++ 9 files changed, 1123 insertions(+), 6 deletions(-) create mode 100644 src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs create mode 100644 src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs create mode 100644 src/tests/AutoSDK.UnitTests/SnippetGenerationTests.cs diff --git a/src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs b/src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs index 5415d74ff6a..54a0bf5cdc8 100644 --- a/src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs +++ b/src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs @@ -464,6 +464,20 @@ await GrpcProjectScaffolder.ScaffoldAsync( } } + if (specFormat == SpecFormat.OpenApi) + { + var document = yaml.GetOpenApiDocument(settings); + var schemas = document.GetSchemas(settings); + var operations = document.GetOperations(settings, settings, schemas); + var snippetManifest = Sources.SnippetManifest(operations, data.Methods.ToArray()); + if (!snippetManifest.IsEmpty) + { + await File.WriteAllTextAsync( + Path.Combine(output, snippetManifest.Name), + snippetManifest.Text).ConfigureAwait(false); + } + } + if (grpcInputs.Length > 0) { await ScaffoldMixedModeGrpcInputsAsync( diff --git a/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs new file mode 100644 index 00000000000..3ce59067d81 --- /dev/null +++ b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs @@ -0,0 +1,680 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AutoSDK.Extensions; +using AutoSDK.Models; +using Microsoft.OpenApi; + +namespace AutoSDK.Generation; + +public static partial class Sources +{ + private static readonly JsonSerializerOptions SnippetManifestJsonOptions = new() + { + WriteIndented = true, + }; + + public static FileWithName SnippetManifest( + IReadOnlyList operations, + IReadOnlyList endPoints, + CancellationToken cancellationToken = default) + { + var text = GenerateSnippetManifest(operations, endPoints, cancellationToken); + return string.IsNullOrWhiteSpace(text) + ? FileWithName.Empty + : new FileWithName( + Name: "autosdk.generated-examples.json", + Text: text); + } + + public static string GenerateSnippetManifest( + IReadOnlyList operations, + IReadOnlyList endPoints, + CancellationToken cancellationToken = default) + { + operations = operations ?? throw new ArgumentNullException(nameof(operations)); + endPoints = endPoints ?? throw new ArgumentNullException(nameof(endPoints)); + + var primaryEndPoints = endPoints + .Where(static x => !string.IsNullOrWhiteSpace(x.Id)) + .GroupBy(static x => x.Id, StringComparer.Ordinal) + .ToDictionary( + static x => x.Key, + static x => x + .OrderBy(static y => y.Stream) + .First(), + StringComparer.Ordinal); + + var snippets = new List(); + foreach (var operation in operations + .Where(static x => HasSnippetSource(x.Operation)) + .OrderBy(static x => x.Tag.SafeName, StringComparer.Ordinal) + .ThenBy(static x => x.MethodName, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + + primaryEndPoints.TryGetValue(operation.MethodName, out var endPoint); + var snippet = CreateSnippetDocument(operation, endPoint, snippets.Count + 1); + if (snippet is not null) + { + snippets.Add(snippet); + } + } + + if (snippets.Count == 0) + { + return string.Empty; + } + + return JsonSerializer.Serialize( + new GeneratedSdkSnippetManifest(snippets), + SnippetManifestJsonOptions); + } + + private static GeneratedSdkSnippetDocument? CreateSnippetDocument( + OperationContext operation, + EndPoint? endPoint, + int order) + { + var title = GetSnippetTitle(operation); + var description = string.IsNullOrWhiteSpace(operation.Operation.Description) + ? "Generated from OpenAPI examples." + : operation.Operation.Description.Trim(); + var slug = Slugify(operation.Operation.OperationId ?? title); + var operationId = operation.Operation.OperationId ?? operation.MethodName; + + if (endPoint is EndPoint method && + TryCreateGeneratedCSharpSnippet(operation, method, out var csharpCode, out var setup)) + { + return new GeneratedSdkSnippetDocument( + Order: order, + Title: title, + Slug: slug, + Description: description, + Language: "csharp", + Code: csharpCode, + Format: "sdk", + OperationId: operationId, + Setup: setup); + } + + if (TryGetPreferredCodeSample(operation.Operation, out var codeSample)) + { + return new GeneratedSdkSnippetDocument( + Order: order, + Title: title, + Slug: slug, + Description: description, + Language: "csharp", + Code: codeSample, + Format: "sdk", + OperationId: operationId); + } + + return new GeneratedSdkSnippetDocument( + Order: order, + Title: title, + Slug: slug, + Description: description, + Language: "http", + Code: GenerateHttpRequest(operation).TrimEnd(), + Format: "http", + OperationId: operationId); + } + + private static bool TryCreateGeneratedCSharpSnippet( + OperationContext operation, + EndPoint endPoint, + out string code, + out string? setup) + { + code = string.Empty; + setup = null; + + if (!TryCreateClientInstantiation(endPoint, out var clientInstantiation, out setup)) + { + return false; + } + + var requiredArgumentLines = new List(); + var optionalArgumentLines = new List(); + var requestPreludeLines = new List(); + IEnumerable parameters = operation.Operation.Parameters ?? []; + IReadOnlyList requestPrelude = Array.Empty(); + var requestArgumentLine = string.Empty; + + foreach (var parameter in endPoint.Parameters.Where(static x => x.Location != null && x.IsRequired && !x.HasSchemaDefault)) + { + if (!TryCreateParameterArgumentLine(parameter, parameters, allowSchemaFallback: true, out var line, out _)) + { + return false; + } + + requiredArgumentLines.Add(line); + } + + if (!string.IsNullOrWhiteSpace(endPoint.RequestType.CSharpType) && + !TryCreateRequestSnippet(operation.Operation, endPoint, out requestPrelude, out requestArgumentLine)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(endPoint.RequestType.CSharpType)) + { + requestPreludeLines.AddRange(requestPrelude); + requiredArgumentLines.Add(requestArgumentLine); + } + + foreach (var parameter in endPoint.Parameters.Where(static x => x.Location != null && (!x.IsRequired || x.HasSchemaDefault))) + { + if (TryCreateParameterArgumentLine(parameter, parameters, allowSchemaFallback: false, out var line, out var hasExplicitExample) && + hasExplicitExample) + { + optionalArgumentLines.Add(line); + } + } + + var clientAccessor = endPoint.Settings.GroupByTags && endPoint.Tag != Tag.Empty + ? $"client.{endPoint.Tag.SafeName}" + : "client"; + var callArguments = requiredArgumentLines + .Concat(optionalArgumentLines) + .ToArray(); + + var builder = new StringBuilder(); + builder.AppendLine(clientInstantiation); + + if (requestPreludeLines.Count > 0) + { + builder.AppendLine(); + foreach (var line in requestPreludeLines) + { + builder.AppendLine(line); + } + } + + if (requestPreludeLines.Count > 0 || callArguments.Length > 0) + { + builder.AppendLine(); + } + + if (string.IsNullOrWhiteSpace(endPoint.SuccessResponse.Type.CSharpType)) + { + builder.Append("await "); + } + else + { + builder.Append("var response = await "); + } + + builder.Append(clientAccessor) + .Append('.') + .Append(endPoint.MethodName); + + if (callArguments.Length == 0) + { + builder.AppendLine("();"); + } + else + { + builder.AppendLine("("); + for (var i = 0; i < callArguments.Length; i++) + { + builder.Append(" ") + .Append(callArguments[i]); + + if (i < callArguments.Length - 1) + { + builder.Append(','); + } + + builder.AppendLine(); + } + + builder.AppendLine(");"); + } + + var responseExample = TryGetFirstSuccessResponseExample(operation.Operation); + if (!string.IsNullOrWhiteSpace(responseExample)) + { + builder.AppendLine(); + builder.AppendLine("// Example response:"); + foreach (var line in NormalizeSnippetNewlines(responseExample).Split('\n')) + { + builder.Append("// "); + builder.AppendLine(line); + } + } + + code = builder + .ToString() + .TrimEnd(); + return true; + } + + private static bool TryCreateClientInstantiation( + EndPoint endPoint, + out string clientInstantiation, + out string setup) + { + var rootClientClassName = endPoint.Settings.ClassName.Replace(".", string.Empty); + if (endPoint.Authorizations.IsEmpty) + { + clientInstantiation = $"using var client = new {rootClientClassName}();"; + setup = $"This example assumes `using {endPoint.Settings.Namespace};` is in scope."; + return true; + } + + if (endPoint.Authorizations.Length != 1 || + endPoint.Authorizations[0].Parameters.IsEmpty) + { + clientInstantiation = string.Empty; + setup = string.Empty; + return false; + } + + var authorization = endPoint.Authorizations[0]; + var parameters = authorization.Parameters.ToArray(); + clientInstantiation = $"using var client = new {rootClientClassName}({string.Join(", ", parameters)});"; + setup = parameters.Length == 1 + ? $"This example assumes `using {endPoint.Settings.Namespace};` is in scope and `{parameters[0]}` contains the required credential." + : $"This example assumes `using {endPoint.Settings.Namespace};` is in scope and `{string.Join("` / `", parameters)}` contain the required credentials."; + return true; + } + + private static bool TryCreateParameterArgumentLine( + MethodParameter parameter, + IEnumerable sourceParameters, + bool allowSchemaFallback, + out string line, + out bool hasExplicitExample) + { + line = string.Empty; + hasExplicitExample = false; + + var sourceParameter = sourceParameters.FirstOrDefault(x => + x.In == parameter.Location && + string.Equals(x.Name, parameter.Id, StringComparison.OrdinalIgnoreCase)); + if (sourceParameter == null || + !TryGetParameterJsonText(sourceParameter, parameter.Type, allowSchemaFallback, out var jsonText, out hasExplicitExample) || + !TryCreateCSharpExpression(parameter.Type, jsonText, out var expression)) + { + return false; + } + + line = $"{parameter.ParameterName}: {expression}"; + return true; + } + + private static bool TryCreateRequestSnippet( + OpenApiOperation operation, + EndPoint endPoint, + out IReadOnlyList preludeLines, + out string requestArgumentLine) + { + preludeLines = Array.Empty(); + requestArgumentLine = string.Empty; + + var preferredContent = GetPreferredRequestContent(operation); + if (preferredContent is not { } requestContent || + !IsJsonContentType(requestContent.ContentType) || + !TryGetMediaTypeExampleText(requestContent.MediaType, requestContent.ContentType, out var requestExample)) + { + return false; + } + + preludeLines = new[] + { + $"var request = global::System.Text.Json.JsonSerializer.Deserialize<{endPoint.RequestType.CSharpTypeWithoutNullability}>(", + $" {ToVerbatimStringLiteral(requestExample)})!;", + }; + requestArgumentLine = "request: request"; + return true; + } + + private static bool TryGetParameterJsonText( + IOpenApiParameter parameter, + TypeData type, + bool allowSchemaFallback, + out string jsonText, + out bool hasExplicitExample) + { + jsonText = string.Empty; + hasExplicitExample = false; + + foreach (var example in (parameter.Examples ?? new Dictionary()) + .OrderBy(static x => x.Key, StringComparer.Ordinal) + .Select(static x => x.Value)) + { + if (TryGetExampleJsonText(example, type, out jsonText)) + { + hasExplicitExample = true; + return true; + } + } + + if (parameter.Example != null) + { + jsonText = parameter.Example.ToJsonString(HttpJsonOptions); + hasExplicitExample = true; + return true; + } + + if (!allowSchemaFallback) + { + return false; + } + + if (parameter.Schema?.Default != null) + { + jsonText = parameter.Schema.Default.ToJsonString(HttpJsonOptions); + return true; + } + + if (parameter.Schema?.Enum is { Count: > 0 }) + { + jsonText = parameter.Schema.Enum[0].ToJsonString(HttpJsonOptions); + return true; + } + + return false; + } + + private static bool TryGetExampleJsonText( + IOpenApiExample? example, + TypeData type, + out string jsonText) + { + jsonText = string.Empty; + if (example == null) + { + return false; + } + + if (example.DataValue != null) + { + jsonText = example.DataValue.ToJsonString(HttpJsonOptions); + return true; + } + + if (example.Value != null) + { + jsonText = example.Value.ToJsonString(HttpJsonOptions); + return true; + } + + return example.SerializedValue != null && + TryNormalizeScalarToJson(example.SerializedValue, type, out jsonText); + } + + private static bool TryGetMediaTypeExampleText( + IOpenApiMediaType mediaType, + string contentType, + out string exampleText) + { + var text = + GetExampleText(mediaType.Examples, contentType, rawScalars: false) ?? + (mediaType.Example != null + ? FormatExampleNode(mediaType.Example, contentType, rawScalars: false) + : null); + + if (!string.IsNullOrWhiteSpace(text)) + { + exampleText = text; + return true; + } + + if (mediaType.Schema?.Example != null) + { + exampleText = mediaType.Schema.Example.ToJsonString(HttpJsonOptions); + return true; + } + + exampleText = string.Empty; + return false; + } + + private static string? TryGetFirstSuccessResponseExample(OpenApiOperation operation) + { + foreach (var response in (operation.Responses ?? new Dictionary()) + .Where(static x => x.Key.StartsWith("2", StringComparison.OrdinalIgnoreCase)) + .OrderBy(static x => x.Key, StringComparer.Ordinal)) + { + foreach (var content in (response.Value.Content ?? new Dictionary()) + .OrderBy(static x => x.Key, StringComparer.Ordinal)) + { + if (TryGetMediaTypeExampleText(content.Value, content.Key, out var exampleText)) + { + return exampleText; + } + } + } + + return null; + } + + private static bool TryCreateCSharpExpression( + TypeData type, + string jsonText, + out string expression) + { + expression = string.Empty; + if (!TryParseJsonNode(jsonText, out var node)) + { + return false; + } + + if (type.CSharpTypeWithoutNullability == "string" && + node is JsonValue stringNode && + stringNode.TryGetValue(out var stringValue)) + { + expression = ToCSharpStringLiteral(stringValue); + return true; + } + + if (type.CSharpTypeWithoutNullability == "bool" && + node is JsonValue boolNode && + boolNode.TryGetValue(out var boolValue)) + { + expression = boolValue ? "true" : "false"; + return true; + } + + if (IsIntegralType(type.CSharpTypeWithoutNullability) && + node is JsonValue) + { + expression = node.ToJsonString(); + return true; + } + + if (type.IsEnum && + !type.IsAnyOfLike && + node is JsonValue enumNode && + enumNode.TryGetValue(out var enumValue)) + { + var enumIndex = Array.FindIndex(type.EnumValues.ToArray(), x => string.Equals(x, enumValue, StringComparison.Ordinal)); + if (enumIndex >= 0 && + enumIndex < type.Properties.Length) + { + expression = $"{type.CSharpTypeWithoutNullability}.{type.Properties[enumIndex]}"; + return true; + } + } + + expression = $@"global::System.Text.Json.JsonSerializer.Deserialize<{type.CSharpTypeWithoutNullability}>( + {ToVerbatimStringLiteral(jsonText)})!"; + return true; + } + + private static bool TryNormalizeScalarToJson( + string rawValue, + TypeData type, + out string jsonText) + { + rawValue = rawValue.Trim(); + if (string.IsNullOrWhiteSpace(rawValue)) + { + jsonText = string.Empty; + return false; + } + + if (TryParseJsonNode(rawValue, out _)) + { + jsonText = rawValue; + return true; + } + + if (type.CSharpTypeWithoutNullability == "bool" && + bool.TryParse(rawValue, out var boolValue)) + { + jsonText = boolValue ? "true" : "false"; + return true; + } + + if (IsIntegralType(type.CSharpTypeWithoutNullability) && + long.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + jsonText = rawValue; + return true; + } + + if ((type.CSharpTypeWithoutNullability == "double" || + type.CSharpTypeWithoutNullability == "float") && + double.TryParse(rawValue, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _)) + { + jsonText = rawValue; + return true; + } + + jsonText = JsonSerializer.Serialize(rawValue); + return true; + } + + private static bool TryParseJsonNode(string text, out JsonNode? node) + { + try + { + node = JsonNode.Parse(text); + return node != null; + } + catch (JsonException) + { + node = null; + return false; + } + } + + private static bool IsIntegralType(string typeName) + { + return typeName is "byte" or "sbyte" or "short" or "ushort" or "int" or "uint" or "long" or "ulong"; + } + + private static bool HasSnippetSource(OpenApiOperation operation) + { + if (TryGetPreferredCodeSample(operation, out _)) + { + return true; + } + + if ((operation.Parameters ?? []).Any(static x => + (x.Examples?.Count ?? 0) > 0 || + x.Example != null)) + { + return true; + } + + if ((operation.RequestBody?.Content ?? new Dictionary()).Any(static x => + (x.Value.Examples?.Count ?? 0) > 0 || + x.Value.Example != null || + x.Value.Schema?.Example != null)) + { + return true; + } + + return (operation.Responses ?? new Dictionary()).Any(static x => + (x.Value.Content ?? new Dictionary()).Any(static y => + (y.Value.Examples?.Count ?? 0) > 0 || + y.Value.Example != null || + y.Value.Schema?.Example != null)); + } + + private static bool TryGetPreferredCodeSample( + OpenApiOperation operation, + out string codeSample) + { + codeSample = string.Empty; + if (!(operation.Extensions ?? new Dictionary()) + .TryGetValue("x-codeSamples", out var extension)) + { + return false; + } + + var jsonNode = OpenApiExtensions.TryGetExtensionJsonNode(extension); + if (jsonNode is not JsonArray samplesArray || samplesArray.Count == 0) + { + return false; + } + + var source = samplesArray + .OfType() + .FirstOrDefault(static sample => HasSnippetLang(sample, "csharp") || HasSnippetLang(sample, "c#")); + if (source == null || + !source.TryGetPropertyValue("source", out var sourceNode) || + sourceNode is not JsonValue sourceValue || + !sourceValue.TryGetValue(out var codeSampleValue) || + string.IsNullOrWhiteSpace(codeSampleValue)) + { + codeSample = string.Empty; + return false; + } + + codeSample = codeSampleValue; + return true; + } + + private static bool HasSnippetLang(JsonObject sample, string expectedLang) + { + if (sample.TryGetPropertyValue("lang", out var langNode) && + langNode is JsonValue langValue && + langValue.TryGetValue(out var langStr)) + { + return string.Equals(langStr, expectedLang, StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static string GetSnippetTitle(OperationContext operation) + { + var summary = !string.IsNullOrWhiteSpace(operation.Operation.Summary) + ? operation.Operation.Summary + : operation.Operation.Description; + return !string.IsNullOrWhiteSpace(summary) + ? summary.Trim() + : $"{operation.OperationType.Method.ToUpperInvariant()} {operation.OperationPath}"; + } + + private static string Slugify(string value) + { + var slug = string.Concat(value.Select(char.ToLowerInvariant)); + slug = Regex.Replace(slug, @"[^a-z0-9]+", "-"); + return slug.Trim('-'); + } + + private static string ToCSharpStringLiteral(string value) + { + return JsonSerializer.Serialize(value); + } + + private static string ToVerbatimStringLiteral(string value) + { + return "@\"" + NormalizeSnippetNewlines(value).Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + } + + private static string NormalizeSnippetNewlines(string value) + { + return value.Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/src/libs/AutoSDK.Docs/AutoSDK.Docs.csproj b/src/libs/AutoSDK.Docs/AutoSDK.Docs.csproj index 26d88bfbad5..8c89bd552f3 100644 --- a/src/libs/AutoSDK.Docs/AutoSDK.Docs.csproj +++ b/src/libs/AutoSDK.Docs/AutoSDK.Docs.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/src/libs/AutoSDK.Docs/DocsConfig.cs b/src/libs/AutoSDK.Docs/DocsConfig.cs index a4b139c685a..79ed1564777 100644 --- a/src/libs/AutoSDK.Docs/DocsConfig.cs +++ b/src/libs/AutoSDK.Docs/DocsConfig.cs @@ -23,6 +23,8 @@ public sealed record DocsConfig public string? DocsExamplesDirectory { get; init; } + public string? GeneratedExamplesPath { get; init; } + public string? ReadmeExamplesStartMarker { get; init; } public string? ReadmeExamplesEndMarker { get; init; } diff --git a/src/libs/AutoSDK.Docs/DocsSynchronizer.cs b/src/libs/AutoSDK.Docs/DocsSynchronizer.cs index e59c872b65b..8db9b339ad2 100644 --- a/src/libs/AutoSDK.Docs/DocsSynchronizer.cs +++ b/src/libs/AutoSDK.Docs/DocsSynchronizer.cs @@ -1,5 +1,7 @@ using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; +using AutoSDK.Models; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -7,6 +9,11 @@ namespace AutoSDK.Docs; public static class DocsSynchronizer { + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + public static async Task SyncAsync( string solutionDirectory, string? configPath = null, @@ -54,6 +61,24 @@ private static async Task SyncMetadataExamplesAsync( .ToList(); } + var generatedExamples = await LoadGeneratedExamplesAsync(project.GeneratedExamplesPath, cancellationToken).ConfigureAwait(false); + if (generatedExamples.Count > 0) + { + var existingSlugs = new HashSet(examples.Select(static x => x.Slug), StringComparer.Ordinal); + foreach (var example in generatedExamples) + { + if (existingSlugs.Add(example.Slug)) + { + examples.Add(example); + } + } + + examples = examples + .OrderBy(x => x.Order) + .ThenBy(x => x.Title, StringComparer.Ordinal) + .ToList(); + } + var readme = NormalizeNewlines(await File.ReadAllTextAsync(project.ReadmePath, cancellationToken).ConfigureAwait(false)); if (HasMarkerBlock(readme, project.ReadmeExamplesStartMarker, project.ReadmeExamplesEndMarker)) { @@ -112,7 +137,39 @@ private static async Task SyncMetadataExamplesAsync( title, slug, description, - TransformCode(body, clientClassName, apiKeyVariableName, clientReplacements)); + TransformCode(body, clientClassName, apiKeyVariableName, clientReplacements), + "csharp", + null); + } + + private static async Task> LoadGeneratedExamplesAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return []; + } + + var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(json, SerializerOptions) ?? + GeneratedSdkSnippetManifest.Empty; + + return manifest.Examples + .Where(static x => + !string.IsNullOrWhiteSpace(x.Title) && + !string.IsNullOrWhiteSpace(x.Slug) && + !string.IsNullOrWhiteSpace(x.Code) && + !string.IsNullOrWhiteSpace(x.Language)) + .Select(static x => new MetadataExampleDocument( + x.Order, + x.Title, + x.Slug, + x.Description, + x.Code, + x.Language, + x.Setup)) + .ToList(); } private static string? TryExtractSingleTestMethodBody(string text) @@ -226,7 +283,8 @@ private static string BuildReadmeExamples(List examples builder.AppendLine(example.Title); builder.AppendLine(example.Description); builder.AppendLine(); - builder.AppendLine("```csharp"); + builder.Append("```"); + builder.AppendLine(example.Language); builder.AppendLine(example.Code); builder.AppendLine("```"); @@ -242,14 +300,20 @@ private static string BuildReadmeExamples(List examples private static string BuildExamplePage(MetadataExampleDocument example, string exampleIntro) { var builder = new StringBuilder(); + var intro = example.Setup ?? exampleIntro; builder.Append("# "); builder.AppendLine(example.Title); builder.AppendLine(); builder.AppendLine(example.Description); + if (!string.IsNullOrWhiteSpace(intro)) + { + builder.AppendLine(); + builder.AppendLine(intro); + } + builder.AppendLine(); - builder.AppendLine(exampleIntro); - builder.AppendLine(); - builder.AppendLine("```csharp"); + builder.Append("```"); + builder.AppendLine(example.Language); builder.AppendLine(example.Code); builder.AppendLine("```"); return builder.ToString().TrimEnd(); @@ -391,7 +455,9 @@ internal sealed record MetadataExampleDocument( string Title, string Slug, string Description, - string Code); + string Code, + string Language, + string? Setup); internal sealed record ExampleMetadata(int Order, string Title, string Slug, string Description) { @@ -467,6 +533,7 @@ internal sealed record ResolvedProject( string DocsDirectory, string ExampleSourceDirectory, string OutputDirectory, + string GeneratedExamplesPath, string Namespace, string BrandName, string ClientClassName, @@ -489,6 +556,10 @@ public static ResolvedProject Create(string solutionDirectory, DocsConfig config solutionDirectory, config.DocsExamplesDirectory, Path.Combine(docsDirectory, "examples")); + var generatedExamplesPath = ResolvePath( + solutionDirectory, + config.GeneratedExamplesPath, + Path.Combine(solutionDirectory, "autosdk.generated-examples.json")); var namespaceValue = config.Namespace ?? InferNamespace(solutionDirectory); var brandName = config.BrandName ?? namespaceValue; @@ -504,6 +575,7 @@ public static ResolvedProject Create(string solutionDirectory, DocsConfig config docsDirectory, exampleSourceDirectory, outputDirectory, + generatedExamplesPath, namespaceValue, brandName, clientClassName, diff --git a/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs b/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs new file mode 100644 index 00000000000..67a392e9791 --- /dev/null +++ b/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs @@ -0,0 +1,18 @@ +namespace AutoSDK.Models; + +public sealed record GeneratedSdkSnippetManifest( + IReadOnlyList Examples) +{ + public static GeneratedSdkSnippetManifest Empty { get; } = new([]); +} + +public sealed record GeneratedSdkSnippetDocument( + int Order, + string Title, + string Slug, + string Description, + string Language, + string Code, + string Format, + string OperationId, + string? Setup = null); diff --git a/src/tests/AutoSDK.IntegrationTests.Cli/CliTests.cs b/src/tests/AutoSDK.IntegrationTests.Cli/CliTests.cs index f2656f471e0..91e3401de69 100644 --- a/src/tests/AutoSDK.IntegrationTests.Cli/CliTests.cs +++ b/src/tests/AutoSDK.IntegrationTests.Cli/CliTests.cs @@ -27,6 +27,110 @@ public async Task Generate_BlandAISdk() await GenerateAsync("blandai.yaml", targetFramework: "net10.0"); } + [TestMethod] + public async Task Generate_WithOpenApiExamples_EmitsGeneratedSnippetManifest() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + Directory.CreateDirectory(tempDirectory); + + var specPath = Path.Combine(tempDirectory, "examples.yaml"); + await File.WriteAllTextAsync( + specPath, + """ + openapi: 3.0.1 + info: + title: Examples + version: 1.0.0 + paths: + /items/{itemId}: + post: + operationId: createItem + parameters: + - name: itemId + in: path + required: true + schema: + type: string + example: item_123 + requestBody: + required: true + content: + application/json: + example: + name: widget + schema: + type: object + properties: + name: + type: string + responses: + '201': + description: Created + content: + application/json: + example: + id: item_123 + schema: + type: object + properties: + id: + type: string + /uploads: + post: + operationId: uploadFile + requestBody: + required: true + content: + multipart/form-data: + example: + file: example.txt + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '202': + description: Accepted + """); + + var currentDirectory = Directory.GetCurrentDirectory(); + var repositoryDirectory = Path.GetFullPath(Path.Combine(currentDirectory, "../../../../../..")); + var outputDirectory = Path.Combine(tempDirectory, "Generated"); + + var generateResult = await RunDotnetAsync( + repositoryDirectory, + "run", + "--disable-build-servers", + "--no-launch-profile", + "--project", "src/libs/AutoSDK.CLI", + "generate", specPath, + "--namespace", "G", + "--clientClassName", "ExampleClient", + "--output", outputDirectory); + + Console.WriteLine(generateResult.StandardOutput); + Console.WriteLine(generateResult.StandardError); + generateResult.ExitCode.Should().Be(0); + + var manifestPath = Path.Combine(outputDirectory, "autosdk.generated-examples.json"); + File.Exists(manifestPath).Should().BeTrue(); + + var manifest = await File.ReadAllTextAsync(manifestPath); + manifest.Should().Contain("\"OperationId\": \"createItem\""); + manifest.Should().Contain("\"OperationId\": \"uploadFile\""); + manifest.Should().Contain("\"Language\": \"csharp\""); + manifest.Should().Contain("\"Language\": \"http\""); + } + finally + { + TryDeleteDirectory(tempDirectory); + } + } + [TestMethod] public async Task Generate_ProtoInput_ScaffoldsGrpcClientProject() { diff --git a/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs b/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs index 45d80b420f6..dc5a3b3ad68 100644 --- a/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs +++ b/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using AutoSDK.Docs; +using AutoSDK.Models; namespace AutoSDK.UnitTests; @@ -288,6 +290,79 @@ public async Task Valid() } } + [TestMethod] + public async Task SyncAsync_GeneratedExamples_MergesLanguageAndSetup() + { + var root = CreateTempDirectory(); + + try + { + Directory.CreateDirectory(Path.Combine(root, "docs")); + Directory.CreateDirectory(Path.Combine(root, "src", "libs", "GeneratedSdk")); + + await File.WriteAllTextAsync( + Path.Combine(root, "README.md"), + """ + # GeneratedSdk + + + + """); + await File.WriteAllTextAsync( + Path.Combine(root, "mkdocs.yml"), + """ + nav: + - Overview: index.md + # EXAMPLES:START + # EXAMPLES:END + """); + await File.WriteAllTextAsync( + Path.Combine(root, "src", "libs", "GeneratedSdk", "GeneratedSdk.csproj"), + ""); + await File.WriteAllTextAsync( + Path.Combine(root, "autosdk.generated-examples.json"), + JsonSerializer.Serialize( + new GeneratedSdkSnippetManifest( + [ + new GeneratedSdkSnippetDocument( + Order: 1, + Title: "Upload File", + Slug: "upload-file", + Description: "Uses the generated HTTP fallback snippet.", + Language: "http", + Code: "POST {{host}}/uploads", + Format: "http", + OperationId: "uploadFile", + Setup: "This example uses the generated HTTP request snippet.") + ]), + new JsonSerializerOptions + { + WriteIndented = true, + })); + + var result = await DocsSynchronizer.SyncAsync(root); + + result.Mode.Should().Be("metadata"); + result.ExampleCount.Should().Be(1); + + var readme = await File.ReadAllTextAsync(Path.Combine(root, "README.md")); + readme.Should().Contain("### Upload File"); + readme.Should().Contain("```http"); + + var examplePage = await File.ReadAllTextAsync(Path.Combine(root, "docs", "examples", "upload-file.md")); + examplePage.Should().Contain("This example uses the generated HTTP request snippet."); + examplePage.Should().Contain("```http"); + examplePage.Should().Contain("POST {{host}}/uploads"); + + var mkDocs = await File.ReadAllTextAsync(Path.Combine(root, "mkdocs.yml")); + mkDocs.Should().Contain("examples/upload-file.md"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + private static string CreateTempDirectory() { var path = Path.Combine(Path.GetTempPath(), "autosdk-docs-" + Guid.NewGuid().ToString("N")); diff --git a/src/tests/AutoSDK.UnitTests/SnippetGenerationTests.cs b/src/tests/AutoSDK.UnitTests/SnippetGenerationTests.cs new file mode 100644 index 00000000000..8924461e3ef --- /dev/null +++ b/src/tests/AutoSDK.UnitTests/SnippetGenerationTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using AutoSDK.Extensions; +using AutoSDK.Generation; +using AutoSDK.Helpers; +using AutoSDK.Models; + +namespace AutoSDK.UnitTests; + +[TestClass] +public sealed class SnippetGenerationTests +{ + private static Settings DefaultSettings => Settings.Default with + { + Namespace = "G", + ClassName = "ExampleClient", + GenerateMethods = true, + GenerateSdk = true, + }; + + [TestMethod] + public void GenerateSnippetManifest_WithJsonExamples_EmitsSdkSnippet() + { + var yaml = """ + openapi: 3.0.1 + info: + title: Test + version: 1.0.0 + paths: + /items/{itemId}: + post: + operationId: createItem + summary: Create item + parameters: + - name: itemId + in: path + required: true + schema: + type: string + example: item_123 + - name: includeDetails + in: query + schema: + type: boolean + example: true + requestBody: + required: true + content: + application/json: + example: + name: widget + count: 3 + schema: + type: object + required: + - name + properties: + name: + type: string + count: + type: integer + responses: + '201': + description: Created + content: + application/json: + example: + id: item_123 + status: created + schema: + type: object + properties: + id: + type: string + status: + type: string + """; + + var document = yaml.GetOpenApiDocument(DefaultSettings); + var schemas = document.GetSchemas(DefaultSettings); + var operations = document.GetOperations(DefaultSettings, globalSettings: DefaultSettings, schemas); + var data = CSharpPipeline.PrepareAndEnrich(((yaml, DefaultSettings), GlobalSettings: DefaultSettings)); + + var manifestJson = Sources.GenerateSnippetManifest(operations, data.Methods.ToArray()); + var manifest = JsonSerializer.Deserialize(manifestJson); + + manifest.Should().NotBeNull(); + manifest!.Examples.Should().ContainSingle(); + + var snippet = manifest.Examples[0]; + snippet.Language.Should().Be("csharp"); + snippet.Format.Should().Be("sdk"); + snippet.Code.Should().Contain("using var client = new ExampleClient();"); + snippet.Code.Should().Contain("var request = global::System.Text.Json.JsonSerializer.Deserialize<"); + snippet.Code.Should().Contain("itemId: \"item_123\""); + snippet.Code.Should().Contain("includeDetails: true"); + snippet.Code.Should().Contain("var response = await client.CreateItemAsync("); + snippet.Code.Should().Contain("// Example response:"); + snippet.Setup.Should().Contain("using G;"); + } + + [TestMethod] + public void GenerateSnippetManifest_WithUnsupportedSdkShape_FallsBackToHttp() + { + var yaml = """ + openapi: 3.0.1 + info: + title: Test + version: 1.0.0 + paths: + /uploads: + post: + operationId: uploadFile + summary: Upload file + requestBody: + required: true + content: + multipart/form-data: + example: + file: example.txt + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '202': + description: Accepted + """; + + var document = yaml.GetOpenApiDocument(DefaultSettings); + var schemas = document.GetSchemas(DefaultSettings); + var operations = document.GetOperations(DefaultSettings, globalSettings: DefaultSettings, schemas); + var data = CSharpPipeline.PrepareAndEnrich(((yaml, DefaultSettings), GlobalSettings: DefaultSettings)); + + var manifestJson = Sources.GenerateSnippetManifest(operations, data.Methods.ToArray()); + var manifest = JsonSerializer.Deserialize(manifestJson); + + manifest.Should().NotBeNull(); + manifest!.Examples.Should().ContainSingle(); + + var snippet = manifest.Examples[0]; + snippet.Language.Should().Be("http"); + snippet.Format.Should().Be("http"); + snippet.Code.Should().Contain("POST {{host}}/uploads"); + snippet.Code.Should().Contain("Content-Type: multipart/form-data"); + } +} From 0e658154bd8ec015c82cf0ebaab444bb30c6bb27 Mon Sep 17 00:00:00 2001 From: "Konstantin S." Date: Thu, 9 Apr 2026 16:53:02 +0400 Subject: [PATCH 2/4] fix(docs): restore legacy target compatibility for snippets --- .../Sources/Sources.Snippets.cs | 50 ++++++++--------- .../Models/GeneratedSdkSnippetManifest.cs | 54 ++++++++++++++----- src/tests/AutoSDK.UnitTests/DocsSyncTests.cs | 18 +++---- 3 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs index 3ce59067d81..ce91a96c217 100644 --- a/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs +++ b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs @@ -89,39 +89,39 @@ public static string GenerateSnippetManifest( TryCreateGeneratedCSharpSnippet(operation, method, out var csharpCode, out var setup)) { return new GeneratedSdkSnippetDocument( - Order: order, - Title: title, - Slug: slug, - Description: description, - Language: "csharp", - Code: csharpCode, - Format: "sdk", - OperationId: operationId, - Setup: setup); + order, + title, + slug, + description, + "csharp", + csharpCode, + "sdk", + operationId, + setup); } if (TryGetPreferredCodeSample(operation.Operation, out var codeSample)) { return new GeneratedSdkSnippetDocument( - Order: order, - Title: title, - Slug: slug, - Description: description, - Language: "csharp", - Code: codeSample, - Format: "sdk", - OperationId: operationId); + order, + title, + slug, + description, + "csharp", + codeSample, + "sdk", + operationId); } return new GeneratedSdkSnippetDocument( - Order: order, - Title: title, - Slug: slug, - Description: description, - Language: "http", - Code: GenerateHttpRequest(operation).TrimEnd(), - Format: "http", - OperationId: operationId); + order, + title, + slug, + description, + "http", + GenerateHttpRequest(operation).TrimEnd(), + "http", + operationId); } private static bool TryCreateGeneratedCSharpSnippet( diff --git a/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs b/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs index 67a392e9791..2282917dde0 100644 --- a/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs +++ b/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs @@ -1,18 +1,48 @@ namespace AutoSDK.Models; -public sealed record GeneratedSdkSnippetManifest( - IReadOnlyList Examples) +public sealed class GeneratedSdkSnippetManifest { + public GeneratedSdkSnippetManifest(IReadOnlyList examples) + { + Examples = examples; + } + + public IReadOnlyList Examples { get; set; } + public static GeneratedSdkSnippetManifest Empty { get; } = new([]); } -public sealed record GeneratedSdkSnippetDocument( - int Order, - string Title, - string Slug, - string Description, - string Language, - string Code, - string Format, - string OperationId, - string? Setup = null); +public sealed class GeneratedSdkSnippetDocument +{ + public GeneratedSdkSnippetDocument( + int order, + string title, + string slug, + string description, + string language, + string code, + string format, + string operationId, + string? setup = null) + { + Order = order; + Title = title; + Slug = slug; + Description = description; + Language = language; + Code = code; + Format = format; + OperationId = operationId; + Setup = setup; + } + + public int Order { get; set; } + public string Title { get; set; } + public string Slug { get; set; } + public string Description { get; set; } + public string Language { get; set; } + public string Code { get; set; } + public string Format { get; set; } + public string OperationId { get; set; } + public string? Setup { get; set; } +} diff --git a/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs b/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs index dc5a3b3ad68..3671c27134b 100644 --- a/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs +++ b/src/tests/AutoSDK.UnitTests/DocsSyncTests.cs @@ -325,15 +325,15 @@ await File.WriteAllTextAsync( new GeneratedSdkSnippetManifest( [ new GeneratedSdkSnippetDocument( - Order: 1, - Title: "Upload File", - Slug: "upload-file", - Description: "Uses the generated HTTP fallback snippet.", - Language: "http", - Code: "POST {{host}}/uploads", - Format: "http", - OperationId: "uploadFile", - Setup: "This example uses the generated HTTP request snippet.") + 1, + "Upload File", + "upload-file", + "Uses the generated HTTP fallback snippet.", + "http", + "POST {{host}}/uploads", + "http", + "uploadFile", + "This example uses the generated HTTP request snippet.") ]), new JsonSerializerOptions { From 0c24c80790be0534b58717712737e74ccdb01440 Mon Sep 17 00:00:00 2001 From: "Konstantin S." Date: Thu, 9 Apr 2026 16:58:19 +0400 Subject: [PATCH 3/4] fix(docs): harden snippet generation for multi-target builds --- .../Sources/Sources.Snippets.cs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs index ce91a96c217..d6604396387 100644 --- a/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs +++ b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs @@ -81,7 +81,7 @@ public static string GenerateSnippetManifest( var title = GetSnippetTitle(operation); var description = string.IsNullOrWhiteSpace(operation.Operation.Description) ? "Generated from OpenAPI examples." - : operation.Operation.Description.Trim(); + : operation.Operation.Description?.Trim() ?? "Generated from OpenAPI examples."; var slug = Slugify(operation.Operation.OperationId ?? title); var operationId = operation.Operation.OperationId ?? operation.MethodName; @@ -239,9 +239,10 @@ private static bool TryCreateGeneratedCSharpSnippet( var responseExample = TryGetFirstSuccessResponseExample(operation.Operation); if (!string.IsNullOrWhiteSpace(responseExample)) { + var normalizedResponseExample = NormalizeSnippetNewlines(responseExample!); builder.AppendLine(); builder.AppendLine("// Example response:"); - foreach (var line in NormalizeSnippetNewlines(responseExample).Split('\n')) + foreach (var line in normalizedResponseExample.Split('\n')) { builder.Append("// "); builder.AppendLine(line); @@ -422,7 +423,7 @@ private static bool TryGetMediaTypeExampleText( if (!string.IsNullOrWhiteSpace(text)) { - exampleText = text; + exampleText = text!; return true; } @@ -651,9 +652,12 @@ private static string GetSnippetTitle(OperationContext operation) var summary = !string.IsNullOrWhiteSpace(operation.Operation.Summary) ? operation.Operation.Summary : operation.Operation.Description; - return !string.IsNullOrWhiteSpace(summary) - ? summary.Trim() - : $"{operation.OperationType.Method.ToUpperInvariant()} {operation.OperationPath}"; + if (string.IsNullOrWhiteSpace(summary)) + { + return $"{operation.OperationType.Method.ToUpperInvariant()} {operation.OperationPath}"; + } + + return summary!.Trim(); } private static string Slugify(string value) @@ -670,11 +674,11 @@ private static string ToCSharpStringLiteral(string value) private static string ToVerbatimStringLiteral(string value) { - return "@\"" + NormalizeSnippetNewlines(value).Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + return "@\"" + NormalizeSnippetNewlines(value).Replace("\"", "\"\"") + "\""; } private static string NormalizeSnippetNewlines(string value) { - return value.Replace("\r\n", "\n", StringComparison.Ordinal); + return value.Replace("\r\n", "\n"); } } From 5264f9891d56f61b5de8e7436ecc7cd490bc0a69 Mon Sep 17 00:00:00 2001 From: "Konstantin S." Date: Fri, 10 Apr 2026 05:06:03 +0400 Subject: [PATCH 4/4] fix(ci): remove duplicate webhook source entry --- src/libs/AutoSDK.CSharp/Sources/Sources.cs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/libs/AutoSDK.CSharp/Sources/Sources.cs b/src/libs/AutoSDK.CSharp/Sources/Sources.cs index c6a6ccf2834..80400e0fe05 100644 --- a/src/libs/AutoSDK.CSharp/Sources/Sources.cs +++ b/src/libs/AutoSDK.CSharp/Sources/Sources.cs @@ -50,20 +50,6 @@ public static FileWithName WebhooksHttpFile( Text: GenerateWebhookHttpFile(operations)); } - public static FileWithName WebhooksHttpFile( - IReadOnlyList operations, - CancellationToken cancellationToken = default) - { - if (operations == null || operations.Count == 0) - { - return FileWithName.Empty; - } - - return new FileWithName( - Name: "webhooks.http", - Text: GenerateWebhookHttpFile(operations)); - } - public static FileWithName Class( ModelData modelData, CancellationToken cancellationToken = default)