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..d6604396387 --- /dev/null +++ b/src/libs/AutoSDK.CSharp/Sources/Sources.Snippets.cs @@ -0,0 +1,684 @@ +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() ?? "Generated from OpenAPI examples."; + 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, + title, + slug, + description, + "csharp", + csharpCode, + "sdk", + operationId, + setup); + } + + if (TryGetPreferredCodeSample(operation.Operation, out var codeSample)) + { + return new GeneratedSdkSnippetDocument( + order, + title, + slug, + description, + "csharp", + codeSample, + "sdk", + operationId); + } + + return new GeneratedSdkSnippetDocument( + order, + title, + slug, + description, + "http", + GenerateHttpRequest(operation).TrimEnd(), + "http", + 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)) + { + var normalizedResponseExample = NormalizeSnippetNewlines(responseExample!); + builder.AppendLine(); + builder.AppendLine("// Example response:"); + foreach (var line in normalizedResponseExample.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; + if (string.IsNullOrWhiteSpace(summary)) + { + return $"{operation.OperationType.Method.ToUpperInvariant()} {operation.OperationPath}"; + } + + return summary!.Trim(); + } + + 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("\"", "\"\"") + "\""; + } + + private static string NormalizeSnippetNewlines(string value) + { + return value.Replace("\r\n", "\n"); + } +} 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..2282917dde0 --- /dev/null +++ b/src/libs/AutoSDK/Models/GeneratedSdkSnippetManifest.cs @@ -0,0 +1,48 @@ +namespace AutoSDK.Models; + +public sealed class GeneratedSdkSnippetManifest +{ + public GeneratedSdkSnippetManifest(IReadOnlyList examples) + { + Examples = examples; + } + + public IReadOnlyList Examples { get; set; } + + public static GeneratedSdkSnippetManifest Empty { get; } = new([]); +} + +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.IntegrationTests.Cli/CliTests.cs b/src/tests/AutoSDK.IntegrationTests.Cli/CliTests.cs index ade35974572..6c30ee00f0b 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..3671c27134b 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( + 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 + { + 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"); + } +}