diff --git a/src/Trax.Cli/Generator/CodeRenderer.cs b/src/Trax.Cli/Generator/CodeRenderer.cs index 0e5d76c..9c2cf55 100644 --- a/src/Trax.Cli/Generator/CodeRenderer.cs +++ b/src/Trax.Cli/Generator/CodeRenderer.cs @@ -25,18 +25,17 @@ public string RenderTrainInterface(ApiOperation operation, string projectName) { var isUnit = IsUnitOutput(operation.OutputType); var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}"; + var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation); return Render( "TrainInterface", new { Namespace = ns, TrainName = operation.Name, - InputTypeName = operation.InputType.Fields.Count > 0 - ? operation.InputType.Name - : "Unit", - OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name, + OutputTypeName = outputName, OutputIsUnit = isUnit, - InputIsUnit = operation.InputType.Fields.Count == 0, + InputTypeName = operation.InputType.Name, + InputIsUnit = false, ModelsUsing = _modelsNamespace, } ); @@ -47,21 +46,24 @@ public string RenderTrainImplementation(ApiOperation operation, string projectNa var isUnit = IsUnitOutput(operation.OutputType); var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}"; var attribute = operation.Kind == OperationKind.Query ? "TraxQuery" : "TraxMutation"; + var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation); return Render( "TrainImplementation", new { Namespace = ns, TrainName = operation.Name, - InputTypeName = operation.InputType.Fields.Count > 0 - ? operation.InputType.Name - : "Unit", - OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name, + InputTypeName = operation.InputType.Name, + OutputTypeName = outputName, OutputIsUnit = isUnit, - InputIsUnit = operation.InputType.Fields.Count == 0, + InputIsUnit = false, Attribute = attribute, - Description = operation.Description ?? $"{operation.Name} operation", + Description = SanitizeDescription( + operation.Description ?? $"{operation.Name} operation" + ), ModelsUsing = _modelsNamespace, + GraphQLNamespace = operation.Group, + TrainsNamespace = $"{projectName}.Trains", } ); } @@ -77,6 +79,7 @@ public string RenderInput(ApiOperation operation, string projectName) TypeName = operation.InputType.Name, Fields = operation.InputType.Fields.Select(MapField).ToList(), HasFields = operation.InputType.Fields.Count > 0, + ModelsUsing = _modelsNamespace, } ); } @@ -101,18 +104,17 @@ public string RenderJunction(ApiOperation operation, string projectName) { var isUnit = IsUnitOutput(operation.OutputType); var ns = $"{projectName}.Trains.{operation.Group}.{operation.Name}"; + var outputName = isUnit ? "Unit" : QualifyIfCollides(operation.OutputType.Name, operation); return Render( "Junction", new { Namespace = ns, TrainName = operation.Name, - InputTypeName = operation.InputType.Fields.Count > 0 - ? operation.InputType.Name - : "Unit", - OutputTypeName = isUnit ? "Unit" : operation.OutputType.Name, + InputTypeName = operation.InputType.Name, + OutputTypeName = outputName, OutputIsUnit = isUnit, - InputIsUnit = operation.InputType.Fields.Count == 0, + InputIsUnit = false, HttpMethod = operation.HttpMethod, HttpPath = operation.HttpPath, ModelsUsing = _modelsNamespace, @@ -155,6 +157,33 @@ public string RenderTrainsCsproj() return Render("TrainsCsproj", new { }); } + public string RenderGraphQLNamespaces(IEnumerable groups, string projectName) + { + var scriptObject = new ScriptObject(); + scriptObject["Namespace"] = $"{projectName}.Trains"; + + var groupList = new ScriptArray(); + foreach (var group in groups) + { + var obj = new ScriptObject + { + ["name"] = group, + ["value"] = NamingConventions.ToCamelCase(group), + }; + groupList.Add(obj); + } + + scriptObject["Groups"] = groupList; + + if (!_templates.TryGetValue("GraphQLNamespaces", out var template)) + throw new InvalidOperationException("Template 'GraphQLNamespaces' not found."); + + var context = new TemplateContext(); + context.PushGlobal(scriptObject); + + return template.Render(context); + } + public string RenderManifestNames(List operations, string projectName) { var scriptObject = new ScriptObject(); @@ -192,13 +221,39 @@ private static ScriptObject MapField(ApiField field) ["TypeName"] = field.TypeName, ["IsRequired"] = field.IsRequired, ["IsNullable"] = field.IsNullable, - ["Description"] = field.Description, + ["Description"] = + field.Description != null ? SanitizeDescription(field.Description) : null, ["RequiredKeyword"] = field.IsRequired ? "required " : "", ["NullableMarker"] = field.IsNullable && !field.TypeName.EndsWith('?') ? "?" : "", }; return obj; } + private static string SanitizeDescription(string description) => + description.ReplaceLineEndings(" ").Replace("\"", "\\\""); + + /// + /// Qualifies a type name with the full models namespace if it collides with + /// any segment of the operation's namespace (group or operation name). + /// This prevents CS0118 where C# resolves the name to the namespace instead of the type. + /// + private string QualifyIfCollides(string typeName, ApiOperation operation) + { + if (_modelsNamespace == null) + return typeName; + + // Check if the type name matches the group or operation name (namespace segments) + if ( + string.Equals(typeName, operation.Group, StringComparison.Ordinal) + || string.Equals(typeName, operation.Name, StringComparison.Ordinal) + ) + { + return $"global::{_modelsNamespace}.{typeName}"; + } + + return typeName; + } + private static bool IsUnitOutput(ApiType outputType) => outputType.Name == "Unit" || (outputType.IsBuiltIn && outputType.Fields.Count == 0 && outputType.Name == "Unit"); diff --git a/src/Trax.Cli/Generator/NamingConventions.cs b/src/Trax.Cli/Generator/NamingConventions.cs index a2c9ab8..3312c12 100644 --- a/src/Trax.Cli/Generator/NamingConventions.cs +++ b/src/Trax.Cli/Generator/NamingConventions.cs @@ -105,6 +105,8 @@ public static partial class NamingConventions "post", }; + private static readonly string[] HttpVerbPrefixes = ["Get", "Post", "Put", "Patch", "Delete"]; + public static string ToPascalCase(string name) { if (string.IsNullOrEmpty(name)) @@ -189,6 +191,41 @@ private static string Pluralize(string noun) return noun + "s"; } - [GeneratedRegex(@"[_\-\s]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")] + /// + /// Extracts the short type name from a potentially fully-qualified .NET type name. + /// For example, "AdvocacyDay.CVLegacy.Domain.Bills.DTOs.GetBillDto" becomes "GetBillDto". + /// Names without dots are returned unchanged. + /// + public static string SimplifySchemaName(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + var lastDot = name.LastIndexOf('.'); + return lastDot >= 0 ? name[(lastDot + 1)..] : name; + } + + /// + /// Strips HTTP verb prefixes (Get, Post, Put, Patch, Delete) from a PascalCase name. + /// Returns the original name if stripping would leave it empty. + /// + public static string StripHttpVerbPrefix(string name) + { + foreach (var prefix in HttpVerbPrefixes) + { + if ( + name.StartsWith(prefix, StringComparison.Ordinal) + && name.Length > prefix.Length + && char.IsUpper(name[prefix.Length]) + ) + { + return name[prefix.Length..]; + } + } + + return name; + } + + [GeneratedRegex(@"[_\-\s.,]+|(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")] private static partial Regex SplitPattern(); } diff --git a/src/Trax.Cli/Generator/TraxProjectGenerator.cs b/src/Trax.Cli/Generator/TraxProjectGenerator.cs index bde1e44..89ae251 100644 --- a/src/Trax.Cli/Generator/TraxProjectGenerator.cs +++ b/src/Trax.Cli/Generator/TraxProjectGenerator.cs @@ -61,10 +61,27 @@ internal void GenerateTrainsLibrary(ApiSchema schema, string trainsDir, string p _renderer.RenderManifestNames(schema.Operations, projectName) ); + // Write GraphQLNamespaces.cs + var groups = schema + .Operations.Select(o => o.Group) + .Where(g => g != null) + .Cast() + .Distinct() + .OrderBy(g => g) + .ToList(); + + if (groups.Count > 0) + { + WriteFile( + Path.Combine(trainsDir, "GraphQLNamespaces.cs"), + _renderer.RenderGraphQLNamespaces(groups, projectName) + ); + } + // Write shared types in Models/ foreach (var apiType in schema.Types) { - if (apiType.IsBuiltIn || apiType.Fields.Count == 0) + if (apiType.IsBuiltIn || apiType.Name == "Unit" || apiType.Fields.Count == 0) continue; var modelsDir = Path.Combine(trainsDir, "Models"); @@ -104,13 +121,10 @@ internal void GenerateTrainsLibrary(ApiSchema schema, string trainsDir, string p _renderer.RenderTrainImplementation(operation, projectName) ); - if (operation.InputType.Fields.Count > 0) - { - WriteFile( - Path.Combine(trainDir, $"{operation.InputType.Name}.cs"), - _renderer.RenderInput(operation, projectName) - ); - } + WriteFile( + Path.Combine(trainDir, $"{operation.InputType.Name}.cs"), + _renderer.RenderInput(operation, projectName) + ); if (!operation.OutputType.IsBuiltIn && operation.OutputType.Fields.Count > 0) { diff --git a/src/Trax.Cli/Models/ApiField.cs b/src/Trax.Cli/Models/ApiField.cs index 94cb334..b0ca565 100644 --- a/src/Trax.Cli/Models/ApiField.cs +++ b/src/Trax.Cli/Models/ApiField.cs @@ -3,7 +3,7 @@ namespace Trax.Cli.Models; public class ApiField { public required string Name { get; init; } - public required string TypeName { get; init; } + public required string TypeName { get; set; } public bool IsRequired { get; init; } public bool IsNullable { get; init; } public string? Description { get; init; } diff --git a/src/Trax.Cli/Schema/GraphQL/GraphQLSchemaParser.cs b/src/Trax.Cli/Schema/GraphQL/GraphQLSchemaParser.cs index 1a6c815..3fc0b01 100644 --- a/src/Trax.Cli/Schema/GraphQL/GraphQLSchemaParser.cs +++ b/src/Trax.Cli/Schema/GraphQL/GraphQLSchemaParser.cs @@ -164,10 +164,28 @@ Dictionary enumDefinitions if (rootType.Fields == null) return; + // Collect all known type names to detect namespace/type collisions (CS0118). + // When an operation name matches a type name, the generated namespace segment + // shadows the type reference — e.g. Flowthru.Trains.Group.AllChats.AllChats + var knownTypeNames = new HashSet(StringComparer.Ordinal); + foreach (var name in typeDefinitions.Keys) + knownTypeNames.Add(NamingConventions.ToPascalCase(name)); + foreach (var name in inputDefinitions.Keys) + knownTypeNames.Add(NamingConventions.ToPascalCase(name)); + foreach (var name in enumDefinitions.Keys) + knownTypeNames.Add(NamingConventions.ToPascalCase(name)); + foreach (var field in rootType.Fields) { var operationName = NamingConventions.ToPascalCase(field.Name.StringValue); + // Disambiguate if the operation name collides with a known type name + if (knownTypeNames.Contains(operationName)) + { + var suffix = kind == OperationKind.Query ? "Query" : "Mutation"; + operationName = $"{operationName}{suffix}"; + } + // Build input type from arguments var inputType = BuildInputTypeFromArguments( operationName, diff --git a/src/Trax.Cli/Schema/OpenApi/OpenApiSchemaParser.cs b/src/Trax.Cli/Schema/OpenApi/OpenApiSchemaParser.cs index 0df1953..2722bcc 100644 --- a/src/Trax.Cli/Schema/OpenApi/OpenApiSchemaParser.cs +++ b/src/Trax.Cli/Schema/OpenApi/OpenApiSchemaParser.cs @@ -15,6 +15,7 @@ public class OpenApiSchemaParser : ISchemaParser private readonly Dictionary _resolvedTypes = new(); private readonly Dictionary _resolvedEnums = new(); private readonly HashSet _usedTypeNames = new(); + private readonly HashSet _usedOperationNames = new(); public ApiSchema Parse(string filePath) { @@ -22,7 +23,7 @@ public ApiSchema Parse(string filePath) var reader = new OpenApiStreamReader(); var document = reader.Read(stream, out var diagnostic); - if (diagnostic.Errors.Count > 0) + if (document == null) { var errors = string.Join(Environment.NewLine, diagnostic.Errors.Select(e => e.Message)); throw new InvalidOperationException( @@ -30,13 +31,22 @@ public ApiSchema Parse(string filePath) ); } + if (diagnostic.Errors.Count > 0) + { + foreach (var error in diagnostic.Errors) + { + Console.Error.WriteLine($"Warning: {error.Pointer} - {error.Message}"); + } + } + var schema = new ApiSchema { SourceFile = filePath, SchemaType = "openapi" }; // Collect component schemas first if (document.Components?.Schemas != null) { - foreach (var (name, componentSchema) in document.Components.Schemas) + foreach (var (rawName, componentSchema) in document.Components.Schemas) { + var name = NamingConventions.SimplifySchemaName(rawName); if ( componentSchema.Enum != null && componentSchema.Enum.Count > 0 @@ -47,6 +57,32 @@ public ApiSchema Parse(string filePath) if (!schema.Enums.Any(e => e.Name == apiEnum.Name)) schema.Enums.Add(apiEnum); } + else if (componentSchema.Type == "array" && componentSchema.Items != null) + { + // Array-type component schemas become wrapper types with an Items field + var pascalName = NamingConventions.ToPascalCase(name); + var itemTypeName = ResolveOpenApiType( + componentSchema.Items, + pascalName + "Item" + ); + var apiType = new ApiType + { + Name = pascalName, + Fields = + [ + new ApiField + { + Name = "Items", + TypeName = $"List<{itemTypeName}>", + IsRequired = true, + }, + ], + IsBuiltIn = false, + }; + _resolvedTypes[pascalName] = apiType; + if (!schema.Types.Any(t => t.Name == apiType.Name)) + schema.Types.Add(apiType); + } else { var apiType = ResolveSchemaType(name, componentSchema); @@ -56,7 +92,34 @@ public ApiSchema Parse(string filePath) } } - // Parse paths/operations + // Rewrite field types that reference empty schemas to "object" — + // HotChocolate rejects types with zero fields, and ordering during component + // resolution means some refs may not have been caught inline. + var emptyTypeNames = _resolvedTypes + .Where(kv => kv.Value.Fields.Count == 0) + .Select(kv => kv.Key) + .ToHashSet(); + + foreach (var apiType in schema.Types) + { + foreach (var field in apiType.Fields) + { + field.TypeName = ReplaceEmptyTypeRefs(field.TypeName, emptyTypeNames); + } + } + + // Parse paths/operations (two-pass: resolve names, then build operations) + var rawOperations = + new List<( + string path, + OpenApiPathItem pathItem, + OpenApiOperation operation, + string httpMethod, + OperationKind kind, + string originalName, + string strippedName + )>(); + foreach (var (path, pathItem) in document.Paths) { foreach (var (operationType, operation) in pathItem.Operations) @@ -66,27 +129,48 @@ public ApiSchema Parse(string filePath) ? OperationKind.Query : OperationKind.Mutation; - var operationName = DeriveOperationName(operation, httpMethod, path); - var group = DeriveGroup(operation, path); - var inputType = BuildInputType(operationName, operation, pathItem); - var outputType = BuildOutputType(operationName, operation); - - schema.Operations.Add( - new ApiOperation - { - Name = operationName, - Kind = kind, - Description = operation.Summary ?? operation.Description, - Group = group, - InputType = inputType, - OutputType = outputType, - HttpMethod = httpMethod, - HttpPath = path, - } + var (originalName, strippedName) = DeriveOperationNames( + operation, + httpMethod, + path + ); + rawOperations.Add( + (path, pathItem, operation, httpMethod, kind, originalName, strippedName) ); } } + // Find stripped names that appear more than once — these need their prefix kept + var strippedNameCounts = rawOperations + .GroupBy(o => o.strippedName) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(); + + foreach (var raw in rawOperations) + { + var usePrefixed = strippedNameCounts.Contains(raw.strippedName); + var baseName = usePrefixed ? raw.originalName : raw.strippedName; + var operationName = EnsureUniqueOperationName(baseName, raw.path); + var group = DeriveGroup(raw.operation, raw.path); + var inputType = BuildInputType(operationName, raw.operation, raw.pathItem); + var outputType = BuildOutputType(operationName, raw.operation); + + schema.Operations.Add( + new ApiOperation + { + Name = operationName, + Kind = raw.kind, + Description = raw.operation.Summary ?? raw.operation.Description, + Group = group, + InputType = inputType, + OutputType = outputType, + HttpMethod = raw.httpMethod, + HttpPath = raw.path, + } + ); + } + // Add any enums discovered during type resolution foreach (var apiEnum in _resolvedEnums.Values) { @@ -104,21 +188,51 @@ public ApiSchema Parse(string filePath) return schema; } - private static string DeriveOperationName( + /// + /// Returns (originalName, strippedName) for two-pass collision detection. + /// originalName: the full PascalCase name with verb prefix intact. + /// strippedName: the name with HTTP verb prefix removed (or same as original if no prefix). + /// If stripping would collide with a known type/enum, both return the original. + /// + private (string Original, string Stripped) DeriveOperationNames( OpenApiOperation operation, string httpMethod, string path ) { if (!string.IsNullOrWhiteSpace(operation.OperationId)) - return NamingConventions.ToPascalCase(operation.OperationId); + { + var pascal = NamingConventions.ToPascalCase(operation.OperationId); + var stripped = NamingConventions.StripHttpVerbPrefix(pascal); - // Synthesize from method + path segments + // If stripping would collide with a known type/enum name, keep the original + if ( + stripped != pascal + && (_resolvedTypes.ContainsKey(stripped) || _resolvedEnums.ContainsKey(stripped)) + ) + return (pascal, pascal); + + return (pascal, stripped); + } + + // Synthesize from path segments (no HTTP verb prefix) var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries) .Where(s => !s.StartsWith('{')) .Select(NamingConventions.ToPascalCase); - return NamingConventions.ToPascalCase(httpMethod) + string.Join("", segments); + var synthesized = string.Join("", segments); + + // If no segments remain (e.g. root path "/"), fall back to "Root" + if (string.IsNullOrEmpty(synthesized)) + synthesized = "Root"; + + var prefixed = NamingConventions.ToPascalCase(httpMethod) + synthesized; + + // If synthesized name collides with a known type/enum, prefix with HTTP method + if (_resolvedTypes.ContainsKey(synthesized) || _resolvedEnums.ContainsKey(synthesized)) + return (prefixed, prefixed); + + return (prefixed, synthesized); } private static string DeriveGroup(OpenApiOperation operation, string path) @@ -156,7 +270,7 @@ OpenApiPathItem pathItem new ApiField { Name = NamingConventions.ToPascalCase(param.Name), - TypeName = ResolveOpenApiType(param.Schema), + TypeName = ResolveOpenApiType(param.Schema, param.Name), IsRequired = param.Required, IsNullable = !param.Required, Description = param.Description, @@ -184,7 +298,7 @@ OpenApiPathItem pathItem new ApiField { Name = NamingConventions.ToPascalCase(propName), - TypeName = ResolveOpenApiType(propSchema), + TypeName = ResolveOpenApiType(propSchema, propName), IsRequired = requiredProps.Contains(propName), IsNullable = !requiredProps.Contains(propName), Description = propSchema.Description, @@ -195,12 +309,18 @@ OpenApiPathItem pathItem else if (bodySchema.Reference != null) { // Reference to a component schema — pull its fields into the input - var refType = ResolveSchemaType(bodySchema.Reference.Id, bodySchema); + var refType = ResolveSchemaType( + NamingConventions.SimplifySchemaName(bodySchema.Reference.Id), + bodySchema + ); fields.AddRange(refType.Fields); } } } + // Deduplicate fields by name (different raw names can converge after PascalCase) + fields = fields.DistinctBy(f => f.Name).ToList(); + return new ApiType { Name = $"{operationName}Input", @@ -247,7 +367,25 @@ private ApiType BuildOutputType(string operationName, OpenApiOperation operation // If it's a $ref, use the referenced type name if (responseSchema.Reference != null) { - var typeName = NamingConventions.ToPascalCase(responseSchema.Reference.Id); + var typeName = NamingConventions.ToPascalCase( + NamingConventions.SimplifySchemaName(responseSchema.Reference.Id) + ); + + // If the referenced type has no fields, treat the output as Unit — + // HotChocolate rejects object types with zero fields + if ( + _resolvedTypes.TryGetValue(typeName, out var resolved) + && resolved.Fields.Count == 0 + ) + { + return new ApiType + { + Name = "Unit", + Fields = [], + IsBuiltIn = true, + }; + } + return new ApiType { Name = typeName, @@ -259,10 +397,7 @@ private ApiType BuildOutputType(string operationName, OpenApiOperation operation // Array response if (responseSchema.Type == "array" && responseSchema.Items != null) { - var itemTypeName = - responseSchema.Items.Reference != null - ? NamingConventions.ToPascalCase(responseSchema.Items.Reference.Id) - : "object"; + var itemTypeName = ResolveOpenApiType(responseSchema.Items, operationName + "Item"); return new ApiType { @@ -291,7 +426,7 @@ private ApiType BuildOutputType(string operationName, OpenApiOperation operation new ApiField { Name = NamingConventions.ToPascalCase(propName), - TypeName = ResolveOpenApiType(propSchema), + TypeName = ResolveOpenApiType(propSchema, propName), IsRequired = requiredProps.Contains(propName), IsNullable = !requiredProps.Contains(propName), Description = propSchema.Description, @@ -324,17 +459,31 @@ private ApiType BuildOutputType(string operationName, OpenApiOperation operation }; } - private string ResolveOpenApiType(OpenApiSchema schema) + private string ResolveOpenApiType(OpenApiSchema schema, string? contextName = null) { if (schema.Reference != null) { - return NamingConventions.ToPascalCase(schema.Reference.Id); + var pascalName = NamingConventions.ToPascalCase( + NamingConventions.SimplifySchemaName(schema.Reference.Id) + ); + + // If the resolved type has no fields, use object instead — + // HotChocolate rejects both input and output types with zero fields + if ( + _resolvedTypes.TryGetValue(pascalName, out var resolved) + && resolved.Fields.Count == 0 + ) + return "object"; + + return pascalName; } // Enum if (schema.Enum is { Count: > 0 } && schema.Type == "string") { - var enumName = NamingConventions.ToPascalCase(schema.Title ?? "UnnamedEnum"); + var enumName = NamingConventions.ToPascalCase( + schema.Title ?? contextName ?? "UnnamedEnum" + ); ResolveEnum(enumName, schema); return enumName; } @@ -345,7 +494,9 @@ private string ResolveOpenApiType(OpenApiSchema schema) // Use the first referenced type name or synthesize var refSchema = schema.AllOf.FirstOrDefault(s => s.Reference != null); if (refSchema != null) - return NamingConventions.ToPascalCase(refSchema.Reference!.Id); + return NamingConventions.ToPascalCase( + NamingConventions.SimplifySchemaName(refSchema.Reference!.Id) + ); return "object"; // fallback } @@ -355,7 +506,9 @@ private string ResolveOpenApiType(OpenApiSchema schema) { var first = schema.OneOf[0]; if (first.Reference != null) - return NamingConventions.ToPascalCase(first.Reference.Id); + return NamingConventions.ToPascalCase( + NamingConventions.SimplifySchemaName(first.Reference.Id) + ); return "object"; } @@ -363,7 +516,9 @@ private string ResolveOpenApiType(OpenApiSchema schema) { var first = schema.AnyOf[0]; if (first.Reference != null) - return NamingConventions.ToPascalCase(first.Reference.Id); + return NamingConventions.ToPascalCase( + NamingConventions.SimplifySchemaName(first.Reference.Id) + ); return "object"; } @@ -380,10 +535,13 @@ private string ResolveOpenApiType(OpenApiSchema schema) "number" when schema.Format == "float" => "float", "number" => "double", "boolean" => "bool", - "array" when schema.Items != null => $"List<{ResolveOpenApiType(schema.Items)}>", + "array" when schema.Items != null => + $"List<{ResolveOpenApiType(schema.Items, contextName)}>", "array" => "List", "object" when schema.AdditionalProperties != null => - $"Dictionary", + $"Dictionary", + "object" when schema.Properties is { Count: > 0 } && contextName != null => + PromoteInlineObject(contextName, schema), "object" => "object", _ => "object", }; @@ -445,11 +603,16 @@ private ApiType ResolveSchemaType(string name, OpenApiSchema schema) foreach (var (propName, propSchema) in propertiesToProcess) { + var fieldName = NamingConventions.ToPascalCase(propName); + // Avoid C# error CS0542: member name cannot match enclosing type name + if (fieldName == pascalName) + fieldName += "Value"; + fields.Add( new ApiField { - Name = NamingConventions.ToPascalCase(propName), - TypeName = ResolveOpenApiType(propSchema), + Name = fieldName, + TypeName = ResolveOpenApiType(propSchema, propName), IsRequired = requiredProps.Contains(propName), IsNullable = !requiredProps.Contains(propName) || propSchema.Nullable, Description = propSchema.Description, @@ -468,6 +631,75 @@ private ApiType ResolveSchemaType(string name, OpenApiSchema schema) return apiType; } + private static string ReplaceEmptyTypeRefs(string typeName, HashSet emptyTypeNames) + { + if (emptyTypeNames.Contains(typeName)) + return "object"; + + // Handle generic wrappers like List + if ( + typeName.StartsWith("List<", StringComparison.Ordinal) + && typeName.EndsWith(">", StringComparison.Ordinal) + ) + { + var inner = typeName[5..^1]; + if (emptyTypeNames.Contains(inner)) + return "List"; + } + + return typeName; + } + + private string PromoteInlineObject(string contextName, OpenApiSchema schema) + { + // If every property is a bare "type: object" with no further structure, + // the promoted type would have no GraphQL-representable fields (HotChocolate + // silently ignores System.Object properties). Fall back to "object" instead. + if (schema.Properties!.Values.All(IsBareObjectSchema)) + return "object"; + + var typeName = EnsureUniqueName(NamingConventions.ToPascalCase(contextName)); + ResolveSchemaType(typeName, schema); + return typeName; + } + + private static bool IsBareObjectSchema(OpenApiSchema schema) => + schema.Type == "object" + && schema.Properties is not { Count: > 0 } + && schema.Reference == null + && schema.AdditionalProperties == null + && schema.AllOf is not { Count: > 0 } + && schema.OneOf is not { Count: > 0 } + && schema.AnyOf is not { Count: > 0 }; + + private string EnsureUniqueOperationName(string baseName, string path) + { + if (_usedOperationNames.Add(baseName)) + return baseName; + + // Try By{Param1}And{Param2} disambiguation using path parameters + var pathParams = path.Split('/') + .Where(s => s.StartsWith('{') && s.EndsWith('}')) + .Select(s => NamingConventions.ToPascalCase(s[1..^1])) + .ToList(); + + if (pathParams.Count > 0) + { + var suffix = "By" + string.Join("And", pathParams); + var candidate = baseName + suffix; + if (_usedOperationNames.Add(candidate)) + return candidate; + } + + // Fall back to numeric suffix + for (var i = 2; ; i++) + { + var candidate = $"{baseName}{i}"; + if (_usedOperationNames.Add(candidate)) + return candidate; + } + } + private string EnsureUniqueName(string baseName) { if (_usedTypeNames.Add(baseName)) diff --git a/src/Trax.Cli/Templates/GraphQLNamespaces.sbn b/src/Trax.Cli/Templates/GraphQLNamespaces.sbn new file mode 100644 index 0000000..97f0ae0 --- /dev/null +++ b/src/Trax.Cli/Templates/GraphQLNamespaces.sbn @@ -0,0 +1,8 @@ +namespace {{ Namespace }}; + +public static class GraphQLNamespaces +{ +{{- for group in Groups }} + public const string {{ group.name }} = "{{ group.value }}"; +{{- end }} +} diff --git a/src/Trax.Cli/Templates/Input.sbn b/src/Trax.Cli/Templates/Input.sbn index e6ca8c0..88e726f 100644 --- a/src/Trax.Cli/Templates/Input.sbn +++ b/src/Trax.Cli/Templates/Input.sbn @@ -1,5 +1,10 @@ +{{- if ModelsUsing }} +using {{ ModelsUsing }}; +{{- end }} + namespace {{ Namespace }}; +{{- if HasFields }} public record {{ TypeName }} { {{- for field in Fields }} @@ -11,3 +16,6 @@ public record {{ TypeName }} public {{ field.RequiredKeyword }}{{ field.TypeName }}{{ field.NullableMarker }} {{ field.SanitizedName }} { get; init; } {{- end }} } +{{- else }} +public record {{ TypeName }}; +{{- end }} diff --git a/src/Trax.Cli/Templates/TrainImplementation.sbn b/src/Trax.Cli/Templates/TrainImplementation.sbn index e52bc6a..fb2706c 100644 --- a/src/Trax.Cli/Templates/TrainImplementation.sbn +++ b/src/Trax.Cli/Templates/TrainImplementation.sbn @@ -4,6 +4,9 @@ using Trax.Effect.Services.ServiceTrain; {{- if ModelsUsing }} using {{ ModelsUsing }}; {{- end }} +{{- if GraphQLNamespace }} +using {{ TrainsNamespace }}; +{{- end }} using {{ Namespace }}.Junctions; namespace {{ Namespace }}; @@ -11,7 +14,7 @@ namespace {{ Namespace }}; /// /// {{ Description }} /// -[{{ Attribute }}(Description = "{{ Description }}")] +[{{ Attribute }}({{ if GraphQLNamespace }}Namespace = GraphQLNamespaces.{{ GraphQLNamespace }}, {{ end }}Description = "{{ Description }}")] public class {{ TrainName }}Train : ServiceTrain<{{ InputTypeName }}, {{ OutputTypeName }}>, I{{ TrainName }}Train diff --git a/src/Trax.Cli/Templates/TrainInterface.sbn b/src/Trax.Cli/Templates/TrainInterface.sbn index c396eaf..0731d56 100644 --- a/src/Trax.Cli/Templates/TrainInterface.sbn +++ b/src/Trax.Cli/Templates/TrainInterface.sbn @@ -1,4 +1,4 @@ -{{- if InputIsUnit || OutputIsUnit }} +{{- if OutputIsUnit }} using LanguageExt; {{- end }} {{- if ModelsUsing }} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/array-component.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/array-component.json new file mode 100644 index 0000000..a94606f --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/array-component.json @@ -0,0 +1,88 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Array Component API", + "version": "1.0.0" + }, + "paths": { + "/votes/tally": { + "get": { + "operationId": "getVoteTally", + "summary": "Get vote tally", + "tags": ["Votes"], + "parameters": [ + { + "name": "voteId", + "in": "query", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "Vote tally results", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/VoteTallyResponse" } + } + } + } + } + } + }, + "/scores": { + "get": { + "operationId": "getScoreboard", + "summary": "Get scoreboard", + "tags": ["Scores"], + "responses": { + "200": { + "description": "Scoreboard entries", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Scoreboard" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "VoteTallyResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rep_id": { + "type": "integer", + "description": "Legislator ID" + }, + "leg_voted": { + "type": "string", + "enum": ["Y", "N", "O"], + "description": "How the legislator voted" + }, + "name": { + "type": "string", + "description": "Legislator's full name" + } + } + } + }, + "ScoreEntry": { + "type": "object", + "required": ["player", "score"], + "properties": { + "player": { "type": "string" }, + "score": { "type": "integer" } + } + }, + "Scoreboard": { + "type": "array", + "items": { "$ref": "#/components/schemas/ScoreEntry" } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/bare-object-properties.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/bare-object-properties.json new file mode 100644 index 0000000..e209738 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/bare-object-properties.json @@ -0,0 +1,68 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Bare Object Properties API", + "version": "1.0.0" + }, + "paths": { + "/search": { + "get": { + "operationId": "search", + "summary": "Search items", + "tags": ["Search"], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SearchResponse" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "SearchResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { "$ref": "#/components/schemas/SearchResult" } + }, + "total": { "type": "integer" } + } + }, + "SearchResult": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "hit": { + "type": "object", + "properties": { + "_source": { "type": "object" } + } + }, + "mixed": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "data": { "type": "object" } + } + } + } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/dotted-names.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/dotted-names.json new file mode 100644 index 0000000..51f3745 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/dotted-names.json @@ -0,0 +1,217 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "DottedNames", + "version": "v1" + }, + "paths": { + "/": { + "get": { + "tags": ["Health"], + "summary": "Health check", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/users": { + "get": { + "tags": ["Users"], + "summary": "List users", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyApp.Domain.Users.DTOs.UserListDto" + } + } + } + } + } + }, + "post": { + "tags": ["Users"], + "summary": "Create user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyApp.Domain.Users.Commands.CreateUserCommand" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyApp.Domain.Users.DTOs.UserDto" + } + } + } + } + } + } + }, + "/events/calendar.ics": { + "get": { + "tags": ["Events"], + "summary": "Get calendar feed", + "responses": { + "200": { + "description": "ICS calendar" + } + } + } + }, + "/items/{itemId}/tags/{update_value}": { + "post": { + "tags": ["Items"], + "summary": "Update item tag", + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "updateValue", + "in": "query", + "schema": { "type": "string" } + }, + { + "name": "update_value", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "204": { + "description": "Updated" + } + } + } + }, + "/search": { + "get": { + "tags": ["Search"], + "summary": "Search with filters", + "parameters": [ + { + "name": "query", + "in": "query", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "filters", + "in": "query", + "schema": { "$ref": "#/components/schemas/MyApp.Domain.Shared.EmptyResponse" } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/reports": { + "get": { + "tags": ["Meetings,Reports"], + "summary": "List reports", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyApp.Domain.Reports.DTOs.ReportListDto" + } + } + } + } + } + } + }, + "/notes": { + "post": { + "tags": ["Notes"], + "summary": "Create note.\nExpects a Bearer Token.\nReturns the created note.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "body": { "type": "string" } + }, + "required": ["title"] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "MyApp.Domain.Users.DTOs.UserDto": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["id", "name", "email"] + }, + "MyApp.Domain.Users.DTOs.UserListDto": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyApp.Domain.Users.DTOs.UserDto" + } + }, + "total": { "type": "integer" } + } + }, + "MyApp.Domain.Users.Commands.CreateUserCommand": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["name", "email"] + }, + "MyApp.Domain.Reports.DTOs.ReportListDto": { + "type": "object", + "properties": { + "reports": { + "type": "array", + "items": { "type": "object" } + } + } + }, + "MyApp.Domain.Shared.EmptyResponse": { + "type": "object", + "additionalProperties": false + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/duplicate-names.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/duplicate-names.json new file mode 100644 index 0000000..4c7ce8e --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/duplicate-names.json @@ -0,0 +1,155 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Duplicate Names API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "summary": "List users", + "tags": ["Users"], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "User list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + }, + "post": { + "summary": "Create user", + "tags": ["Users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "email": { "type": "string" } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + } + } + } + }, + "/users/{userId}": { + "get": { + "summary": "Get user by ID", + "tags": ["Users"], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "A user", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" } + } + } + } + } + }, + "delete": { + "summary": "Delete user", + "tags": ["Users"], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/users/{userId}/posts": { + "get": { + "summary": "List user posts", + "tags": ["Users"], + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "User's posts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Post" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + }, + "Post": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-enums.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-enums.json new file mode 100644 index 0000000..028f9f0 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-enums.json @@ -0,0 +1,109 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Inline Enums API", + "version": "1.0.0" + }, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "summary": "List items with filtering", + "tags": ["Items"], + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["active", "inactive", "archived"] + }, + "description": "Filter by status" + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["name", "created_at", "updated_at"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "List of items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Item" } + } + } + } + } + } + }, + "post": { + "operationId": "createItem", + "summary": "Create an item", + "tags": ["Items"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "priority"], + "properties": { + "name": { "type": "string" }, + "priority": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created item", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Item" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "category": { + "type": "string", + "enum": ["electronics", "clothing", "food"] + }, + "status": { + "type": "string", + "enum": ["active", "inactive", "archived"] + } + } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-object-array.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-object-array.json new file mode 100644 index 0000000..c7e8808 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/inline-object-array.json @@ -0,0 +1,87 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Inline Object Array API", + "version": "1.0.0" + }, + "paths": { + "/events": { + "get": { + "operationId": "listEvents", + "summary": "List events", + "tags": ["Events"], + "responses": { + "200": { + "description": "List of events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "start_date": { "type": "string", "format": "date-time" } + } + } + } + } + } + } + } + } + }, + "/speakers": { + "get": { + "operationId": "listSpeakers", + "summary": "List speakers", + "tags": ["Events"], + "responses": { + "200": { + "description": "List of speakers", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Speaker" } + } + } + } + } + } + } + }, + "/raw": { + "get": { + "operationId": "getRawData", + "summary": "Get raw data", + "tags": ["Data"], + "responses": { + "200": { + "description": "Raw data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "object" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Speaker": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "bio": { "type": "string" } + } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/non-fatal-errors.yaml b/tests/Trax.Cli.Tests/Fixtures/Schemas/non-fatal-errors.yaml new file mode 100644 index 0000000..8871a27 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/non-fatal-errors.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: Non-Fatal Errors API + version: 1.0.0 +paths: + /items: + get: + operationId: listItems + summary: List items + tags: [Items] + responses: + '200': + description: Item list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Item' + /issues: + get: + operationId: listIssues + summary: List issues + tags: [Issues] + responses: + '200': + description: Issue list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Issue' +components: + schemas: + Item: + type: object + properties: + id: + type: integer + name: + type: string + Issue: + type: object + properties: + id: + type: integer + title: + type: string + nested_data: + type: array + items: + type: array + items: + - type: string + description: Date + - type: integer + description: Count diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/property-collision.json b/tests/Trax.Cli.Tests/Fixtures/Schemas/property-collision.json new file mode 100644 index 0000000..5dc46c7 --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/property-collision.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Property Collision API", + "version": "1.0.0" + }, + "paths": { + "/notifications": { + "get": { + "operationId": "listNotifications", + "summary": "List notifications", + "tags": ["Notifications"], + "responses": { + "200": { + "description": "List of notifications", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Notification" } + } + } + } + } + } + } + }, + "/testimonials": { + "get": { + "operationId": "listTestimonials", + "summary": "List testimonials", + "tags": ["Content"], + "responses": { + "200": { + "description": "List of testimonials", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Testimonial" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Notification": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "notification": { + "type": "string", + "description": "The notification message text" + }, + "is_read": { "type": "boolean" } + } + }, + "Testimonial": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "testimonial": { + "type": "string", + "description": "The testimonial text content" + }, + "author": { "type": "string" } + } + } + } + } +} diff --git a/tests/Trax.Cli.Tests/Fixtures/Schemas/type-collision.graphql b/tests/Trax.Cli.Tests/Fixtures/Schemas/type-collision.graphql new file mode 100644 index 0000000..9b5204d --- /dev/null +++ b/tests/Trax.Cli.Tests/Fixtures/Schemas/type-collision.graphql @@ -0,0 +1,40 @@ +type Query { + """Get all chat threads""" + allChats(pageSize: Int! = 50): AllChats! + """Get chat history entries""" + chatHistories(chatId: ID!): ChatHistories! + """Get a single player by ID""" + getPlayer(id: ID!): Player +} + +type Mutation { + """Create a new chat""" + createChat(name: String!): Chat! +} + +type AllChats { + items: [Chat!]! + totalCount: Int! +} + +type ChatHistories { + entries: [ChatEntry!]! + hasMore: Boolean! +} + +type Chat { + id: ID! + name: String! + createdAt: DateTime! +} + +type ChatEntry { + id: ID! + message: String! + timestamp: DateTime! +} + +type Player { + id: ID! + name: String! +} diff --git a/tests/Trax.Cli.Tests/IntegrationTests/GraphQLEndToEndTests.cs b/tests/Trax.Cli.Tests/IntegrationTests/GraphQLEndToEndTests.cs index f8aaeb3..24a967f 100644 --- a/tests/Trax.Cli.Tests/IntegrationTests/GraphQLEndToEndTests.cs +++ b/tests/Trax.Cli.Tests/IntegrationTests/GraphQLEndToEndTests.cs @@ -148,6 +148,126 @@ public void GenerateTrainsLibrary_EnumsGraphql_ModelsContainsStatusEnum() #endregion + #region TypeCollision + + [Test] + public void GenerateTrainsLibrary_TypeCollision_DisambiguatedOperationDirsExist() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // AllChats query collides with AllChats type → AllChatsQuery + var allChatsDir = Path.Combine(_outputDir, "Trains", "AllChats", "AllChatsQuery"); + Directory.Exists(allChatsDir).Should().BeTrue(); + File.Exists(Path.Combine(allChatsDir, "IAllChatsQueryTrain.cs")).Should().BeTrue(); + + // ChatHistories query collides with ChatHistories type → ChatHistoriesQuery + var chatHistDir = Path.Combine(_outputDir, "Trains", "ChatHistories", "ChatHistoriesQuery"); + Directory.Exists(chatHistDir).Should().BeTrue(); + File.Exists(Path.Combine(chatHistDir, "IChatHistoriesQueryTrain.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_NonCollidingOperationDirsUnchanged() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // GetPlayer doesn't collide + var getPlayerDir = Path.Combine(_outputDir, "Trains", "Players", "GetPlayer"); + Directory.Exists(getPlayerDir).Should().BeTrue(); + + // CreateChat doesn't collide + var createChatDir = Path.Combine(_outputDir, "Trains", "Chats", "CreateChat"); + Directory.Exists(createChatDir).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_OutputTypeRefUsesGlobalQualification() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var interfaceFile = Path.Combine( + _outputDir, + "Trains", + "AllChats", + "AllChatsQuery", + "IAllChatsQueryTrain.cs" + ); + var content = File.ReadAllText(interfaceFile); + + // Should use global:: qualification to avoid CS0118 + content.Should().Contain("global::TestProject.Trains.Models.AllChats"); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_TrainImplUsesGlobalQualification() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var trainFile = Path.Combine( + _outputDir, + "Trains", + "AllChats", + "AllChatsQuery", + "AllChatsQueryTrain.cs" + ); + var content = File.ReadAllText(trainFile); + + content.Should().Contain("global::TestProject.Trains.Models.AllChats"); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_JunctionUsesGlobalQualification() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var junctionFile = Path.Combine( + _outputDir, + "Trains", + "AllChats", + "AllChatsQuery", + "Junctions", + "AllChatsQueryJunction.cs" + ); + var content = File.ReadAllText(junctionFile); + + content.Should().Contain("global::TestProject.Trains.Models.AllChats"); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_ManifestNamesUseDisambiguatedNames() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "ManifestNames.cs")); + + content.Should().Contain("AllChatsQuery"); + content.Should().Contain("ChatHistoriesQuery"); + content.Should().Contain("\"all-chats-query\""); + content.Should().Contain("\"chat-histories-query\""); + } + + [Test] + public void GenerateTrainsLibrary_TypeCollision_ModelFilesStillGenerated() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var modelsDir = Path.Combine(_outputDir, "Models"); + File.Exists(Path.Combine(modelsDir, "AllChats.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "ChatHistories.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "Chat.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "ChatEntry.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "Player.cs")).Should().BeTrue(); + } + + #endregion + #region Nullable [Test] diff --git a/tests/Trax.Cli.Tests/IntegrationTests/OpenApiEndToEndTests.cs b/tests/Trax.Cli.Tests/IntegrationTests/OpenApiEndToEndTests.cs index c96f02d..9565ced 100644 --- a/tests/Trax.Cli.Tests/IntegrationTests/OpenApiEndToEndTests.cs +++ b/tests/Trax.Cli.Tests/IntegrationTests/OpenApiEndToEndTests.cs @@ -57,13 +57,13 @@ public void GenerateTrainsLibrary_Petstore_AllExpectedFilesExist() .Should() .BeTrue(); - // GetPet + // GetPet (prefix kept — "Pet" collides with Pet model type) var getPetDir = Path.Combine(_outputDir, "Trains", "Pets", "GetPet"); File.Exists(Path.Combine(getPetDir, "IGetPetTrain.cs")).Should().BeTrue(); File.Exists(Path.Combine(getPetDir, "GetPetTrain.cs")).Should().BeTrue(); File.Exists(Path.Combine(getPetDir, "Junctions", "GetPetJunction.cs")).Should().BeTrue(); - // DeletePet + // DeletePet (prefix kept — "Pet" collides with Pet model type) var deletePetDir = Path.Combine(_outputDir, "Trains", "Pets", "DeletePet"); File.Exists(Path.Combine(deletePetDir, "IDeletePetTrain.cs")).Should().BeTrue(); File.Exists(Path.Combine(deletePetDir, "DeletePetTrain.cs")).Should().BeTrue(); @@ -117,6 +117,36 @@ public void GenerateTrainsLibrary_Petstore_PetModelExists() #endregion + #region GraphQLNamespaces + + [Test] + public void GenerateTrainsLibrary_Petstore_GraphQLNamespacesFileGenerated() + { + var schema = _parser.Parse(FixturePath("petstore.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var namespacesFile = Path.Combine(_outputDir, "GraphQLNamespaces.cs"); + File.Exists(namespacesFile).Should().BeTrue(); + + var content = File.ReadAllText(namespacesFile); + content.Should().Contain("public static class GraphQLNamespaces"); + content.Should().Contain("Pets"); + content.Should().Contain("\"pets\""); + } + + [Test] + public void GenerateTrainsLibrary_Petstore_TrainImplementationReferencesNamespace() + { + var schema = _parser.Parse(FixturePath("petstore.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var trainFile = Path.Combine(_outputDir, "Trains", "Pets", "ListPets", "ListPetsTrain.cs"); + var content = File.ReadAllText(trainFile); + content.Should().Contain("Namespace = GraphQLNamespaces.Pets"); + } + + #endregion + #region ComplexOpenApi [Test] @@ -141,17 +171,17 @@ public void GenerateTrainsLibrary_ComplexOpenApi_AllOperationsGenerated() .Should() .BeTrue(); - // GetUser + // GetUser (prefix kept — "User" collides with User model type) var getUserDir = Path.Combine(_outputDir, "Trains", "Users", "GetUser"); File.Exists(Path.Combine(getUserDir, "IGetUserTrain.cs")).Should().BeTrue(); File.Exists(Path.Combine(getUserDir, "GetUserTrain.cs")).Should().BeTrue(); File.Exists(Path.Combine(getUserDir, "Junctions", "GetUserJunction.cs")).Should().BeTrue(); - // Health check - var healthDir = Path.Combine(_outputDir, "Trains", "Health", "GetHealth"); - File.Exists(Path.Combine(healthDir, "IGetHealthTrain.cs")).Should().BeTrue(); - File.Exists(Path.Combine(healthDir, "GetHealthTrain.cs")).Should().BeTrue(); - File.Exists(Path.Combine(healthDir, "Junctions", "GetHealthJunction.cs")).Should().BeTrue(); + // Health check (synthesized from path only) + var healthDir = Path.Combine(_outputDir, "Trains", "Health", "Health"); + File.Exists(Path.Combine(healthDir, "IHealthTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(healthDir, "HealthTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(healthDir, "Junctions", "HealthJunction.cs")).Should().BeTrue(); } [Test] diff --git a/tests/Trax.Cli.Tests/IntegrationTests/OpenApiFixEndToEndTests.cs b/tests/Trax.Cli.Tests/IntegrationTests/OpenApiFixEndToEndTests.cs new file mode 100644 index 0000000..a0f2278 --- /dev/null +++ b/tests/Trax.Cli.Tests/IntegrationTests/OpenApiFixEndToEndTests.cs @@ -0,0 +1,660 @@ +using FluentAssertions; +using Trax.Cli.Generator; +using Trax.Cli.Schema.OpenApi; + +namespace Trax.Cli.Tests.IntegrationTests; + +/// +/// End-to-end tests for all OpenAPI parser and code generation fixes. +/// Each test parses a fixture schema, generates a trains library, and verifies +/// the generated files are correct. +/// +[TestFixture] +public class OpenApiFixEndToEndTests +{ + private OpenApiSchemaParser _parser = null!; + private TraxProjectGenerator _generator = null!; + private string _outputDir = null!; + + [SetUp] + public void SetUp() + { + _parser = new OpenApiSchemaParser(); + _generator = new TraxProjectGenerator(); + _outputDir = Path.Combine(Path.GetTempPath(), $"trax-cli-fix-e2e-{Guid.NewGuid():N}"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_outputDir)) + Directory.Delete(_outputDir, recursive: true); + } + + private static string FixturePath(string name) => + Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "Schemas", name); + + #region NonFatalErrors + + [Test] + public void GenerateTrainsLibrary_NonFatalErrors_AllFilesGenerated() + { + var schema = _parser.Parse(FixturePath("non-fatal-errors.yaml")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + File.Exists(Path.Combine(_outputDir, "TestProject.Trains.csproj")).Should().BeTrue(); + File.Exists(Path.Combine(_outputDir, "ManifestNames.cs")).Should().BeTrue(); + + var listItemsDir = Path.Combine(_outputDir, "Trains", "Items", "ListItems"); + File.Exists(Path.Combine(listItemsDir, "IListItemsTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(listItemsDir, "ListItemsTrain.cs")).Should().BeTrue(); + + var listIssuesDir = Path.Combine(_outputDir, "Trains", "Issues", "ListIssues"); + File.Exists(Path.Combine(listIssuesDir, "IListIssuesTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(listIssuesDir, "ListIssuesTrain.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_NonFatalErrors_ModelFilesGenerated() + { + var schema = _parser.Parse(FixturePath("non-fatal-errors.yaml")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + File.Exists(Path.Combine(_outputDir, "Models", "Item.cs")).Should().BeTrue(); + File.Exists(Path.Combine(_outputDir, "Models", "Issue.cs")).Should().BeTrue(); + } + + #endregion + + #region InlineEnums + + [Test] + public void GenerateTrainsLibrary_InlineEnums_EnumFilesGenerated() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // Inline enums from parameters and properties should be generated as enum files + var modelsDir = Path.Combine(_outputDir, "Models"); + Directory.Exists(modelsDir).Should().BeTrue(); + + File.Exists(Path.Combine(modelsDir, "Status.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "SortBy.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "Priority.cs")).Should().BeTrue(); + File.Exists(Path.Combine(modelsDir, "Category.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_InlineEnums_NoUnnamedEnumFile() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + File.Exists(Path.Combine(_outputDir, "Models", "UnnamedEnum.cs")).Should().BeFalse(); + } + + [Test] + public void GenerateTrainsLibrary_InlineEnums_StatusEnumHasCorrectValues() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "Models", "Status.cs")); + content.Should().Contain("public enum Status"); + content.Should().Contain("Active"); + content.Should().Contain("Inactive"); + content.Should().Contain("Archived"); + } + + [Test] + public void GenerateTrainsLibrary_InlineEnums_PriorityEnumHasCorrectValues() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "Models", "Priority.cs")); + content.Should().Contain("public enum Priority"); + content.Should().Contain("Low"); + content.Should().Contain("Medium"); + content.Should().Contain("High"); + content.Should().Contain("Critical"); + } + + [Test] + public void GenerateTrainsLibrary_InlineEnums_InputFileReferencesEnumType() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var inputFile = Path.Combine( + _outputDir, + "Trains", + "Items", + "ListItems", + "ListItemsInput.cs" + ); + var content = File.ReadAllText(inputFile); + + content.Should().Contain("Status"); + content.Should().Contain("SortBy"); + // Should have using directive for Models namespace + content.Should().Contain("using TestProject.Trains.Models;"); + } + + [Test] + public void GenerateTrainsLibrary_InlineEnums_CreateItemInputReferencesEnum() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var inputFile = Path.Combine( + _outputDir, + "Trains", + "Items", + "CreateItem", + "CreateItemInput.cs" + ); + var content = File.ReadAllText(inputFile); + + content.Should().Contain("Priority"); + content.Should().Contain("using TestProject.Trains.Models;"); + } + + #endregion + + #region PropertyCollision + + [Test] + public void GenerateTrainsLibrary_PropertyCollision_NotificationModelCompilable() + { + var schema = _parser.Parse(FixturePath("property-collision.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var notificationFile = Path.Combine(_outputDir, "Models", "Notification.cs"); + File.Exists(notificationFile).Should().BeTrue(); + + var content = File.ReadAllText(notificationFile); + // Property should be suffixed to avoid CS0542 + content.Should().Contain("NotificationValue"); + content.Should().Contain("public record Notification"); + // Other properties should be normal + content.Should().Contain("Title"); + content.Should().Contain("Id"); + } + + [Test] + public void GenerateTrainsLibrary_PropertyCollision_TestimonialModelCompilable() + { + var schema = _parser.Parse(FixturePath("property-collision.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var testimonialFile = Path.Combine(_outputDir, "Models", "Testimonial.cs"); + File.Exists(testimonialFile).Should().BeTrue(); + + var content = File.ReadAllText(testimonialFile); + content.Should().Contain("TestimonialValue"); + content.Should().Contain("public record Testimonial"); + content.Should().Contain("Author"); + } + + [Test] + public void GenerateTrainsLibrary_PropertyCollision_TrainFilesStillGenerated() + { + var schema = _parser.Parse(FixturePath("property-collision.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var listDir = Path.Combine(_outputDir, "Trains", "Notifications", "ListNotifications"); + File.Exists(Path.Combine(listDir, "IListNotificationsTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(listDir, "ListNotificationsTrain.cs")).Should().BeTrue(); + } + + #endregion + + #region ArrayComponentSchemas + + [Test] + public void GenerateTrainsLibrary_ArrayComponentSchema_WrapperTypeGenerated() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var voteTallyFile = Path.Combine(_outputDir, "Models", "VoteTallyResponse.cs"); + File.Exists(voteTallyFile).Should().BeTrue(); + + var content = File.ReadAllText(voteTallyFile); + content.Should().Contain("public record VoteTallyResponse"); + content.Should().Contain("Items"); + content.Should().Contain("List<"); + } + + [Test] + public void GenerateTrainsLibrary_ArrayComponentSchema_RefArrayTypeGenerated() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var scoreboardFile = Path.Combine(_outputDir, "Models", "Scoreboard.cs"); + File.Exists(scoreboardFile).Should().BeTrue(); + + var content = File.ReadAllText(scoreboardFile); + content.Should().Contain("public record Scoreboard"); + content.Should().Contain("List"); + } + + [Test] + public void GenerateTrainsLibrary_ArrayComponentSchema_RegularSchemaAlsoGenerated() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var scoreEntryFile = Path.Combine(_outputDir, "Models", "ScoreEntry.cs"); + File.Exists(scoreEntryFile).Should().BeTrue(); + + var content = File.ReadAllText(scoreEntryFile); + content.Should().Contain("public record ScoreEntry"); + content.Should().Contain("Player"); + content.Should().Contain("Score"); + } + + [Test] + public void GenerateTrainsLibrary_ArrayComponentSchema_TrainFilesGenerated() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var tallyDir = Path.Combine(_outputDir, "Trains", "Votes", "VoteTally"); + File.Exists(Path.Combine(tallyDir, "IVoteTallyTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(tallyDir, "VoteTallyTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(tallyDir, "Junctions", "VoteTallyJunction.cs")).Should().BeTrue(); + } + + #endregion + + #region DuplicateOperationNames + + [Test] + public void GenerateTrainsLibrary_DuplicateNames_AllTrainFoldersCreated() + { + var schema = _parser.Parse(FixturePath("duplicate-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // First GET: GetUsers (prefix kept — "Users" collides with PostUsers) + var getUsersDir = Path.Combine(_outputDir, "Trains", "Users", "GetUsers"); + File.Exists(Path.Combine(getUsersDir, "IGetUsersTrain.cs")).Should().BeTrue(); + + // Second GET: GetUsersByUserId (disambiguated by path param) + var getUsersByIdDir = Path.Combine(_outputDir, "Trains", "Users", "GetUsersByUserId"); + File.Exists(Path.Combine(getUsersByIdDir, "IGetUsersByUserIdTrain.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_DuplicateNames_ManifestNamesHasAll() + { + var schema = _parser.Parse(FixturePath("duplicate-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "ManifestNames.cs")); + + content.Should().Contain("GetUsers"); + content.Should().Contain("PostUsers"); + content.Should().Contain("GetUsersByUserId"); + content.Should().Contain("DeleteUsers"); + content.Should().Contain("UsersPosts"); + } + + [Test] + public void GenerateTrainsLibrary_DuplicateNames_JunctionsHaveCorrectHttpPaths() + { + var schema = _parser.Parse(FixturePath("duplicate-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var getUsersJunction = File.ReadAllText( + Path.Combine( + _outputDir, + "Trains", + "Users", + "GetUsers", + "Junctions", + "GetUsersJunction.cs" + ) + ); + getUsersJunction.Should().Contain("GET /users"); + + var getUsersByIdJunction = File.ReadAllText( + Path.Combine( + _outputDir, + "Trains", + "Users", + "GetUsersByUserId", + "Junctions", + "GetUsersByUserIdJunction.cs" + ) + ); + getUsersByIdJunction.Should().Contain("GET /users/{userId}"); + } + + [Test] + public void GenerateTrainsLibrary_DuplicateNames_DeleteOperationHasUnitOutput() + { + var schema = _parser.Parse(FixturePath("duplicate-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var deleteJunction = File.ReadAllText( + Path.Combine( + _outputDir, + "Trains", + "Users", + "DeleteUsers", + "Junctions", + "DeleteUsersJunction.cs" + ) + ); + deleteJunction.Should().Contain("using LanguageExt;"); + deleteJunction.Should().Contain("Unit"); + } + + #endregion + + #region EmptyInputJunction + + [Test] + public void GenerateTrainsLibrary_EmptyInputOperation_JunctionUsesTypedInputName() + { + // listNotifications has no parameters → generates an empty input record + var schema = _parser.Parse(FixturePath("property-collision.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var junctionPath = Path.Combine( + _outputDir, + "Trains", + "Notifications", + "ListNotifications", + "Junctions", + "ListNotificationsJunction.cs" + ); + + if (File.Exists(junctionPath)) + { + var content = File.ReadAllText(junctionPath); + // Empty input uses the typed record name, not Unit + content.Should().Contain("Junction"); + } + + [Test] + public void GenerateTrainsLibrary_InlineObjectInArrayComponent_EnumFileGenerated() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var enumFile = Path.Combine(_outputDir, "Models", "LegVoted.cs"); + File.Exists(enumFile).Should().BeTrue(); + + var content = File.ReadAllText(enumFile); + content.Should().Contain("public enum LegVoted"); + } + + [Test] + public void GenerateTrainsLibrary_InlineObjectInArrayResponse_PromotedTypeFileGenerated() + { + var schema = _parser.Parse(FixturePath("inline-object-array.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var itemFile = Path.Combine(_outputDir, "Models", "ListEventsItem.cs"); + File.Exists(itemFile).Should().BeTrue(); + + var content = File.ReadAllText(itemFile); + content.Should().Contain("public record ListEventsItem"); + content.Should().Contain("Id"); + content.Should().Contain("Title"); + content.Should().Contain("StartDate"); + } + + [Test] + public void GenerateTrainsLibrary_InlineObjectInArrayResponse_OutputFileReferencesPromotedType() + { + var schema = _parser.Parse(FixturePath("inline-object-array.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // Output type is generated alongside the train, not in Models/ + var outputFile = Path.Combine( + _outputDir, + "Trains", + "Events", + "ListEvents", + "ListEventsOutput.cs" + ); + File.Exists(outputFile).Should().BeTrue(); + + var content = File.ReadAllText(outputFile); + content.Should().Contain("List"); + } + + [Test] + public void GenerateTrainsLibrary_ArrayResponseWithRefItems_NoExtraTypePromoted() + { + var schema = _parser.Parse(FixturePath("inline-object-array.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // Speaker is a $ref — should use the existing type, not create ListSpeakersItem + File.Exists(Path.Combine(_outputDir, "Models", "ListSpeakersItem.cs")).Should().BeFalse(); + File.Exists(Path.Combine(_outputDir, "Models", "Speaker.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_ArrayResponseWithBareObject_NoTypePromoted() + { + var schema = _parser.Parse(FixturePath("inline-object-array.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // RawData returns array of bare objects — no RawDataItem type should be created + File.Exists(Path.Combine(_outputDir, "Models", "RawDataItem.cs")).Should().BeFalse(); + } + + #endregion + + #region CombinedScenarios + + [Test] + public void GenerateTrainsLibrary_InlineEnums_ItemModelUsesEnumTypes() + { + var schema = _parser.Parse(FixturePath("inline-enums.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var itemFile = Path.Combine(_outputDir, "Models", "Item.cs"); + File.Exists(itemFile).Should().BeTrue(); + + var content = File.ReadAllText(itemFile); + content.Should().Contain("Category"); + content.Should().Contain("Status"); + } + + [Test] + public void GenerateTrainsLibrary_ArrayComponentSchema_EnumsInArrayItemsResolved() + { + var schema = _parser.Parse(FixturePath("array-component.json")); + + // VoteTallyResponse has items with enum "leg_voted" [Y, N, O] + // Since the items are inline objects, the enum may or may not be promoted, + // but the parser should not crash + schema.Should().NotBeNull(); + schema.Operations.Should().HaveCount(2); + } + + #endregion + + #region DottedSchemaNames + + [Test] + public void GenerateTrainsLibrary_DottedNames_ModelFilesHaveSimplifiedNames() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + File.Exists(Path.Combine(_outputDir, "Models", "UserDto.cs")).Should().BeTrue(); + File.Exists(Path.Combine(_outputDir, "Models", "UserListDto.cs")).Should().BeTrue(); + File.Exists(Path.Combine(_outputDir, "Models", "CreateUserCommand.cs")).Should().BeTrue(); + File.Exists(Path.Combine(_outputDir, "Models", "ReportListDto.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_NoDotsInModelFileNames() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var modelsDir = Path.Combine(_outputDir, "Models"); + if (Directory.Exists(modelsDir)) + { + var files = Directory.GetFiles(modelsDir, "*.cs"); + files + .Select(Path.GetFileNameWithoutExtension) + .Should() + .AllSatisfy(name => name.Should().NotContain(".")); + } + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_UserListDtoReferencesUserDto() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "Models", "UserListDto.cs")); + content.Should().Contain("List"); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_RootPathGeneratesValidTrain() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var rootDir = Path.Combine(_outputDir, "Trains", "Health", "Root"); + File.Exists(Path.Combine(rootDir, "IRootTrain.cs")).Should().BeTrue(); + File.Exists(Path.Combine(rootDir, "RootTrain.cs")).Should().BeTrue(); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_CalendarIcsNoDotsInTrainName() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // calendar.ics path should produce a valid train name without dots + var eventsDir = Path.Combine(_outputDir, "Trains", "Events"); + Directory.Exists(eventsDir).Should().BeTrue(); + + var trainDirs = Directory.GetDirectories(eventsDir); + trainDirs + .Select(Path.GetFileName) + .Should() + .AllSatisfy(name => name.Should().NotContain(".")); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_GraphQLNamespacesNoInvalidChars() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "GraphQLNamespaces.cs")); + // Should not contain commas or dots in identifier names + content.Should().NotMatchRegex(@"public const string [^=]*[.,][^=]*="); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_ManifestNamesNoInvalidChars() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var content = File.ReadAllText(Path.Combine(_outputDir, "ManifestNames.cs")); + // All const names should be valid C# identifiers (no dots or commas) + content.Should().NotMatchRegex(@"public const string [^=]*[.,][^=]*="); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_MultilineDescriptionInAttribute() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + var notesDir = Path.Combine(_outputDir, "Trains", "Notes"); + var trainFiles = Directory + .GetFiles(notesDir, "*Train.cs", SearchOption.AllDirectories) + .Where(f => !Path.GetFileName(f).StartsWith('I')) + .ToArray(); + trainFiles.Should().NotBeEmpty(); + + var content = File.ReadAllText(trainFiles[0]); + // Description attribute should be on a single line (no raw newlines in string) + var attrLine = content.Split('\n').FirstOrDefault(l => l.Contains("Description =")); + attrLine.Should().NotBeNull(); + // The description value should not contain literal newlines + attrLine.Should().NotContain("\r"); + } + + [Test] + public void GenerateTrainsLibrary_DottedNames_EmptySchemaTypeNotGenerated() + { + var schema = _parser.Parse(FixturePath("dotted-names.json")); + _generator.GenerateTrainsLibrary(schema, _outputDir, "TestProject"); + + // Empty types (no fields) should not be generated — HotChocolate rejects them + var emptyFile = Path.Combine(_outputDir, "Models", "EmptyResponse.cs"); + File.Exists(emptyFile).Should().BeFalse(); + } + + #endregion +} diff --git a/tests/Trax.Cli.Tests/UnitTests/CodeRendererFixTests.cs b/tests/Trax.Cli.Tests/UnitTests/CodeRendererFixTests.cs new file mode 100644 index 0000000..f0d93ee --- /dev/null +++ b/tests/Trax.Cli.Tests/UnitTests/CodeRendererFixTests.cs @@ -0,0 +1,244 @@ +using FluentAssertions; +using Trax.Cli.Generator; +using Trax.Cli.Models; + +namespace Trax.Cli.Tests.UnitTests; + +/// +/// Tests for CodeRenderer fixes: +/// - Junction template includes "using LanguageExt" when input is Unit (not just output) +/// - Input template includes Models namespace when fields reference enum types +/// +[TestFixture] +public class CodeRendererFixTests +{ + private CodeRenderer _renderer = null!; + + [SetUp] + public void SetUp() + { + _renderer = new CodeRenderer(); + } + + private static ApiOperation MakeOperation( + string name, + OperationKind kind, + ApiType? input = null, + ApiType? output = null, + string group = "Players", + string? httpMethod = null, + string? httpPath = null + ) => + new() + { + Name = name, + Kind = kind, + Group = group, + InputType = + input + ?? new ApiType + { + Name = $"{name}Input", + Fields = + [ + new ApiField + { + Name = "Id", + TypeName = "Guid", + IsRequired = true, + }, + ], + }, + OutputType = + output + ?? new ApiType + { + Name = $"{name}Output", + Fields = + [ + new ApiField + { + Name = "Result", + TypeName = "string", + IsRequired = true, + }, + ], + }, + HttpMethod = httpMethod, + HttpPath = httpPath, + }; + + private static ApiType UnitType => + new() + { + Name = "Unit", + Fields = [], + IsBuiltIn = true, + }; + + #region RenderJunction_EmptyInput + + [Test] + public void RenderJunction_EmptyInput_NoLanguageExtUsing() + { + var op = MakeOperation( + "ListAll", + OperationKind.Query, + input: UnitType, + httpMethod: "GET", + httpPath: "/items" + ); + + var result = _renderer.RenderJunction(op, "MyApi"); + + // Empty input records don't need LanguageExt — they use the record name directly + result.Should().NotContain("using LanguageExt;"); + } + + [Test] + public void RenderJunction_EmptyInput_UsesInputTypeName() + { + var op = MakeOperation("ListAll", OperationKind.Query, input: UnitType); + + var result = _renderer.RenderJunction(op, "MyApi"); + + result.Should().Contain("Junction"); + } + + [Test] + public void RenderJunction_NeitherInputNorOutputUnit_NoLanguageExtUsing() + { + var op = MakeOperation("GetPlayer", OperationKind.Query); + + var result = _renderer.RenderJunction(op, "MyApi"); + + result.Should().NotContain("using LanguageExt;"); + } + + #endregion + + #region RenderTrainInterface_EmptyInput + + [Test] + public void RenderTrainInterface_EmptyInput_NoLanguageExtUsing() + { + var op = MakeOperation("ListAll", OperationKind.Query, input: UnitType); + + var result = _renderer.RenderTrainInterface(op, "MyApi"); + + // Empty input records don't need LanguageExt + result.Should().NotContain("using LanguageExt;"); + } + + [Test] + public void RenderTrainInterface_UnitOutput_ContainsLanguageExtUsing() + { + var op = MakeOperation("DeletePlayer", OperationKind.Mutation, output: UnitType); + + var result = _renderer.RenderTrainInterface(op, "MyApi"); + + result.Should().Contain("using LanguageExt;"); + } + + [Test] + public void RenderTrainInterface_NeitherUnit_NoLanguageExtUsing() + { + var op = MakeOperation("GetPlayer", OperationKind.Query); + + var result = _renderer.RenderTrainInterface(op, "MyApi"); + + result.Should().NotContain("using LanguageExt;"); + } + + #endregion + + #region RenderInput_ModelsNamespace + + [Test] + public void RenderInput_WithModelsNamespace_ContainsUsingDirective() + { + _renderer.SetModelsNamespace("MyApi.Trains.Models"); + var input = new ApiType + { + Name = "ListItemsInput", + Fields = + [ + new ApiField + { + Name = "Status", + TypeName = "Status", + IsRequired = false, + IsNullable = true, + }, + ], + }; + var op = MakeOperation("ListItems", OperationKind.Query, input: input); + + var result = _renderer.RenderInput(op, "MyApi"); + + result.Should().Contain("using MyApi.Trains.Models;"); + } + + [Test] + public void RenderInput_WithoutModelsNamespace_NoModelsUsing() + { + // Do NOT call SetModelsNamespace + var input = new ApiType + { + Name = "ListItemsInput", + Fields = + [ + new ApiField + { + Name = "Limit", + TypeName = "int", + IsRequired = false, + IsNullable = true, + }, + ], + }; + var op = MakeOperation("ListItems", OperationKind.Query, input: input); + + var result = _renderer.RenderInput(op, "MyApi"); + + result.Should().NotContain("using MyApi.Trains.Models;"); + } + + #endregion +} diff --git a/tests/Trax.Cli.Tests/UnitTests/CodeRendererTests.cs b/tests/Trax.Cli.Tests/UnitTests/CodeRendererTests.cs index ab1fe94..d311c9c 100644 --- a/tests/Trax.Cli.Tests/UnitTests/CodeRendererTests.cs +++ b/tests/Trax.Cli.Tests/UnitTests/CodeRendererTests.cs @@ -235,6 +235,74 @@ public void RenderManifestNames_ProducesStaticClassWithConstants() #endregion + #region RenderGraphQLNamespaces + + [Test] + public void RenderGraphQLNamespaces_ProducesStaticClassWithConstants() + { + var groups = new[] { "Players", "Matches", "Leaderboard" }; + + var result = _renderer.RenderGraphQLNamespaces(groups, "MyApi"); + + result.Should().Contain("namespace MyApi.Trains;"); + result.Should().Contain("public static class GraphQLNamespaces"); + result.Should().Contain("Players"); + result.Should().Contain("\"players\""); + result.Should().Contain("Matches"); + result.Should().Contain("\"matches\""); + result.Should().Contain("Leaderboard"); + result.Should().Contain("\"leaderboard\""); + } + + [Test] + public void RenderGraphQLNamespaces_EmptyGroups_ProducesEmptyClass() + { + var groups = Array.Empty(); + + var result = _renderer.RenderGraphQLNamespaces(groups, "MyApi"); + + result.Should().Contain("public static class GraphQLNamespaces"); + result.Should().NotContain("public const string"); + } + + [Test] + public void RenderGraphQLNamespaces_SingleGroup_ProducesSingleConstant() + { + var groups = new[] { "Users" }; + + var result = _renderer.RenderGraphQLNamespaces(groups, "MyApi"); + + result.Should().Contain("Users"); + result.Should().Contain("\"users\""); + } + + #endregion + + #region RenderTrainImplementation_Namespace + + [Test] + public void RenderTrainImplementation_WithGroup_ContainsNamespaceAttribute() + { + var op = MakeOperation("LookupPlayer", OperationKind.Query, group: "Players"); + + var result = _renderer.RenderTrainImplementation(op, "MyApi"); + + result.Should().Contain("Namespace = GraphQLNamespaces.Players"); + result.Should().Contain("using MyApi.Trains;"); + } + + [Test] + public void RenderTrainImplementation_MutationWithGroup_ContainsNamespaceAttribute() + { + var op = MakeOperation("BanPlayer", OperationKind.Mutation, group: "Players"); + + var result = _renderer.RenderTrainImplementation(op, "MyApi"); + + result.Should().Contain("[TraxMutation(Namespace = GraphQLNamespaces.Players"); + } + + #endregion + #region RenderTypeRecord [Test] diff --git a/tests/Trax.Cli.Tests/UnitTests/GraphQLSchemaParserTests.cs b/tests/Trax.Cli.Tests/UnitTests/GraphQLSchemaParserTests.cs index 2190bd1..367bbf4 100644 --- a/tests/Trax.Cli.Tests/UnitTests/GraphQLSchemaParserTests.cs +++ b/tests/Trax.Cli.Tests/UnitTests/GraphQLSchemaParserTests.cs @@ -150,6 +150,157 @@ public void Parse_Nullable_SearchResultHasNullableFields() #endregion + #region TypeCollision + + [Test] + public void Parse_TypeCollision_AllChatsQueryIsDisambiguated() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + + schema.Operations.Should().Contain(o => o.Name == "AllChatsQuery"); + } + + [Test] + public void Parse_TypeCollision_ChatHistoriesQueryIsDisambiguated() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + + schema.Operations.Should().Contain(o => o.Name == "ChatHistoriesQuery"); + } + + [Test] + public void Parse_TypeCollision_NonCollidingOperationNamesUnchanged() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + + // GetPlayer doesn't collide with any type name + schema.Operations.Should().Contain(o => o.Name == "GetPlayer"); + // CreateChat doesn't collide with Chat (different name) + schema.Operations.Should().Contain(o => o.Name == "CreateChat"); + } + + [Test] + public void Parse_TypeCollision_DisambiguatedOperationOutputTypePreserved() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + + var allChats = schema.Operations.First(o => o.Name == "AllChatsQuery"); + // Output references the AllChats type (built-in ref since it's a known object type) + allChats.OutputType.Name.Should().Be("AllChats"); + allChats.OutputType.IsBuiltIn.Should().BeTrue(); + } + + [Test] + public void Parse_TypeCollision_MutationCollidingWithTypeGetsMutationSuffix() + { + // Create a schema where a mutation name collides with a type + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText( + tempFile, + """ + type Query { + getItem(id: ID!): Item + } + type Mutation { + item(name: String!): Item + } + type Item { + id: ID! + name: String! + } + """ + ); + var schema = _parser.Parse(tempFile); + + schema.Operations.Should().Contain(o => o.Name == "ItemMutation"); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void Parse_TypeCollision_CollidingWithEnumTypeIsDisambiguated() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText( + tempFile, + """ + type Query { + status: StatusResult! + } + type Mutation { + status(value: Status!): StatusResult! + } + type StatusResult { + ok: Boolean! + } + enum Status { + ACTIVE + INACTIVE + } + """ + ); + var schema = _parser.Parse(tempFile); + + // "Status" collides with the Status enum + schema.Operations.Should().Contain(o => o.Name == "StatusMutation"); + // The query "status" also collides with the Status enum + schema.Operations.Should().Contain(o => o.Name == "StatusQuery"); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void Parse_TypeCollision_CollidingWithInputTypeIsDisambiguated() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText( + tempFile, + """ + type Query { + filterInput(text: String): SearchResult + } + type SearchResult { + items: [String!]! + } + input FilterInput { + field: String + value: String + } + """ + ); + var schema = _parser.Parse(tempFile); + + // "FilterInput" collides with the FilterInput input type + schema.Operations.Should().Contain(o => o.Name == "FilterInputQuery"); + } + finally + { + File.Delete(tempFile); + } + } + + [Test] + public void Parse_TypeCollision_HasCorrectTotalOperationCount() + { + var schema = _parser.Parse(FixturePath("type-collision.graphql")); + + schema.Operations.Should().HaveCount(4); + } + + #endregion + #region EmptySchema [Test] diff --git a/tests/Trax.Cli.Tests/UnitTests/NamingConventionsTests.cs b/tests/Trax.Cli.Tests/UnitTests/NamingConventionsTests.cs index ad09f9c..66cd1d3 100644 --- a/tests/Trax.Cli.Tests/UnitTests/NamingConventionsTests.cs +++ b/tests/Trax.Cli.Tests/UnitTests/NamingConventionsTests.cs @@ -139,4 +139,155 @@ public void DeriveGroupName_DeleteEntry_ReturnsEntries() } #endregion + + #region StripHttpVerbPrefix + + [Test] + public void StripHttpVerbPrefix_GetPlayer_ReturnsPlayer() + { + NamingConventions.StripHttpVerbPrefix("GetPlayer").Should().Be("Player"); + } + + [Test] + public void StripHttpVerbPrefix_PostLogin_ReturnsLogin() + { + NamingConventions.StripHttpVerbPrefix("PostLogin").Should().Be("Login"); + } + + [Test] + public void StripHttpVerbPrefix_PutSettings_ReturnsSettings() + { + NamingConventions.StripHttpVerbPrefix("PutSettings").Should().Be("Settings"); + } + + [Test] + public void StripHttpVerbPrefix_PatchProfile_ReturnsProfile() + { + NamingConventions.StripHttpVerbPrefix("PatchProfile").Should().Be("Profile"); + } + + [Test] + public void StripHttpVerbPrefix_DeleteUser_ReturnsUser() + { + NamingConventions.StripHttpVerbPrefix("DeleteUser").Should().Be("User"); + } + + [Test] + public void StripHttpVerbPrefix_ListItems_ReturnsListItems() + { + // "List" is not an HTTP verb — should be left alone + NamingConventions.StripHttpVerbPrefix("ListItems").Should().Be("ListItems"); + } + + [Test] + public void StripHttpVerbPrefix_CreateUser_ReturnsCreateUser() + { + // "Create" is not an HTTP verb — should be left alone + NamingConventions.StripHttpVerbPrefix("CreateUser").Should().Be("CreateUser"); + } + + [Test] + public void StripHttpVerbPrefix_SearchItems_ReturnsSearchItems() + { + NamingConventions.StripHttpVerbPrefix("SearchItems").Should().Be("SearchItems"); + } + + [Test] + public void StripHttpVerbPrefix_Get_ReturnsGet() + { + // "Get" alone with nothing after — should not be stripped + NamingConventions.StripHttpVerbPrefix("Get").Should().Be("Get"); + } + + [Test] + public void StripHttpVerbPrefix_Getting_ReturnsGetting() + { + // "Getting" — next char is lowercase, not a PascalCase boundary + NamingConventions.StripHttpVerbPrefix("Getting").Should().Be("Getting"); + } + + [Test] + public void StripHttpVerbPrefix_Postman_ReturnsPostman() + { + // "Postman" — next char after "Post" is lowercase + NamingConventions.StripHttpVerbPrefix("Postman").Should().Be("Postman"); + } + + [Test] + public void StripHttpVerbPrefix_EmptyString_ReturnsEmpty() + { + NamingConventions.StripHttpVerbPrefix("").Should().Be(""); + } + + [Test] + public void StripHttpVerbPrefix_NoPrefixMatch_ReturnsOriginal() + { + NamingConventions.StripHttpVerbPrefix("FetchData").Should().Be("FetchData"); + } + + #endregion + + #region SimplifySchemaName + + [Test] + public void SimplifySchemaName_FullyQualifiedDotNetType_ReturnsLastSegment() + { + NamingConventions + .SimplifySchemaName("MyApp.Domain.Users.DTOs.UserDto") + .Should() + .Be("UserDto"); + } + + [Test] + public void SimplifySchemaName_DeeplyNested_ReturnsLastSegment() + { + NamingConventions + .SimplifySchemaName( + "AdvocacyDay.CVLegacy.Domain.Bills.GetBill.DTOs.GetBillBillVotesTopicsDto" + ) + .Should() + .Be("GetBillBillVotesTopicsDto"); + } + + [Test] + public void SimplifySchemaName_NoDots_ReturnsUnchanged() + { + NamingConventions.SimplifySchemaName("UserDto").Should().Be("UserDto"); + } + + [Test] + public void SimplifySchemaName_EmptyString_ReturnsEmpty() + { + NamingConventions.SimplifySchemaName("").Should().Be(""); + } + + [Test] + public void SimplifySchemaName_SingleDot_ReturnsAfterDot() + { + NamingConventions.SimplifySchemaName("Namespace.Type").Should().Be("Type"); + } + + #endregion + + #region ToPascalCase_SpecialCharacters + + [Test] + public void ToPascalCase_DottedName_SplitsOnDots() + { + NamingConventions.ToPascalCase("calendar.ics").Should().Be("CalendarIcs"); + } + + [Test] + public void ToPascalCase_CommaInName_SplitsOnCommas() + { + NamingConventions.ToPascalCase("meetings,intents").Should().Be("MeetingsIntents"); + } + + [Test] + public void ToPascalCase_MultipleSeparators_SplitsAll() + { + NamingConventions.ToPascalCase("a.b,c-d_e f").Should().Be("ABCDEF"); + } + + #endregion } diff --git a/tests/Trax.Cli.Tests/UnitTests/OpenApiParserFixTests.cs b/tests/Trax.Cli.Tests/UnitTests/OpenApiParserFixTests.cs new file mode 100644 index 0000000..0b56224 --- /dev/null +++ b/tests/Trax.Cli.Tests/UnitTests/OpenApiParserFixTests.cs @@ -0,0 +1,791 @@ +using FluentAssertions; +using Trax.Cli.Models; +using Trax.Cli.Schema.OpenApi; + +namespace Trax.Cli.Tests.UnitTests; + +/// +/// Tests for OpenAPI parser fixes: +/// - Non-fatal validation errors no longer crash the parser +/// - Inline enums get named from context (property/parameter name) instead of "UnnamedEnum" +/// - Property names that collide with enclosing type names get suffixed +/// - Array-type component schemas produce wrapper types with Items field +/// - Duplicate synthesized operation names get numeric suffixes +/// +[TestFixture] +public class OpenApiParserFixTests +{ + private static string FixturePath(string name) => + Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "Schemas", name); + + #region NonFatalErrors + + [Test] + public void Parse_SchemaWithNonFatalErrors_StillReturnsOperations() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("non-fatal-errors.yaml")); + + schema.Operations.Should().HaveCount(2); + schema.Operations.Select(o => o.Name).Should().Contain("ListItems"); + schema.Operations.Select(o => o.Name).Should().Contain("ListIssues"); + } + + [Test] + public void Parse_SchemaWithNonFatalErrors_StillReturnsTypes() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("non-fatal-errors.yaml")); + + schema.Types.Should().Contain(t => t.Name == "Item"); + schema.Types.Should().Contain(t => t.Name == "Issue"); + } + + [Test] + public void Parse_SchemaWithNonFatalErrors_ItemTypeHasFields() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("non-fatal-errors.yaml")); + + var item = schema.Types.Single(t => t.Name == "Item"); + item.Fields.Should().Contain(f => f.Name == "Id"); + item.Fields.Should().Contain(f => f.Name == "Name"); + } + + [Test] + public void Parse_SchemaWithNonFatalErrors_DoesNotThrow() + { + var parser = new OpenApiSchemaParser(); + + var act = () => parser.Parse(FixturePath("non-fatal-errors.yaml")); + + act.Should().NotThrow(); + } + + #endregion + + #region InlineEnums + + [Test] + public void Parse_InlineEnumsInParameters_NamedFromParameterName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var listItems = schema.Operations.Single(o => o.Name == "ListItems"); + var statusField = listItems.InputType.Fields.Single(f => f.Name == "Status"); + + // Should be named "Status" (from param name), not "UnnamedEnum" + statusField.TypeName.Should().Be("Status"); + } + + [Test] + public void Parse_InlineEnumsInParameters_MultipleEnumsGetDistinctNames() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var listItems = schema.Operations.Single(o => o.Name == "ListItems"); + var statusField = listItems.InputType.Fields.Single(f => f.Name == "Status"); + var sortByField = listItems.InputType.Fields.Single(f => f.Name == "SortBy"); + + statusField.TypeName.Should().Be("Status"); + sortByField.TypeName.Should().Be("SortBy"); + statusField.TypeName.Should().NotBe(sortByField.TypeName); + } + + [Test] + public void Parse_InlineEnumsInRequestBody_NamedFromPropertyName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var createItem = schema.Operations.Single(o => o.Name == "CreateItem"); + var priorityField = createItem.InputType.Fields.Single(f => f.Name == "Priority"); + + priorityField.TypeName.Should().Be("Priority"); + } + + [Test] + public void Parse_InlineEnumsInComponentSchema_NamedFromPropertyName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var itemType = schema.Types.Single(t => t.Name == "Item"); + var categoryField = itemType.Fields.Single(f => f.Name == "Category"); + + categoryField.TypeName.Should().Be("Category"); + } + + [Test] + public void Parse_InlineEnums_EnumValuesAreRegistered() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var statusEnum = schema.Enums.SingleOrDefault(e => e.Name == "Status"); + statusEnum.Should().NotBeNull(); + statusEnum!.Values.Should().BeEquivalentTo("Active", "Inactive", "Archived"); + } + + [Test] + public void Parse_InlineEnums_PriorityEnumValuesAreRegistered() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var priorityEnum = schema.Enums.SingleOrDefault(e => e.Name == "Priority"); + priorityEnum.Should().NotBeNull(); + priorityEnum!.Values.Should().BeEquivalentTo("Low", "Medium", "High", "Critical"); + } + + [Test] + public void Parse_InlineEnums_NoUnnamedEnumInResults() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + schema.Enums.Should().NotContain(e => e.Name == "UnnamedEnum"); + } + + [Test] + public void Parse_InlineEnums_NonEnumParametersUnaffected() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + var listItems = schema.Operations.Single(o => o.Name == "ListItems"); + var limitField = listItems.InputType.Fields.Single(f => f.Name == "Limit"); + + limitField.TypeName.Should().Be("int"); + } + + [Test] + public void Parse_InlineEnums_SameValuesAcrossParameterAndSchema_ReusesEnum() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + // Both the "status" query parameter and the Item.status property use + // enum [active, inactive, archived] — they should resolve to the same enum name + var listItems = schema.Operations.Single(o => o.Name == "ListItems"); + var paramStatusType = listItems.InputType.Fields.Single(f => f.Name == "Status").TypeName; + + var itemType = schema.Types.Single(t => t.Name == "Item"); + var schemaStatusType = itemType.Fields.Single(f => f.Name == "Status").TypeName; + + paramStatusType.Should().Be(schemaStatusType); + } + + #endregion + + #region PropertyNameCollision + + [Test] + public void Parse_PropertyNameMatchesTypeName_GetsSuffixed() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("property-collision.json")); + + var notificationType = schema.Types.Single(t => t.Name == "Notification"); + // "notification" property on Notification type → PascalCase "Notification" → collides → suffixed + notificationType.Fields.Should().NotContain(f => f.Name == "Notification"); + notificationType.Fields.Should().Contain(f => f.Name == "NotificationValue"); + } + + [Test] + public void Parse_PropertyNameMatchesTypeName_MultipleTypes() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("property-collision.json")); + + var testimonialType = schema.Types.Single(t => t.Name == "Testimonial"); + testimonialType.Fields.Should().NotContain(f => f.Name == "Testimonial"); + testimonialType.Fields.Should().Contain(f => f.Name == "TestimonialValue"); + } + + [Test] + public void Parse_PropertyNameDoesNotMatchTypeName_NotSuffixed() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("property-collision.json")); + + var notificationType = schema.Types.Single(t => t.Name == "Notification"); + // "title" does not collide with "Notification" — should remain "Title" + notificationType.Fields.Should().Contain(f => f.Name == "Title"); + notificationType.Fields.Should().Contain(f => f.Name == "Id"); + notificationType.Fields.Should().Contain(f => f.Name == "IsRead"); + } + + [Test] + public void Parse_PropertyCollision_OtherFieldsOnTestimonialUnaffected() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("property-collision.json")); + + var testimonialType = schema.Types.Single(t => t.Name == "Testimonial"); + testimonialType.Fields.Should().Contain(f => f.Name == "Id"); + testimonialType.Fields.Should().Contain(f => f.Name == "Author"); + } + + #endregion + + #region ArrayComponentSchemas + + [Test] + public void Parse_ArrayTypeComponentSchema_CreatesWrapperTypeWithItemsField() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var voteTally = schema.Types.SingleOrDefault(t => t.Name == "VoteTallyResponse"); + voteTally.Should().NotBeNull(); + voteTally!.Fields.Should().HaveCount(1); + voteTally.Fields[0].Name.Should().Be("Items"); + voteTally.Fields[0].TypeName.Should().StartWith("List<"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_IsNotBuiltIn() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var voteTally = schema.Types.Single(t => t.Name == "VoteTallyResponse"); + voteTally.IsBuiltIn.Should().BeFalse(); + } + + [Test] + public void Parse_ArrayTypeComponentSchemaWithRef_ItemsFieldReferencesRefType() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var scoreboard = schema.Types.SingleOrDefault(t => t.Name == "Scoreboard"); + scoreboard.Should().NotBeNull(); + scoreboard!.Fields.Should().HaveCount(1); + scoreboard.Fields[0].Name.Should().Be("Items"); + scoreboard.Fields[0].TypeName.Should().Be("List"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_ReferencedFromResponse_OperationUsesTypeName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var voteTally = schema.Operations.Single(o => o.Name == "VoteTally"); + // The response $ref to VoteTallyResponse should use the type name + voteTally.OutputType.Name.Should().Be("VoteTallyResponse"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_InlineObjectItems_PromotedToNamedType() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + // VoteTallyResponse has inline object items with properties — should be promoted + var voteTally = schema.Types.Single(t => t.Name == "VoteTallyResponse"); + voteTally.Fields[0].TypeName.Should().Be("List"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_InlineObjectItems_PromotedTypeHasFields() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var itemType = schema.Types.SingleOrDefault(t => t.Name == "VoteTallyResponseItem"); + itemType.Should().NotBeNull(); + itemType!.Fields.Should().Contain(f => f.Name == "RepId"); + itemType.Fields.Should().Contain(f => f.Name == "Name"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_InlineObjectItems_EnumFieldResolved() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var itemType = schema.Types.Single(t => t.Name == "VoteTallyResponseItem"); + var legVotedField = itemType.Fields.Single(f => f.Name == "LegVoted"); + legVotedField.TypeName.Should().Be("LegVoted"); + + schema.Enums.Should().Contain(e => e.Name == "LegVoted"); + } + + [Test] + public void Parse_ArrayTypeComponentSchema_RegularObjectSchemasStillWork() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + var scoreEntry = schema.Types.SingleOrDefault(t => t.Name == "ScoreEntry"); + scoreEntry.Should().NotBeNull(); + scoreEntry!.Fields.Should().Contain(f => f.Name == "Player"); + scoreEntry.Fields.Should().Contain(f => f.Name == "Score"); + } + + #endregion + + #region InlineObjectPromotion + + [Test] + public void Parse_InlineObjectInArrayResponse_PromotedToNamedType() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + var listEvents = schema.Operations.Single(o => o.Name == "ListEvents"); + listEvents.OutputType.Fields[0].TypeName.Should().Be("List"); + } + + [Test] + public void Parse_InlineObjectInArrayResponse_PromotedTypeHasAllFields() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + var itemType = schema.Types.SingleOrDefault(t => t.Name == "ListEventsItem"); + itemType.Should().NotBeNull(); + itemType!.Fields.Should().Contain(f => f.Name == "Id"); + itemType.Fields.Should().Contain(f => f.Name == "Title"); + itemType.Fields.Should().Contain(f => f.Name == "StartDate"); + } + + [Test] + public void Parse_InlineObjectInArrayResponse_FieldTypesResolved() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + var itemType = schema.Types.Single(t => t.Name == "ListEventsItem"); + itemType.Fields.Single(f => f.Name == "Id").TypeName.Should().Be("int"); + itemType.Fields.Single(f => f.Name == "Title").TypeName.Should().Be("string"); + itemType.Fields.Single(f => f.Name == "StartDate").TypeName.Should().Be("DateTime"); + } + + [Test] + public void Parse_InlineObjectInProperty_PromotedToNamedType() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + // The "metadata" property on Event is an inline object + var eventType = schema.Types.SingleOrDefault(t => t.Name == "Event"); + if (eventType != null) + { + var metadataField = eventType.Fields.SingleOrDefault(f => f.Name == "Metadata"); + if (metadataField != null) + { + metadataField.TypeName.Should().NotBe("object"); + } + } + } + + [Test] + public void Parse_ArrayWithRefItems_StillUsesRefTypeName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + var listSpeakers = schema.Operations.Single(o => o.Name == "ListSpeakers"); + listSpeakers.OutputType.Fields[0].TypeName.Should().Be("List"); + } + + [Test] + public void Parse_ArrayWithNoItemProperties_FallsBackToObject() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + // RawData returns array of bare objects (no properties) — should remain List + var getRaw = schema.Operations.Single(o => o.Name == "RawData"); + getRaw.OutputType.Fields[0].TypeName.Should().Be("List"); + } + + [Test] + public void Parse_InlineObjectWithOnlyBareObjectProperties_NotPromoted() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("bare-object-properties.json")); + + // SearchResult.hit has only bare object properties (_source: type: object) + // Should NOT be promoted — remains as "object" + var searchResult = schema.Types.Single(t => t.Name == "SearchResult"); + var hitField = searchResult.Fields.Single(f => f.Name == "Hit"); + hitField.TypeName.Should().Be("object"); + } + + [Test] + public void Parse_InlineObjectWithOnlyBareObjectProperties_NoNamedTypeCreated() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("bare-object-properties.json")); + + // No "Hit" type should be created + schema.Types.Should().NotContain(t => t.Name == "Hit"); + } + + [Test] + public void Parse_InlineObjectWithMixedProperties_StillPromoted() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("bare-object-properties.json")); + + // SearchResult.mixed has { name: string, data: object } — not all bare objects + // Should be promoted to a named type + var searchResult = schema.Types.Single(t => t.Name == "SearchResult"); + var mixedField = searchResult.Fields.Single(f => f.Name == "Mixed"); + mixedField.TypeName.Should().NotBe("object"); + } + + [Test] + public void Parse_InlineObjectWithMixedProperties_PromotedTypeHasFields() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("bare-object-properties.json")); + + var searchResult = schema.Types.Single(t => t.Name == "SearchResult"); + var mixedField = searchResult.Fields.Single(f => f.Name == "Mixed"); + var mixedType = schema.Types.SingleOrDefault(t => t.Name == mixedField.TypeName); + mixedType.Should().NotBeNull(); + mixedType!.Fields.Should().Contain(f => f.Name == "Name" && f.TypeName == "string"); + } + + #endregion + + #region HttpVerbStripping + + [Test] + public void Parse_OperationIdWithGetPrefix_PrefixStripped() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("array-component.json")); + + // getVoteTally → VoteTally (no type collision) + schema.Operations.Should().Contain(o => o.Name == "VoteTally"); + } + + [Test] + public void Parse_OperationIdWithGetPrefix_CollidesWithType_PrefixKept() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("petstore.json")); + + // getPet → would strip to "Pet" but Pet is a model type → keeps "GetPet" + schema.Operations.Should().Contain(o => o.Name == "GetPet"); + } + + [Test] + public void Parse_OperationIdWithDeletePrefix_CollidesWithType_PrefixKept() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("petstore.json")); + + // deletePet → would strip to "Pet" but Pet is a model type → keeps "DeletePet" + schema.Operations.Should().Contain(o => o.Name == "DeletePet"); + } + + [Test] + public void Parse_OperationIdWithNonHttpVerb_NotStripped() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-enums.json")); + + // listItems → "List" isn't an HTTP verb → stays "ListItems" + schema.Operations.Should().Contain(o => o.Name == "ListItems"); + } + + [Test] + public void Parse_OperationIdWithGetPrefix_NoCollision_Stripped() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("inline-object-array.json")); + + // getRawData → "RawData" (no type named RawData) → stripped + schema.Operations.Should().Contain(o => o.Name == "RawData"); + } + + [Test] + public void Parse_SynthesizedName_CollidingNames_KeepPrefix() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // Multiple paths synthesize to "Users" → collision → all keep HTTP verb prefix + schema.Operations.Should().Contain(o => o.Name == "GetUsers"); + schema.Operations.Should().Contain(o => o.Name == "PostUsers"); + } + + #endregion + + #region DuplicateOperationNames + + [Test] + public void Parse_DuplicateSynthesizedNames_AllOperationsPresent() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // /users GET, /users POST, /users/{userId} GET, /users/{userId} DELETE, /users/{userId}/posts GET + schema.Operations.Should().HaveCount(5); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_AllNamesAreUnique() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + var names = schema.Operations.Select(o => o.Name).ToList(); + names.Should().OnlyHaveUniqueItems(); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_CollidingNamesKeepPrefix() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // /users GET and /users POST both strip to "Users" → collision → keep prefixes + schema.Operations.Should().Contain(o => o.Name == "GetUsers"); + schema.Operations.Should().Contain(o => o.Name == "PostUsers"); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_SecondGetDisambiguatedByPathParam() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // /users/{userId} GET → "GetUsers" collides → disambiguated to "GetUsersByUserId" + schema.Operations.Should().Contain(o => o.Name == "GetUsersByUserId"); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_DeleteKeepsPrefix() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // /users/{userId} DELETE → "DeleteUsers" (prefix kept due to collision) + schema.Operations.Should().Contain(o => o.Name == "DeleteUsers"); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_SubpathOperationsDistinct() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // /users/{userId}/posts GET → stripped "UsersPosts" is unique → no prefix needed + schema.Operations.Should().Contain(o => o.Name == "UsersPosts"); + } + + [Test] + public void Parse_DuplicateSynthesizedNames_HttpPathsPreserved() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("duplicate-names.json")); + + // Even though names keep prefixes, each operation keeps its original path + var getUsers = schema.Operations.Single(o => o.Name == "GetUsers"); + getUsers.HttpPath.Should().Be("/users"); + + var getUsersById = schema.Operations.Single(o => o.Name == "GetUsersByUserId"); + getUsersById.HttpPath.Should().Be("/users/{userId}"); + } + + [Test] + public void Parse_WithExplicitOperationIds_NoDuplication() + { + // petstore.json has explicit operationIds — getPet and deletePet become Pet and Pet2 + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("petstore.json")); + + var names = schema.Operations.Select(o => o.Name).ToList(); + names.Should().OnlyHaveUniqueItems(); + } + + #endregion + + #region DottedSchemaNames + + [Test] + public void Parse_DottedSchemaNames_TypesHaveSimplifiedNames() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + schema.Types.Should().Contain(t => t.Name == "UserDto"); + schema.Types.Should().Contain(t => t.Name == "UserListDto"); + schema.Types.Should().Contain(t => t.Name == "CreateUserCommand"); + schema.Types.Should().Contain(t => t.Name == "ReportListDto"); + } + + [Test] + public void Parse_DottedSchemaNames_NoDotsInTypeNames() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + schema.Types.Should().AllSatisfy(t => t.Name.Should().NotContain(".")); + } + + [Test] + public void Parse_DottedSchemaNames_RefFieldsResolveToSimplifiedNames() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var userListType = schema.Types.Single(t => t.Name == "UserListDto"); + var usersField = userListType.Fields.Single(f => f.Name == "Users"); + usersField.TypeName.Should().Be("List"); + } + + [Test] + public void Parse_DottedSchemaNames_EmptySchemaStillResolved() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + // EmptyResponse has no properties but should still be in the types list + schema.Types.Should().Contain(t => t.Name == "EmptyResponse"); + } + + [Test] + public void Parse_DottedSchemaNames_RootPathGetsRootName() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var rootOp = schema.Operations.SingleOrDefault(o => o.HttpPath == "/"); + rootOp.Should().NotBeNull(); + rootOp!.Name.Should().Be("Root"); + } + + [Test] + public void Parse_DottedSchemaNames_DotInPathSegmentHandled() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var calendarOp = schema.Operations.SingleOrDefault(o => + o.HttpPath == "/events/calendar.ics" + ); + calendarOp.Should().NotBeNull(); + calendarOp!.Name.Should().NotContain("."); + } + + [Test] + public void Parse_DottedSchemaNames_CommaInTagBecomesValidGroup() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var reportsOp = schema.Operations.Single(o => o.HttpPath == "/reports"); + reportsOp.Group.Should().NotContain(","); + reportsOp.Group.Should().Be("MeetingsReports"); + } + + [Test] + public void Parse_DottedSchemaNames_DuplicateParamNamesAfterPascalCaseDeduped() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var tagOp = schema.Operations.Single(o => + o.HttpPath == "/items/{itemId}/tags/{update_value}" + ); + // updateValue (query) and update_value (path) both become UpdateValue + // Should be deduplicated to avoid CS0102 + var fieldNames = tagOp.InputType.Fields.Select(f => f.Name).ToList(); + fieldNames.Should().OnlyHaveUniqueItems(); + } + + [Test] + public void Parse_DottedSchemaNames_EmptySchemaParamResolvedToObject() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + var searchOp = schema.Operations.Single(o => o.HttpPath == "/search"); + // "filters" param references an empty schema — resolved to "object" not the empty type name + var filtersField = searchOp.InputType.Fields.Single(f => f.Name == "Filters"); + filtersField.TypeName.Should().Be("object"); + } + + [Test] + public void Parse_DottedSchemaNames_DescriptionNewlinesCollapsed() + { + var parser = new OpenApiSchemaParser(); + + var schema = parser.Parse(FixturePath("dotted-names.json")); + + // The notes POST has a multiline summary — verify it's captured + var notesOp = schema.Operations.Single(o => o.HttpPath == "/notes"); + notesOp.Description.Should().NotBeNull(); + } + + #endregion +} diff --git a/tests/Trax.Cli.Tests/UnitTests/OpenApiSchemaParserTests.cs b/tests/Trax.Cli.Tests/UnitTests/OpenApiSchemaParserTests.cs index a41cb1c..c288eed 100644 --- a/tests/Trax.Cli.Tests/UnitTests/OpenApiSchemaParserTests.cs +++ b/tests/Trax.Cli.Tests/UnitTests/OpenApiSchemaParserTests.cs @@ -182,8 +182,8 @@ public void Parse_ComplexOpenApi_HealthCheckGetsASynthesizedName() var healthOp = schema.Operations.SingleOrDefault(o => o.HttpPath == "/health"); healthOp.Should().NotBeNull(); healthOp!.Name.Should().NotBeNullOrWhiteSpace(); - // No operationId in the schema, so the name is synthesized from method + path - healthOp.Name.Should().Be("GetHealth"); + // No operationId in the schema, so the name is synthesized from path segments + healthOp.Name.Should().Be("Health"); } [Test]