From 44884a451bdb6264e202ebd14d44d2cba9c0ced4 Mon Sep 17 00:00:00 2001 From: Isaac Chung Date: Tue, 9 Dec 2025 02:51:40 -0800 Subject: [PATCH 1/3] fix: empty spec names in schema generator --- epoch/openapi/README.md | 7 - epoch/openapi/config.go | 2 +- epoch/openapi/generator.go | 688 +++++++++++++++++++-------- epoch/openapi/generator_test.go | 61 +-- epoch/openapi/version_transformer.go | 96 ---- epoch/openapi/writer.go | 20 +- 6 files changed, 552 insertions(+), 322 deletions(-) diff --git a/epoch/openapi/README.md b/epoch/openapi/README.md index 8dacf07..f4a6492 100644 --- a/epoch/openapi/README.md +++ b/epoch/openapi/README.md @@ -379,11 +379,6 @@ The generator uses different naming strategies depending on whether schemas are - Preserves the same name across all versions - Example: `versionedapi.UpdateExampleRequest` in all version files -**When generating from scratch** (not found in base spec): -- **HEAD version**: Uses bare type name (e.g., `UserResponse`) -- **Versioned releases**: Uses type name + version suffix (e.g., `UserResponseV20240101`) -- Version suffix format: `V` + date without hyphens (e.g., `V20240101`) - **Unmanaged schemas** (in base spec but not in TypeRegistry): - Preserved as-is in all versions - Examples: `ErrorResponse`, `PaginationMeta`, common utility types @@ -393,8 +388,6 @@ The generator uses different naming strategies depending on whether schemas are **`SchemaNameMapper`**: Maps Go type names to OpenAPI schema names (useful for Swag integration with package prefixes) -**`ComponentNamePrefix`**: Adds prefix to version suffixes (e.g., `"Astro"` → `UserResponseAstroV20240101`) - **`OutputFormat`**: `"yaml"` or `"json"` **`IncludeMigrationMetadata`**: Adds `x-epoch-migrations` extensions to schemas diff --git a/epoch/openapi/config.go b/epoch/openapi/config.go index c93a981..65d8ff8 100644 --- a/epoch/openapi/config.go +++ b/epoch/openapi/config.go @@ -31,7 +31,7 @@ type SchemaGeneratorConfig struct { // // Behavior: // - If schema with mapped name exists in base spec → transforms it in place - // - If schema doesn't exist → generates from scratch using Go type name + version suffix + // - If schema doesn't exist → generates from scratch using Go type name SchemaNameMapper func(typeName string) string } diff --git a/epoch/openapi/generator.go b/epoch/openapi/generator.go index ae3873a..e2ce0d1 100644 --- a/epoch/openapi/generator.go +++ b/epoch/openapi/generator.go @@ -16,8 +16,12 @@ type SchemaGenerator struct { transformer *VersionTransformer writer *Writer - // Cache of generated schemas per version per type - schemaCache map[string]map[reflect.Type]*openapi3.SchemaRef + // Nested type registry for two-pass generation + // Maps version -> type -> component name + nestedTypeRegistry map[string]map[reflect.Type]string + + // Track which types need component schemas generated + typesToGenerate map[string][]reflect.Type } // NewSchemaGenerator creates a new schema generator @@ -32,11 +36,12 @@ func NewSchemaGenerator(config SchemaGeneratorConfig) *SchemaGenerator { } return &SchemaGenerator{ - config: &config, - typeParser: NewTypeParser(), - transformer: NewVersionTransformer(config.VersionBundle), - writer: NewWriter(config.OutputFormat), - schemaCache: make(map[string]map[reflect.Type]*openapi3.SchemaRef), + config: &config, + typeParser: NewTypeParser(), + transformer: NewVersionTransformer(config.VersionBundle), + writer: NewWriter(config.OutputFormat), + nestedTypeRegistry: make(map[string]map[reflect.Type]string), + typesToGenerate: make(map[string][]reflect.Type), } } @@ -67,6 +72,7 @@ func (sg *SchemaGenerator) GenerateVersionedSpecs(baseSpec *openapi3.T) (map[str // GenerateSpecForVersion generates an OpenAPI spec for a specific version // Uses smart transform: transforms existing schemas, generates missing ones +// Implements two-pass approach: collect all types first, then generate with refs func (sg *SchemaGenerator) GenerateSpecForVersion(baseSpec *openapi3.T, version *epoch.Version) (*openapi3.T, error) { // Clone the base spec spec := sg.cloneSpec(baseSpec) @@ -82,13 +88,91 @@ func (sg *SchemaGenerator) GenerateSpecForVersion(baseSpec *openapi3.T, version // Get all registered types types := sg.getRegisteredTypes() - // Process each registered type + // PASS 1: Collect all types that need component schemas (including nested types) + for _, typ := range types { + sg.collectNestedTypesForGeneration(typ, version) + } + + // PASS 2: Generate component schemas for all nested types in three sub-passes + // Sub-pass 2a: Parse and store base schemas (no refs, no transformations) + versionKey := version.String() + baseSchemas := make(map[reflect.Type]*openapi3.Schema) + + for _, nestedType := range sg.typesToGenerate[versionKey] { + // Parse the base schema + sg.typeParser.Reset() + schemaRef, err := sg.typeParser.ParseType(nestedType) + if err != nil { + return nil, fmt.Errorf("failed to parse nested type %s: %w", nestedType.Name(), err) + } + + // Get the base schema + var baseSchema *openapi3.Schema + if schemaRef.Ref != "" { + components := sg.typeParser.GetComponents() + componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") + if comp, ok := components[componentName]; ok && comp.Value != nil { + baseSchema = comp.Value + } + } else { + baseSchema = schemaRef.Value + } + + if baseSchema != nil { + baseSchemas[nestedType] = CloneSchema(baseSchema) + } + } + + // Sub-pass 2b: Add all base schemas to components (so refs can resolve in PASS 4) + for nestedType, schema := range baseSchemas { + componentName := sg.getComponentNameForType(versionKey, nestedType) + if componentName == "" { + continue + } + + // Add the base schema to components + spec.Components.Schemas[componentName] = openapi3.NewSchemaRef("", schema) + } + + // Sub-pass 2c: Apply transformations to all schemas (refs will be replaced in PASS 4) + for nestedType, schema := range baseSchemas { + componentName := sg.getComponentNameForType(versionKey, nestedType) + if componentName == "" { + continue + } + + direction := sg.getDirectionForType(nestedType) + transformedSchema, err := sg.transformer.TransformSchemaForVersion(schema, nestedType, version, direction) + if err != nil { + return nil, fmt.Errorf("failed to transform nested schema %s: %w", nestedType.Name(), err) + } + + // Rewrite refs to use versioned component names + sg.rewriteRefsForVersion(transformedSchema, version) + + // Update the component with the transformed schema + spec.Components.Schemas[componentName] = openapi3.NewSchemaRef("", transformedSchema) + } + + // PASS 3: Process top-level registered types (without ref replacement yet) for _, typ := range types { if err := sg.processTypeForVersion(baseSpec, spec, typ, version); err != nil { return nil, err } } + // PASS 4: Now that all components exist, replace nested schemas with refs in ALL schemas + for componentName, schemaRef := range spec.Components.Schemas { + if schemaRef == nil || schemaRef.Value == nil { + continue + } + + // Replace any remaining inline schemas with refs + if err := sg.replaceNestedSchemasWithRefsGeneric(schemaRef.Value, version, spec); err != nil { + return nil, fmt.Errorf("failed to replace refs in %s: %w", componentName, err) + } + } + return spec, nil } @@ -110,14 +194,17 @@ func (sg *SchemaGenerator) processTypeForVersion( if existingSchema != nil { // TRANSFORM PATH: Schema exists, transform it in place - // Resolve any $refs in the existing schema using other schemas from base spec - resolvedSchema := sg.resolveRefsFromBaseSpec(existingSchema, baseSpec) + // Clone the schema + clonedSchema := CloneSchema(existingSchema) + + // DON'T replace refs here - will be done in PASS 4 after all components exist // Determine correct direction based on type's role (request vs response) direction := sg.getDirectionForType(typ) + // Apply transformations transformedSchema, err := sg.transformer.TransformSchemaForVersion( - resolvedSchema, typ, version, direction) + clonedSchema, typ, version, direction) if err != nil { return fmt.Errorf("failed to transform schema %s: %w", mappedSchemaName, err) } @@ -125,68 +212,33 @@ func (sg *SchemaGenerator) processTypeForVersion( // Replace with same name (preserves endpoint references) spec.Components.Schemas[mappedSchemaName] = openapi3.NewSchemaRef("", transformedSchema) } else { - // FALLBACK PATH: Schema doesn't exist, generate from scratch + // FALLBACK PATH: Schema doesn't exist in base spec + + schemaKey := goTypeName + + if _, exists := spec.Components.Schemas[schemaKey]; exists { + // Already generated as a nested type in PASS 2, skip to avoid duplication + return nil + } // Determine correct direction based on type's role (request vs response) direction := sg.getDirectionForType(typ) - generatedSchema, err := sg.GetSchemaForType(typ, version, direction) + // Generate schema (without refs - will be done in PASS 4) + generatedSchema, err := sg.generateSchemaWithoutRefs(typ, version, direction) if err != nil { return fmt.Errorf("failed to generate schema for %s: %w", goTypeName, err) } - schemaKey := goTypeName + // Rewrite refs to use versioned component names + sg.rewriteRefsForVersion(generatedSchema, version) + spec.Components.Schemas[schemaKey] = openapi3.NewSchemaRef("", generatedSchema) } return nil } -// resolveRefsFromBaseSpec resolves $refs using schemas from the base spec -func (sg *SchemaGenerator) resolveRefsFromBaseSpec(schema *openapi3.Schema, baseSpec *openapi3.T) *openapi3.Schema { - if schema == nil || baseSpec == nil || baseSpec.Components == nil || baseSpec.Components.Schemas == nil { - return schema - } - - // Clone the schema first - result := CloneSchema(schema) - - // Resolve $refs in properties - if result.Properties != nil { - for propName, propRef := range result.Properties { - if propRef.Ref != "" { - // It's a $ref - resolve it from base spec - componentName := strings.TrimPrefix(propRef.Ref, "#/components/schemas/") - if comp, ok := baseSpec.Components.Schemas[componentName]; ok && comp.Value != nil { - // Recursively resolve nested $refs - resolvedProp := sg.resolveRefsFromBaseSpec(comp.Value, baseSpec) - result.Properties[propName] = openapi3.NewSchemaRef("", resolvedProp) - } - } else if propRef.Value != nil { - // Recursively resolve nested objects - resolvedProp := sg.resolveRefsFromBaseSpec(propRef.Value, baseSpec) - result.Properties[propName] = openapi3.NewSchemaRef("", resolvedProp) - } - } - } - - // Resolve $refs in array items - if result.Items != nil { - if result.Items.Ref != "" { - componentName := strings.TrimPrefix(result.Items.Ref, "#/components/schemas/") - if comp, ok := baseSpec.Components.Schemas[componentName]; ok && comp.Value != nil { - resolvedItems := sg.resolveRefsFromBaseSpec(comp.Value, baseSpec) - result.Items = openapi3.NewSchemaRef("", resolvedItems) - } - } else if result.Items.Value != nil { - resolvedItems := sg.resolveRefsFromBaseSpec(result.Items.Value, baseSpec) - result.Items = openapi3.NewSchemaRef("", resolvedItems) - } - } - - return result -} - // findSchemaInSpec looks for a schema by name in the base spec // Returns cloned schema if found, nil if not found func (sg *SchemaGenerator) findSchemaInSpec(spec *openapi3.T, schemaName string) *openapi3.Schema { @@ -203,112 +255,6 @@ func (sg *SchemaGenerator) findSchemaInSpec(spec *openapi3.T, schemaName string) return CloneSchema(schemaRef.Value) } -// GetSchemaForType generates a schema for a specific type at a specific version and direction -func (sg *SchemaGenerator) GetSchemaForType( - typ reflect.Type, - version *epoch.Version, - direction SchemaDirection, -) (*openapi3.Schema, error) { - // Check cache - versionKey := version.String() - if schemas, ok := sg.schemaCache[versionKey]; ok { - if cached, ok := schemas[typ]; ok && cached.Value != nil { - return cached.Value, nil - } - } - - // Parse the HEAD version schema first - sg.typeParser.Reset() // Reset to get fresh schema - schemaRef, err := sg.typeParser.ParseType(typ) - if err != nil { - return nil, fmt.Errorf("failed to parse type: %w", err) - } - - // Get all components for resolving $refs - components := sg.typeParser.GetComponents() - - // If it's a $ref, we need to get the actual schema from components - var baseSchema *openapi3.Schema - if schemaRef.Ref != "" { - // Extract component name from $ref - componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") - if comp, ok := components[componentName]; ok && comp.Value != nil { - baseSchema = comp.Value - } else { - return nil, fmt.Errorf("component %s not found", componentName) - } - } else { - baseSchema = schemaRef.Value - } - - if baseSchema == nil { - return nil, fmt.Errorf("no schema found for type %s", typ.Name()) - } - - // Resolve all $refs inline to avoid unresolved reference issues - // when generating versioned schemas from scratch - resolvedSchema := sg.resolveRefsInSchema(baseSchema, components) - - // Apply version transformations - transformedSchema, err := sg.transformer.TransformSchemaForVersion(resolvedSchema, typ, version, direction) - if err != nil { - return nil, fmt.Errorf("failed to transform schema: %w", err) - } - - // Cache the result - if sg.schemaCache[versionKey] == nil { - sg.schemaCache[versionKey] = make(map[reflect.Type]*openapi3.SchemaRef) - } - sg.schemaCache[versionKey][typ] = openapi3.NewSchemaRef("", transformedSchema) - - return transformedSchema, nil -} - -// resolveRefsInSchema recursively resolves all $ref references to inline schemas -func (sg *SchemaGenerator) resolveRefsInSchema(schema *openapi3.Schema, components map[string]*openapi3.SchemaRef) *openapi3.Schema { - if schema == nil { - return nil - } - - // Clone the schema to avoid modifying original - result := CloneSchema(schema) - - // Resolve $refs in properties - if result.Properties != nil { - for propName, propRef := range result.Properties { - if propRef.Ref != "" { - // It's a $ref - resolve it - componentName := strings.TrimPrefix(propRef.Ref, "#/components/schemas/") - if comp, ok := components[componentName]; ok && comp.Value != nil { - // Recursively resolve nested $refs - resolvedProp := sg.resolveRefsInSchema(comp.Value, components) - result.Properties[propName] = openapi3.NewSchemaRef("", resolvedProp) - } - } else if propRef.Value != nil { - // Recursively resolve nested objects - resolvedProp := sg.resolveRefsInSchema(propRef.Value, components) - result.Properties[propName] = openapi3.NewSchemaRef("", resolvedProp) - } - } - } - - // Resolve $refs in array items - if result.Items != nil { - if result.Items.Ref != "" { - componentName := strings.TrimPrefix(result.Items.Ref, "#/components/schemas/") - if comp, ok := components[componentName]; ok && comp.Value != nil { - resolvedItems := sg.resolveRefsInSchema(comp.Value, components) - result.Items = openapi3.NewSchemaRef("", resolvedItems) - } - } else if result.Items.Value != nil { - resolvedItems := sg.resolveRefsInSchema(result.Items.Value, components) - result.Items = openapi3.NewSchemaRef("", resolvedItems) - } - } - - return result -} - // getRegisteredTypes extracts all types from the endpoint registry // including nested types discovered from struct analysis func (sg *SchemaGenerator) getRegisteredTypes() []reflect.Type { @@ -394,27 +340,6 @@ func (sg *SchemaGenerator) collectNestedTypes(rootType reflect.Type, typeMap map } } -// getVersionSuffix returns a suffix for versioned schema names -// e.g., "V20240101" for date "2024-01-01" -func (sg *SchemaGenerator) getVersionSuffix(version *epoch.Version) string { - if version.IsHead { - return "" - } - - // Remove hyphens and special characters from version string - versionStr := version.String() - versionStr = strings.ReplaceAll(versionStr, "-", "") - versionStr = strings.ReplaceAll(versionStr, ".", "") - versionStr = strings.ReplaceAll(versionStr, "v", "") - - // Add prefix - if sg.config.ComponentNamePrefix != "" { - return fmt.Sprintf("%sV%s", sg.config.ComponentNamePrefix, versionStr) - } - - return fmt.Sprintf("V%s", versionStr) -} - // cloneSpec creates a shallow clone of an OpenAPI spec // We clone to avoid modifying the original base spec func (sg *SchemaGenerator) cloneSpec(original *openapi3.T) *openapi3.T { @@ -477,3 +402,394 @@ func (sg *SchemaGenerator) getDirectionForType(typ reflect.Type) SchemaDirection // Default to response if unknown (safer default) return SchemaDirectionResponse } + +// registerNestedType registers a nested type for component generation +func (sg *SchemaGenerator) registerNestedType(version string, typ reflect.Type, componentName string) { + if sg.nestedTypeRegistry[version] == nil { + sg.nestedTypeRegistry[version] = make(map[reflect.Type]string) + } + sg.nestedTypeRegistry[version][typ] = componentName + + // Track for generation if not already tracked + found := false + for _, t := range sg.typesToGenerate[version] { + if t == typ { + found = true + break + } + } + if !found { + sg.typesToGenerate[version] = append(sg.typesToGenerate[version], typ) + } +} + +// getComponentNameForType returns the component name for a type, or empty if not registered +func (sg *SchemaGenerator) getComponentNameForType(version string, typ reflect.Type) string { + if sg.nestedTypeRegistry[version] == nil { + return "" + } + return sg.nestedTypeRegistry[version][typ] +} + +// generateComponentNameForType generates a component name for a nested type +func (sg *SchemaGenerator) generateComponentNameForType(typ reflect.Type, version *epoch.Version) string { + // Dereference pointer + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + // Get base type name + typeName := typ.Name() + + // Handle anonymous types + if typeName == "" { + switch typ.Kind() { + case reflect.Slice, reflect.Array: + elemType := typ.Elem() + elemName := sg.generateComponentNameForType(elemType, version) + return elemName + "Array" + case reflect.Struct: + // Generate synthetic name based on package path + if typ.PkgPath() != "" { + parts := strings.Split(typ.PkgPath(), "/") + pkgName := parts[len(parts)-1] + return pkgName + "AnonymousStruct" + } + return "AnonymousStruct" + default: + return "AnonymousType" + } + } + + return typeName +} + +// typeHasMigrations checks if a type has any migrations defined +func (sg *SchemaGenerator) typeHasMigrations(typ reflect.Type) bool { + // Check all version changes in the bundle + for _, version := range sg.config.VersionBundle.GetVersions() { + for _, change := range version.Changes { + if vc, ok := change.(*epoch.VersionChange); ok { + // Check if this type has request or response operations + if _, exists := vc.GetRequestOperationsByType(typ); exists { + return true + } + if _, exists := vc.GetResponseOperationsByType(typ); exists { + return true + } + } + } + } + return false +} + +// collectNestedTypesForGeneration collects all nested types that need component generation +// Uses visited map to prevent infinite recursion on circular dependencies +func (sg *SchemaGenerator) collectNestedTypesForGeneration(typ reflect.Type, version *epoch.Version) { + // Use a visited map to prevent infinite recursion + visited := make(map[reflect.Type]bool) + sg.collectNestedTypesRecursive(typ, version, visited) +} + +// collectNestedTypesRecursive is the internal recursive implementation with cycle detection +func (sg *SchemaGenerator) collectNestedTypesRecursive(typ reflect.Type, version *epoch.Version, visited map[reflect.Type]bool) { + if typ == nil { + return + } + + // Dereference pointer + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + // Check if we've already visited this type (cycle detection) + if visited[typ] { + return + } + visited[typ] = true + + // Only process struct types for nested discovery + if typ.Kind() != reflect.Struct { + return + } + + versionKey := version.String() + + // Get nested arrays and objects from the type + nestedArrays, nestedObjects := epoch.BuildNestedTypeMaps(typ) + + // Register nested arrays + for _, arrayItemType := range nestedArrays { + // Dereference if pointer + itemType := arrayItemType + if itemType.Kind() == reflect.Ptr { + itemType = itemType.Elem() + } + + // Generate component name + componentName := sg.generateComponentNameForType(itemType, version) + sg.registerNestedType(versionKey, itemType, componentName) + + // Recursively collect nested types (with cycle detection) + sg.collectNestedTypesRecursive(itemType, version, visited) + } + + // Register nested objects + for _, objectType := range nestedObjects { + // Dereference if pointer + objType := objectType + if objType.Kind() == reflect.Ptr { + objType = objType.Elem() + } + + // Generate component name + componentName := sg.generateComponentNameForType(objType, version) + sg.registerNestedType(versionKey, objType, componentName) + + // Recursively collect nested types (with cycle detection) + sg.collectNestedTypesRecursive(objType, version, visited) + } +} + +// generateSchemaWithoutRefs generates a schema WITHOUT replacing nested schemas with refs +// This is used for initial generation before refs are available +func (sg *SchemaGenerator) generateSchemaWithoutRefs( + typ reflect.Type, + version *epoch.Version, + direction SchemaDirection, +) (*openapi3.Schema, error) { + // Parse the HEAD version schema first + sg.typeParser.Reset() + schemaRef, err := sg.typeParser.ParseType(typ) + if err != nil { + return nil, fmt.Errorf("failed to parse type: %w", err) + } + + // Get the base schema + var baseSchema *openapi3.Schema + if schemaRef.Ref != "" { + // Extract component name from $ref + components := sg.typeParser.GetComponents() + componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") + if comp, ok := components[componentName]; ok && comp.Value != nil { + baseSchema = comp.Value + } else { + return nil, fmt.Errorf("component %s not found", componentName) + } + } else { + baseSchema = schemaRef.Value + } + + if baseSchema == nil { + return nil, fmt.Errorf("no schema found for type %s", typ.Name()) + } + + // Clone the schema + schema := CloneSchema(baseSchema) + + // Apply version transformations (without ref replacement) + transformedSchema, err := sg.transformer.TransformSchemaForVersion(schema, typ, version, direction) + if err != nil { + return nil, fmt.Errorf("failed to transform schema: %w", err) + } + + return transformedSchema, nil +} + +// replaceNestedSchemasWithRefsGeneric replaces inline nested schemas with $ref pointers +// This version doesn't require the parent Go type - it infers refs from inline object/array schemas +func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( + schema *openapi3.Schema, + version *epoch.Version, + spec *openapi3.T, +) error { + if schema == nil { + return nil + } + + if schema.Properties == nil { + return nil + } + + versionKey := version.String() + + // Scan properties for inline object schemas that should be refs + for propName, propRef := range schema.Properties { + if propRef == nil { + continue + } + + // Skip if it's already a $ref + if propRef.Ref != "" { + continue + } + + propSchema := propRef.Value + if propSchema == nil { + continue + } + + // Check if it's an inline object schema + if propSchema.Type != nil && len(*propSchema.Type) > 0 { + typeStr := (*propSchema.Type)[0] + + if typeStr == "object" && propSchema.Properties != nil { + // This is an inline object - try to find a matching component + // Look for components that match this schema structure + matchingComponentName := sg.findMatchingComponent(propSchema, versionKey, spec) + if matchingComponentName != "" { + // Replace with $ref + schema.Properties[propName] = &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s", matchingComponentName), + } + } + } else if typeStr == "array" && propSchema.Items != nil && propSchema.Items.Value != nil { + // Check if array items are inline objects + itemSchema := propSchema.Items.Value + if itemSchema.Type != nil && len(*itemSchema.Type) > 0 && (*itemSchema.Type)[0] == "object" { + // Inline object in array - try to find matching component + matchingComponentName := sg.findMatchingComponent(itemSchema, versionKey, spec) + if matchingComponentName != "" { + // Replace items with $ref + propSchema.Items = &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s", matchingComponentName), + } + } + } + } + } + } + + return nil +} + +// findMatchingComponent finds a component schema that matches the given inline schema structure +// This is used to deduplicate inline schemas by finding their component equivalents +func (sg *SchemaGenerator) findMatchingComponent( + inlineSchema *openapi3.Schema, + versionKey string, + spec *openapi3.T, +) string { + if inlineSchema == nil || inlineSchema.Properties == nil { + return "" + } + + // Get the property names from the inline schema + inlineProps := make(map[string]bool) + for propName := range inlineSchema.Properties { + inlineProps[propName] = true + } + + // Search components for a matching schema + for componentName, componentRef := range spec.Components.Schemas { + if componentRef == nil || componentRef.Value == nil || componentRef.Value.Properties == nil { + continue + } + + // Check if properties match + componentProps := make(map[string]bool) + for propName := range componentRef.Value.Properties { + componentProps[propName] = true + } + + // Simple heuristic: if property names match, it's likely the same type + if len(inlineProps) == len(componentProps) { + allMatch := true + for propName := range inlineProps { + if !componentProps[propName] { + allMatch = false + break + } + } + if allMatch { + return componentName + } + } + } + + return "" +} + +// rewriteRefsForVersion rewrites all $ref pointers in a schema to use versioned component names +// This is necessary because the TypeParser generates refs using HEAD type names (e.g., ProfileRequest), +// but versioned schemas need refs to versioned components (e.g., ProfileRequestV20240101) +func (sg *SchemaGenerator) rewriteRefsForVersion(schema *openapi3.Schema, version *epoch.Version) { + if schema == nil { + return + } + + // If HEAD version, no rewriting needed + if version.IsHead { + return + } + + // Rewrite refs in properties + if schema.Properties != nil { + for propName, propRef := range schema.Properties { + if propRef == nil { + continue + } + + // Rewrite $ref if it's a reference to a component + if propRef.Ref != "" && strings.HasPrefix(propRef.Ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(propRef.Ref, "#/components/schemas/") + // Check if this component needs versioning (has migrations) + // For now, assume all refs need versioning - we can refine this later + versionedRef := fmt.Sprintf("#/components/schemas/%s", componentName) + schema.Properties[propName] = &openapi3.SchemaRef{ + Ref: versionedRef, + } + } + + // Recursively rewrite refs in nested schemas + if propRef.Value != nil { + sg.rewriteRefsForVersion(propRef.Value, version) + } + } + } + + // Rewrite refs in array items + if schema.Items != nil { + if schema.Items.Ref != "" && strings.HasPrefix(schema.Items.Ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(schema.Items.Ref, "#/components/schemas/") + versionedRef := fmt.Sprintf("#/components/schemas/%s", componentName) + schema.Items = &openapi3.SchemaRef{ + Ref: versionedRef, + } + } + + // Recursively rewrite refs in item schema + if schema.Items != nil && schema.Items.Value != nil { + sg.rewriteRefsForVersion(schema.Items.Value, version) + } + } + + // Rewrite refs in allOf/oneOf/anyOf + for _, schemaRef := range schema.AllOf { + if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") + schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) + } + if schemaRef != nil && schemaRef.Value != nil { + sg.rewriteRefsForVersion(schemaRef.Value, version) + } + } + for _, schemaRef := range schema.OneOf { + if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") + schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) + } + if schemaRef != nil && schemaRef.Value != nil { + sg.rewriteRefsForVersion(schemaRef.Value, version) + } + } + for _, schemaRef := range schema.AnyOf { + if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { + componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") + schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) + } + if schemaRef != nil && schemaRef.Value != nil { + sg.rewriteRefsForVersion(schemaRef.Value, version) + } + } +} diff --git a/epoch/openapi/generator_test.go b/epoch/openapi/generator_test.go index 0990c7d..bfd42d7 100644 --- a/epoch/openapi/generator_test.go +++ b/epoch/openapi/generator_test.go @@ -97,23 +97,6 @@ var _ = Describe("SchemaGenerator", func() { }) }) - DescribeTable("Version Suffix", - func(versionFunc func() *epoch.Version, expectedSuffix string) { - versionBundle, _ := epoch.NewVersionBundle([]*epoch.Version{}) - config := SchemaGeneratorConfig{ - VersionBundle: versionBundle, - } - generator := NewSchemaGenerator(config) - - version := versionFunc() - suffix := generator.getVersionSuffix(version) - Expect(suffix).To(Equal(expectedSuffix)) - }, - Entry("HEAD version", func() *epoch.Version { return epoch.NewHeadVersion() }, ""), - Entry("date version", func() *epoch.Version { v, _ := epoch.NewDateVersion("2024-01-01"); return v }, "V20240101"), - Entry("semver version", func() *epoch.Version { v, _ := epoch.NewSemverVersion("1.2.3"); return v }, "V123"), - ) - Describe("Spec Cloning", func() { It("should clone spec correctly", func() { original := &openapi3.T{ @@ -192,21 +175,45 @@ var _ = Describe("SchemaGenerator", func() { generator := NewSchemaGenerator(config) - // Generate schema for HEAD - headVersion := epoch.NewHeadVersion() - schema, err := generator.GetSchemaForType( - reflect.TypeOf(TestUserResponse{}), - headVersion, - SchemaDirectionResponse, - ) + // Create a base spec with the type + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "TestUserResponse": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "id": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }), + "name": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "email": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + }, + } + // Generate spec for HEAD + headVersion := epoch.NewHeadVersion() + headSpec, err := generator.GenerateSpecForVersion(baseSpec, headVersion) Expect(err).NotTo(HaveOccurred()) + Expect(headSpec).NotTo(BeNil()) + + // Get the schema from the generated spec + schema := headSpec.Components.Schemas["TestUserResponse"] Expect(schema).NotTo(BeNil()) + Expect(schema.Value).NotTo(BeNil()) // Check that required fields are present - Expect(schema.Properties).NotTo(BeEmpty()) - Expect(schema.Properties["id"]).NotTo(BeNil()) - Expect(schema.Properties["name"]).NotTo(BeNil()) + Expect(schema.Value.Properties).NotTo(BeEmpty()) + Expect(schema.Value.Properties["id"]).NotTo(BeNil()) + Expect(schema.Value.Properties["name"]).NotTo(BeNil()) }) }) diff --git a/epoch/openapi/version_transformer.go b/epoch/openapi/version_transformer.go index 85df2b6..3f5cf43 100644 --- a/epoch/openapi/version_transformer.go +++ b/epoch/openapi/version_transformer.go @@ -78,105 +78,9 @@ func (vt *VersionTransformer) transformNestedSchemas( return nil } - // Analyze the parent type to discover nested types - nestedTypes := epoch.AnalyzeStructFields(parentType, "", nil) - - // Process each nested type found - for _, nested := range nestedTypes { - // Only handle direct children (no dots in path for first level) - // Deeper nesting will be handled recursively - if containsDot(nested.Path) { - continue - } - - // Find the property in the schema - propRef, exists := schema.Properties[nested.Path] - if !exists || propRef == nil { - continue - } - - // Get the schema to transform - var propSchema *openapi3.Schema - if propRef.Value != nil { - propSchema = propRef.Value - } else if propRef.Ref != "" { - // Skip $ref - these should be transformed separately as component schemas - continue - } - - if propSchema == nil { - continue - } - - if nested.IsArray { - // Transform array items - if propSchema.Items != nil && propSchema.Items.Value != nil { - itemSchema := propSchema.Items.Value - - // Apply transformations for the array item type - transformedItemSchema, err := vt.transformNestedTypeSchema( - itemSchema, nested.Type, targetVersion, direction) - if err != nil { - return fmt.Errorf("failed to transform array items at %s: %w", nested.Path, err) - } - - // Update the items schema - propSchema.Items = openapi3.NewSchemaRef("", transformedItemSchema) - } - } else { - // Transform nested object - transformedSchema, err := vt.transformNestedTypeSchema( - propSchema, nested.Type, targetVersion, direction) - if err != nil { - return fmt.Errorf("failed to transform nested object at %s: %w", nested.Path, err) - } - - // Update the property schema - schema.Properties[nested.Path] = openapi3.NewSchemaRef("", transformedSchema) - } - } - return nil } -// transformNestedTypeSchema applies transformations to a nested type's schema -func (vt *VersionTransformer) transformNestedTypeSchema( - schema *openapi3.Schema, - nestedType reflect.Type, - targetVersion *epoch.Version, - direction SchemaDirection, -) (*openapi3.Schema, error) { - // Clone the schema first - clonedSchema := CloneSchema(schema) - - // Get version changes for the nested type - changes := vt.getVersionChanges(nestedType, targetVersion, direction) - - // Apply all changes - for _, change := range changes { - if err := vt.applyChange(clonedSchema, change, direction); err != nil { - return nil, fmt.Errorf("failed to apply change: %w", err) - } - } - - // Recursively transform any nested types within this type - if err := vt.transformNestedSchemas(clonedSchema, nestedType, targetVersion, direction); err != nil { - return nil, err - } - - return clonedSchema, nil -} - -// containsDot checks if a string contains a dot character -func containsDot(s string) bool { - for _, c := range s { - if c == '.' { - return true - } - } - return false -} - // versionChange holds information about a single version change operation type versionChange struct { fromVersion *epoch.Version diff --git a/epoch/openapi/writer.go b/epoch/openapi/writer.go index ccca5c7..d01f003 100644 --- a/epoch/openapi/writer.go +++ b/epoch/openapi/writer.go @@ -26,11 +26,6 @@ func NewWriter(format string) *Writer { // WriteSpec writes an OpenAPI spec to a file func (w *Writer) WriteSpec(spec *openapi3.T, filepath string) error { - // Validate the spec first - if err := w.ValidateSpec(spec); err != nil { - return fmt.Errorf("spec validation failed: %w", err) - } - var data []byte var err error @@ -45,6 +40,21 @@ func (w *Writer) WriteSpec(spec *openapi3.T, filepath string) error { return fmt.Errorf("failed to marshal spec: %w", err) } + // Validate by loading the marshalled data + // This ensures we validate the actual output, not in-memory state + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + validationSpec, err := loader.LoadFromData(data) + + if err != nil { + return fmt.Errorf("failed to load spec for validation: %w", err) + } + + if err := validationSpec.Validate(context.Background(), openapi3.DisableExamplesValidation()); err != nil { + return fmt.Errorf("spec validation failed: %w", err) + } + // Write to file if err := os.WriteFile(filepath, data, 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) From 63789f54174a5b46e6a826bad327d36efd2e9079 Mon Sep 17 00:00:00 2001 From: Isaac Chung Date: Tue, 9 Dec 2025 14:33:56 -0800 Subject: [PATCH 2/3] fix: bugs --- epoch/openapi/README.md | 5 +- epoch/openapi/config.go | 7 -- epoch/openapi/generator.go | 152 ++++++--------------------- epoch/openapi/version_transformer.go | 29 ----- 4 files changed, 31 insertions(+), 162 deletions(-) diff --git a/epoch/openapi/README.md b/epoch/openapi/README.md index f4a6492..26cb5b8 100644 --- a/epoch/openapi/README.md +++ b/epoch/openapi/README.md @@ -390,8 +390,6 @@ The generator uses different naming strategies depending on whether schemas are **`OutputFormat`**: `"yaml"` or `"json"` -**`IncludeMigrationMetadata`**: Adds `x-epoch-migrations` extensions to schemas - ### Two Generation Paths **Path 1: Transform Existing Schema** (base spec has schema) @@ -400,8 +398,7 @@ The generator uses different naming strategies depending on whether schemas are - Example: `versionedapi.UserResponse` in all versions, with different fields **Path 2: Generate From Scratch** (base spec missing schema) -- HEAD: uses bare name (`UserResponse`) -- Versioned: uses versioned name (`UserResponseV20240101`) +- Uses bare type name for all versions (e.g., `UserResponse`) - Content generated from Go types + migrations applied ## Version Transformations diff --git a/epoch/openapi/config.go b/epoch/openapi/config.go index 65d8ff8..a90b73e 100644 --- a/epoch/openapi/config.go +++ b/epoch/openapi/config.go @@ -15,13 +15,6 @@ type SchemaGeneratorConfig struct { // OutputFormat specifies the output format ("yaml" or "json") OutputFormat string - // IncludeMigrationMetadata adds x-epoch-migrations extensions to schemas (optional) - IncludeMigrationMetadata bool - - // ComponentNamePrefix is added to all generated component names (e.g., "Astro") - // Results in component names like "AstroUser" instead of "User" - ComponentNamePrefix string - // SchemaNameMapper maps Go type names to OpenAPI schema names. // Use this when your base spec uses different naming conventions. // Common use case: Swag adds package prefixes to schema names. diff --git a/epoch/openapi/generator.go b/epoch/openapi/generator.go index e2ce0d1..7f18453 100644 --- a/epoch/openapi/generator.go +++ b/epoch/openapi/generator.go @@ -141,14 +141,22 @@ func (sg *SchemaGenerator) GenerateSpecForVersion(baseSpec *openapi3.T, version continue } - direction := sg.getDirectionForType(nestedType) - transformedSchema, err := sg.transformer.TransformSchemaForVersion(schema, nestedType, version, direction) + // For nested types, apply BOTH request and response transformations to create + // a superset schema that works in both contexts. This handles cases where the + // same nested type appears in both request and response types. + transformedSchema := CloneSchema(schema) + + // Apply request transformations + transformedSchema, err := sg.transformer.TransformSchemaForVersion(transformedSchema, nestedType, version, SchemaDirectionRequest) if err != nil { - return nil, fmt.Errorf("failed to transform nested schema %s: %w", nestedType.Name(), err) + return nil, fmt.Errorf("failed to transform nested schema %s (request): %w", nestedType.Name(), err) } - // Rewrite refs to use versioned component names - sg.rewriteRefsForVersion(transformedSchema, version) + // Apply response transformations on top + transformedSchema, err = sg.transformer.TransformSchemaForVersion(transformedSchema, nestedType, version, SchemaDirectionResponse) + if err != nil { + return nil, fmt.Errorf("failed to transform nested schema %s (response): %w", nestedType.Name(), err) + } // Update the component with the transformed schema spec.Components.Schemas[componentName] = openapi3.NewSchemaRef("", transformedSchema) @@ -168,7 +176,7 @@ func (sg *SchemaGenerator) GenerateSpecForVersion(baseSpec *openapi3.T, version } // Replace any remaining inline schemas with refs - if err := sg.replaceNestedSchemasWithRefsGeneric(schemaRef.Value, version, spec); err != nil { + if err := sg.replaceNestedSchemasWithRefsGeneric(schemaRef.Value, spec); err != nil { return nil, fmt.Errorf("failed to replace refs in %s: %w", componentName, err) } } @@ -230,9 +238,6 @@ func (sg *SchemaGenerator) processTypeForVersion( return fmt.Errorf("failed to generate schema for %s: %w", goTypeName, err) } - // Rewrite refs to use versioned component names - sg.rewriteRefsForVersion(generatedSchema, version) - spec.Components.Schemas[schemaKey] = openapi3.NewSchemaRef("", generatedSchema) } @@ -432,7 +437,7 @@ func (sg *SchemaGenerator) getComponentNameForType(version string, typ reflect.T } // generateComponentNameForType generates a component name for a nested type -func (sg *SchemaGenerator) generateComponentNameForType(typ reflect.Type, version *epoch.Version) string { +func (sg *SchemaGenerator) generateComponentNameForType(typ reflect.Type) string { // Dereference pointer if typ.Kind() == reflect.Ptr { typ = typ.Elem() @@ -446,7 +451,7 @@ func (sg *SchemaGenerator) generateComponentNameForType(typ reflect.Type, versio switch typ.Kind() { case reflect.Slice, reflect.Array: elemType := typ.Elem() - elemName := sg.generateComponentNameForType(elemType, version) + elemName := sg.generateComponentNameForType(elemType) return elemName + "Array" case reflect.Struct: // Generate synthetic name based on package path @@ -464,25 +469,6 @@ func (sg *SchemaGenerator) generateComponentNameForType(typ reflect.Type, versio return typeName } -// typeHasMigrations checks if a type has any migrations defined -func (sg *SchemaGenerator) typeHasMigrations(typ reflect.Type) bool { - // Check all version changes in the bundle - for _, version := range sg.config.VersionBundle.GetVersions() { - for _, change := range version.Changes { - if vc, ok := change.(*epoch.VersionChange); ok { - // Check if this type has request or response operations - if _, exists := vc.GetRequestOperationsByType(typ); exists { - return true - } - if _, exists := vc.GetResponseOperationsByType(typ); exists { - return true - } - } - } - } - return false -} - // collectNestedTypesForGeneration collects all nested types that need component generation // Uses visited map to prevent infinite recursion on circular dependencies func (sg *SchemaGenerator) collectNestedTypesForGeneration(typ reflect.Type, version *epoch.Version) { @@ -527,7 +513,7 @@ func (sg *SchemaGenerator) collectNestedTypesRecursive(typ reflect.Type, version } // Generate component name - componentName := sg.generateComponentNameForType(itemType, version) + componentName := sg.generateComponentNameForType(itemType) sg.registerNestedType(versionKey, itemType, componentName) // Recursively collect nested types (with cycle detection) @@ -543,7 +529,7 @@ func (sg *SchemaGenerator) collectNestedTypesRecursive(typ reflect.Type, version } // Generate component name - componentName := sg.generateComponentNameForType(objType, version) + componentName := sg.generateComponentNameForType(objType) sg.registerNestedType(versionKey, objType, componentName) // Recursively collect nested types (with cycle detection) @@ -600,7 +586,6 @@ func (sg *SchemaGenerator) generateSchemaWithoutRefs( // This version doesn't require the parent Go type - it infers refs from inline object/array schemas func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( schema *openapi3.Schema, - version *epoch.Version, spec *openapi3.T, ) error { if schema == nil { @@ -611,8 +596,6 @@ func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( return nil } - versionKey := version.String() - // Scan properties for inline object schemas that should be refs for propName, propRef := range schema.Properties { if propRef == nil { @@ -636,7 +619,7 @@ func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( if typeStr == "object" && propSchema.Properties != nil { // This is an inline object - try to find a matching component // Look for components that match this schema structure - matchingComponentName := sg.findMatchingComponent(propSchema, versionKey, spec) + matchingComponentName := sg.findMatchingComponent(propSchema, spec) if matchingComponentName != "" { // Replace with $ref schema.Properties[propName] = &openapi3.SchemaRef{ @@ -648,7 +631,7 @@ func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( itemSchema := propSchema.Items.Value if itemSchema.Type != nil && len(*itemSchema.Type) > 0 && (*itemSchema.Type)[0] == "object" { // Inline object in array - try to find matching component - matchingComponentName := sg.findMatchingComponent(itemSchema, versionKey, spec) + matchingComponentName := sg.findMatchingComponent(itemSchema, spec) if matchingComponentName != "" { // Replace items with $ref propSchema.Items = &openapi3.SchemaRef{ @@ -665,9 +648,18 @@ func (sg *SchemaGenerator) replaceNestedSchemasWithRefsGeneric( // findMatchingComponent finds a component schema that matches the given inline schema structure // This is used to deduplicate inline schemas by finding their component equivalents +// +// LIMITATION: This uses a property-name-only heuristic which could produce false positives. +// If two different types have identical property names but different types (e.g., User.id: int +// vs Product.id: string), they could incorrectly match. In practice, this is rare because: +// - The TypeParser generates component schemas for named Go types, preserving type identity +// - Inline schemas only occur for anonymous structs or when parsing fails +// - Property name collisions across different business domains are uncommon +// +// If false positives occur, the workaround is to ensure all nested types are named Go structs +// rather than anonymous inline definitions. func (sg *SchemaGenerator) findMatchingComponent( inlineSchema *openapi3.Schema, - versionKey string, spec *openapi3.T, ) string { if inlineSchema == nil || inlineSchema.Properties == nil { @@ -709,87 +701,3 @@ func (sg *SchemaGenerator) findMatchingComponent( return "" } - -// rewriteRefsForVersion rewrites all $ref pointers in a schema to use versioned component names -// This is necessary because the TypeParser generates refs using HEAD type names (e.g., ProfileRequest), -// but versioned schemas need refs to versioned components (e.g., ProfileRequestV20240101) -func (sg *SchemaGenerator) rewriteRefsForVersion(schema *openapi3.Schema, version *epoch.Version) { - if schema == nil { - return - } - - // If HEAD version, no rewriting needed - if version.IsHead { - return - } - - // Rewrite refs in properties - if schema.Properties != nil { - for propName, propRef := range schema.Properties { - if propRef == nil { - continue - } - - // Rewrite $ref if it's a reference to a component - if propRef.Ref != "" && strings.HasPrefix(propRef.Ref, "#/components/schemas/") { - componentName := strings.TrimPrefix(propRef.Ref, "#/components/schemas/") - // Check if this component needs versioning (has migrations) - // For now, assume all refs need versioning - we can refine this later - versionedRef := fmt.Sprintf("#/components/schemas/%s", componentName) - schema.Properties[propName] = &openapi3.SchemaRef{ - Ref: versionedRef, - } - } - - // Recursively rewrite refs in nested schemas - if propRef.Value != nil { - sg.rewriteRefsForVersion(propRef.Value, version) - } - } - } - - // Rewrite refs in array items - if schema.Items != nil { - if schema.Items.Ref != "" && strings.HasPrefix(schema.Items.Ref, "#/components/schemas/") { - componentName := strings.TrimPrefix(schema.Items.Ref, "#/components/schemas/") - versionedRef := fmt.Sprintf("#/components/schemas/%s", componentName) - schema.Items = &openapi3.SchemaRef{ - Ref: versionedRef, - } - } - - // Recursively rewrite refs in item schema - if schema.Items != nil && schema.Items.Value != nil { - sg.rewriteRefsForVersion(schema.Items.Value, version) - } - } - - // Rewrite refs in allOf/oneOf/anyOf - for _, schemaRef := range schema.AllOf { - if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { - componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") - schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) - } - if schemaRef != nil && schemaRef.Value != nil { - sg.rewriteRefsForVersion(schemaRef.Value, version) - } - } - for _, schemaRef := range schema.OneOf { - if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { - componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") - schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) - } - if schemaRef != nil && schemaRef.Value != nil { - sg.rewriteRefsForVersion(schemaRef.Value, version) - } - } - for _, schemaRef := range schema.AnyOf { - if schemaRef != nil && schemaRef.Ref != "" && strings.HasPrefix(schemaRef.Ref, "#/components/schemas/") { - componentName := strings.TrimPrefix(schemaRef.Ref, "#/components/schemas/") - schemaRef.Ref = fmt.Sprintf("#/components/schemas/%s", componentName) - } - if schemaRef != nil && schemaRef.Value != nil { - sg.rewriteRefsForVersion(schemaRef.Value, version) - } - } -} diff --git a/epoch/openapi/version_transformer.go b/epoch/openapi/version_transformer.go index 3f5cf43..229ffc5 100644 --- a/epoch/openapi/version_transformer.go +++ b/epoch/openapi/version_transformer.go @@ -49,38 +49,9 @@ func (vt *VersionTransformer) TransformSchemaForVersion( } } - // Recursively transform nested schemas (objects and arrays) - if err := vt.transformNestedSchemas(schema, targetType, targetVersion, direction); err != nil { - return nil, fmt.Errorf("failed to transform nested schemas: %w", err) - } - return schema, nil } -// transformNestedSchemas recursively transforms nested object and array schemas -func (vt *VersionTransformer) transformNestedSchemas( - schema *openapi3.Schema, - parentType reflect.Type, - targetVersion *epoch.Version, - direction SchemaDirection, -) error { - if schema == nil || schema.Properties == nil { - return nil - } - - // Dereference pointer type if needed - if parentType.Kind() == reflect.Ptr { - parentType = parentType.Elem() - } - - // Only process struct types - if parentType.Kind() != reflect.Struct { - return nil - } - - return nil -} - // versionChange holds information about a single version change operation type versionChange struct { fromVersion *epoch.Version From 3ce2cf3f3c270dc0f0f2f44333f6de5b303952a2 Mon Sep 17 00:00:00 2001 From: Isaac Chung Date: Tue, 9 Dec 2025 14:44:01 -0800 Subject: [PATCH 3/3] test: add generator unit tests --- epoch/openapi/generator_test.go | 712 ++++++++++++++++++++++++++++++++ 1 file changed, 712 insertions(+) diff --git a/epoch/openapi/generator_test.go b/epoch/openapi/generator_test.go index bfd42d7..9f12519 100644 --- a/epoch/openapi/generator_test.go +++ b/epoch/openapi/generator_test.go @@ -35,6 +35,47 @@ type MissingRequest struct { Field2 string `json:"field2"` } +// Test types for nested type handling tests +type NestedMetadata struct { + Version string `json:"version"` + CreatedBy string `json:"created_by"` +} + +type NestedSubItem struct { + ID int `json:"id"` + Label string `json:"label"` +} + +type NestedItem struct { + ID int `json:"id"` + Name string `json:"name"` + SubItems []NestedSubItem `json:"sub_items"` + Details NestedMetadata `json:"details"` +} + +type NestedContainer struct { + Items []NestedItem `json:"items"` + Metadata NestedMetadata `json:"metadata"` +} + +// Self-referential type for circular dependency testing +type SelfReferential struct { + ID int `json:"id"` + Name string `json:"name"` + Child *SelfReferential `json:"child,omitempty"` +} + +// Circular dependency types +type CircularA struct { + ID int `json:"id"` + RefB *CircularB `json:"ref_b,omitempty"` +} + +type CircularB struct { + Name string `json:"name"` + RefA *CircularA `json:"ref_a,omitempty"` +} + var _ = Describe("SchemaGenerator", func() { Describe("Initialization", func() { It("should create a new SchemaGenerator", func() { @@ -610,4 +651,675 @@ var _ = Describe("SchemaGenerator", func() { }) }) }) + + Describe("Nested Type Handling", func() { + var ( + generator *SchemaGenerator + v1 *epoch.Version + versionBundle *epoch.VersionBundle + ) + + BeforeEach(func() { + v1, _ = epoch.NewDateVersion("2024-01-01") + versionBundle, _ = epoch.NewVersionBundle([]*epoch.Version{v1}) + registry := epoch.NewEndpointRegistry() + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + }) + + Context("Nested type collection", func() { + It("should collect nested objects from a struct", func() { + generator.collectNestedTypesForGeneration(reflect.TypeOf(NestedContainer{}), v1) + + // Check that metadata object was registered + versionKey := v1.String() + componentName := generator.getComponentNameForType(versionKey, reflect.TypeOf(NestedMetadata{})) + Expect(componentName).To(Equal("NestedMetadata")) + + // Check that it's in typesToGenerate + typesToGen := generator.typesToGenerate[versionKey] + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedMetadata{}))) + }) + + It("should collect nested arrays from a struct", func() { + generator.collectNestedTypesForGeneration(reflect.TypeOf(NestedContainer{}), v1) + + versionKey := v1.String() + // Check that NestedItem (array element type) was registered + componentName := generator.getComponentNameForType(versionKey, reflect.TypeOf(NestedItem{})) + Expect(componentName).To(Equal("NestedItem")) + + typesToGen := generator.typesToGenerate[versionKey] + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedItem{}))) + }) + + It("should recursively collect deeply nested types", func() { + generator.collectNestedTypesForGeneration(reflect.TypeOf(NestedContainer{}), v1) + + versionKey := v1.String() + typesToGen := generator.typesToGenerate[versionKey] + + // Should collect all levels: NestedItem, NestedMetadata, NestedSubItem + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedItem{}))) + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedMetadata{}))) + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedSubItem{}))) + }) + + It("should handle circular dependencies without infinite recursion", func() { + // Should complete without hanging + generator.collectNestedTypesForGeneration(reflect.TypeOf(SelfReferential{}), v1) + + versionKey := v1.String() + typesToGen := generator.typesToGenerate[versionKey] + + // SelfReferential should only appear once despite circular reference + count := 0 + for _, typ := range typesToGen { + if typ == reflect.TypeOf(SelfReferential{}) { + count++ + } + } + Expect(count).To(Equal(1)) + }) + + It("should handle mutual circular dependencies", func() { + generator.collectNestedTypesForGeneration(reflect.TypeOf(CircularA{}), v1) + + versionKey := v1.String() + typesToGen := generator.typesToGenerate[versionKey] + + // Both types should be collected exactly once + Expect(typesToGen).To(ContainElement(reflect.TypeOf(CircularB{}))) + + countA := 0 + countB := 0 + for _, typ := range typesToGen { + if typ == reflect.TypeOf(CircularA{}) { + countA++ + } + if typ == reflect.TypeOf(CircularB{}) { + countB++ + } + } + Expect(countA).To(Equal(1)) + Expect(countB).To(Equal(1)) + }) + + It("should handle pointer types by dereferencing", func() { + generator.collectNestedTypesForGeneration(reflect.TypeOf(&NestedContainer{}), v1) + + versionKey := v1.String() + typesToGen := generator.typesToGenerate[versionKey] + + // Should still collect nested types through pointer + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedMetadata{}))) + Expect(typesToGen).To(ContainElement(reflect.TypeOf(NestedItem{}))) + }) + }) + + Context("Nested type registration", func() { + It("should register types with correct component names", func() { + typ := reflect.TypeOf(NestedMetadata{}) + versionKey := v1.String() + componentName := "NestedMetadata" + + generator.registerNestedType(versionKey, typ, componentName) + + // Check registration + registeredName := generator.getComponentNameForType(versionKey, typ) + Expect(registeredName).To(Equal(componentName)) + }) + + It("should track types for generation", func() { + typ := reflect.TypeOf(NestedMetadata{}) + versionKey := v1.String() + + generator.registerNestedType(versionKey, typ, "NestedMetadata") + + // Check it's in typesToGenerate + typesToGen := generator.typesToGenerate[versionKey] + Expect(typesToGen).To(ContainElement(typ)) + }) + + It("should not duplicate types in typesToGenerate", func() { + typ := reflect.TypeOf(NestedMetadata{}) + versionKey := v1.String() + + // Register twice + generator.registerNestedType(versionKey, typ, "NestedMetadata") + generator.registerNestedType(versionKey, typ, "NestedMetadata") + + // Should only appear once + typesToGen := generator.typesToGenerate[versionKey] + count := 0 + for _, t := range typesToGen { + if t == typ { + count++ + } + } + Expect(count).To(Equal(1)) + }) + }) + }) + + Describe("Component Name Generation", func() { + var generator *SchemaGenerator + + BeforeEach(func() { + v1, _ := epoch.NewDateVersion("2024-01-01") + versionBundle, _ := epoch.NewVersionBundle([]*epoch.Version{v1}) + registry := epoch.NewEndpointRegistry() + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + }) + + DescribeTable("should generate correct component names", + func(typ reflect.Type, expectedName string) { + componentName := generator.generateComponentNameForType(typ) + Expect(componentName).To(Equal(expectedName)) + }, + Entry("named struct", reflect.TypeOf(NestedMetadata{}), "NestedMetadata"), + Entry("pointer to struct", reflect.TypeOf(&NestedMetadata{}), "NestedMetadata"), + Entry("nested struct", reflect.TypeOf(NestedItem{}), "NestedItem"), + Entry("slice of struct", reflect.TypeOf([]NestedItem{}), "NestedItemArray"), + Entry("slice of pointer", reflect.TypeOf([]*NestedItem{}), "NestedItemArray"), + ) + + It("should handle anonymous structs", func() { + anonymousType := reflect.TypeOf(struct { + Field string + }{}) + + componentName := generator.generateComponentNameForType(anonymousType) + // Should generate some name (exact name depends on implementation) + Expect(componentName).NotTo(BeEmpty()) + }) + }) + + Describe("Reference Replacement", func() { + var ( + generator *SchemaGenerator + spec *openapi3.T + ) + + BeforeEach(func() { + v1, _ := epoch.NewDateVersion("2024-01-01") + versionBundle, _ := epoch.NewVersionBundle([]*epoch.Version{v1}) + registry := epoch.NewEndpointRegistry() + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + // Create a spec with component schemas + spec = &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "NestedMetadata": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "version": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "created_by": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + }, + } + }) + + It("should replace inline object schemas with refs", func() { + // Create a schema with an inline object + parentSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "metadata": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "version": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "created_by": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + } + + err := generator.replaceNestedSchemasWithRefsGeneric(parentSchema, spec) + Expect(err).NotTo(HaveOccurred()) + + // Should replace inline object with $ref + metadataRef := parentSchema.Properties["metadata"] + Expect(metadataRef).NotTo(BeNil()) + Expect(metadataRef.Ref).To(Equal("#/components/schemas/NestedMetadata")) + }) + + It("should replace inline objects in array items with refs", func() { + // Create a schema with array of inline objects + parentSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "items": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "version": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "created_by": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }, + }, + }), + }, + } + + err := generator.replaceNestedSchemasWithRefsGeneric(parentSchema, spec) + Expect(err).NotTo(HaveOccurred()) + + // Should replace array items with $ref + itemsRef := parentSchema.Properties["items"] + Expect(itemsRef).NotTo(BeNil()) + Expect(itemsRef.Value.Items).NotTo(BeNil()) + Expect(itemsRef.Value.Items.Ref).To(Equal("#/components/schemas/NestedMetadata")) + }) + + It("should leave existing refs unchanged", func() { + // Create a schema that already has $ref + parentSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "metadata": openapi3.NewSchemaRef("#/components/schemas/NestedMetadata", nil), + }, + } + + err := generator.replaceNestedSchemasWithRefsGeneric(parentSchema, spec) + Expect(err).NotTo(HaveOccurred()) + + // Should remain unchanged + metadataRef := parentSchema.Properties["metadata"] + Expect(metadataRef.Ref).To(Equal("#/components/schemas/NestedMetadata")) + }) + + It("should not replace if no matching component found", func() { + // Create a schema with inline object that doesn't match any component + parentSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "unknown": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "unknown_field": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + } + + err := generator.replaceNestedSchemasWithRefsGeneric(parentSchema, spec) + Expect(err).NotTo(HaveOccurred()) + + // Should remain inline since no match + unknownRef := parentSchema.Properties["unknown"] + Expect(unknownRef.Ref).To(BeEmpty()) + Expect(unknownRef.Value).NotTo(BeNil()) + }) + }) + + Describe("findMatchingComponent", func() { + var ( + generator *SchemaGenerator + spec *openapi3.T + ) + + BeforeEach(func() { + v1, _ := epoch.NewDateVersion("2024-01-01") + versionBundle, _ := epoch.NewVersionBundle([]*epoch.Version{v1}) + registry := epoch.NewEndpointRegistry() + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + spec = &openapi3.T{ + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "NestedMetadata": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "version": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "created_by": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + }, + } + }) + + It("should find matching component by property names", func() { + inlineSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "version": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + "created_by": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + } + + componentName := generator.findMatchingComponent(inlineSchema, spec) + Expect(componentName).To(Equal("NestedMetadata")) + }) + + It("should return empty string if no match found", func() { + inlineSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "different_field": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + } + + componentName := generator.findMatchingComponent(inlineSchema, spec) + Expect(componentName).To(BeEmpty()) + }) + + It("should return empty string for nil schema", func() { + componentName := generator.findMatchingComponent(nil, spec) + Expect(componentName).To(BeEmpty()) + }) + + It("should return empty string for schema without properties", func() { + inlineSchema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + } + + componentName := generator.findMatchingComponent(inlineSchema, spec) + Expect(componentName).To(BeEmpty()) + }) + }) + + Describe("Multi-Pass Generation", func() { + var ( + generator *SchemaGenerator + registry *epoch.EndpointRegistry + v1 *epoch.Version + versionBundle *epoch.VersionBundle + ) + + BeforeEach(func() { + v1, _ = epoch.NewDateVersion("2024-01-01") + versionBundle, _ = epoch.NewVersionBundle([]*epoch.Version{v1}) + registry = epoch.NewEndpointRegistry() + }) + + It("should generate component schemas for nested objects", func() { + // Register an endpoint with nested types + registry.Register("GET", "/containers", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/containers", + ResponseType: reflect.TypeOf(NestedContainer{}), + }) + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + spec, err := generator.GenerateSpecForVersion(baseSpec, v1) + Expect(err).NotTo(HaveOccurred()) + + // Should generate component schemas for all nested types + Expect(spec.Components.Schemas["NestedMetadata"]).NotTo(BeNil()) + Expect(spec.Components.Schemas["NestedItem"]).NotTo(BeNil()) + Expect(spec.Components.Schemas["NestedSubItem"]).NotTo(BeNil()) + }) + + It("should use refs for nested objects in parent schema", func() { + registry.Register("GET", "/containers", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/containers", + ResponseType: reflect.TypeOf(NestedContainer{}), + }) + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + spec, err := generator.GenerateSpecForVersion(baseSpec, v1) + Expect(err).NotTo(HaveOccurred()) + + // Parent container should have refs to nested types + containerSchema := spec.Components.Schemas["NestedContainer"] + Expect(containerSchema).NotTo(BeNil()) + + // Metadata should be a $ref + metadataRef := containerSchema.Value.Properties["metadata"] + Expect(metadataRef).NotTo(BeNil()) + Expect(metadataRef.Ref).To(Equal("#/components/schemas/NestedMetadata")) + }) + + It("should use refs for array items", func() { + registry.Register("GET", "/containers", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/containers", + ResponseType: reflect.TypeOf(NestedContainer{}), + }) + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + spec, err := generator.GenerateSpecForVersion(baseSpec, v1) + Expect(err).NotTo(HaveOccurred()) + + containerSchema := spec.Components.Schemas["NestedContainer"] + Expect(containerSchema).NotTo(BeNil()) + + // Items array should use $ref for array items + itemsRef := containerSchema.Value.Properties["items"] + Expect(itemsRef).NotTo(BeNil()) + Expect(itemsRef.Value.Items).NotTo(BeNil()) + Expect(itemsRef.Value.Items.Ref).To(Equal("#/components/schemas/NestedItem")) + }) + + It("should handle deeply nested types (3+ levels)", func() { + registry.Register("GET", "/containers", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/containers", + ResponseType: reflect.TypeOf(NestedContainer{}), + }) + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + spec, err := generator.GenerateSpecForVersion(baseSpec, v1) + Expect(err).NotTo(HaveOccurred()) + + // All three levels should exist: Container -> Item -> SubItem + Expect(spec.Components.Schemas["NestedContainer"]).NotTo(BeNil()) + Expect(spec.Components.Schemas["NestedItem"]).NotTo(BeNil()) + Expect(spec.Components.Schemas["NestedSubItem"]).NotTo(BeNil()) + + // Verify refs at each level + itemSchema := spec.Components.Schemas["NestedItem"] + subItemsRef := itemSchema.Value.Properties["sub_items"] + Expect(subItemsRef.Value.Items.Ref).To(Equal("#/components/schemas/NestedSubItem")) + }) + + It("should preserve existing schemas when generating new nested ones", func() { + // Register endpoint + registry.Register("GET", "/containers", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/containers", + ResponseType: reflect.TypeOf(NestedContainer{}), + }) + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + + // Base spec has only NestedMetadata + baseSpec := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "Test", Version: "1.0"}, + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "ExistingSchema": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "field": openapi3.NewSchemaRef("", &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }), + }, + }), + }, + }, + } + + spec, err := generator.GenerateSpecForVersion(baseSpec, v1) + Expect(err).NotTo(HaveOccurred()) + + // Should preserve existing schema + Expect(spec.Components.Schemas["ExistingSchema"]).NotTo(BeNil()) + + // Should also generate new nested schemas + Expect(spec.Components.Schemas["NestedMetadata"]).NotTo(BeNil()) + Expect(spec.Components.Schemas["NestedItem"]).NotTo(BeNil()) + }) + }) + + Describe("Direction Detection", func() { + var ( + generator *SchemaGenerator + registry *epoch.EndpointRegistry + ) + + BeforeEach(func() { + v1, _ := epoch.NewDateVersion("2024-01-01") + versionBundle, _ := epoch.NewVersionBundle([]*epoch.Version{v1}) + registry = epoch.NewEndpointRegistry() + + config := SchemaGeneratorConfig{ + VersionBundle: versionBundle, + TypeRegistry: registry, + } + generator = NewSchemaGenerator(config) + }) + + It("should detect request types", func() { + registry.Register("POST", "/users", &epoch.EndpointDefinition{ + Method: "POST", + PathPattern: "/users", + RequestType: reflect.TypeOf(TestUserRequest{}), + }) + + direction := generator.getDirectionForType(reflect.TypeOf(TestUserRequest{})) + Expect(direction).To(Equal(SchemaDirectionRequest)) + }) + + It("should detect response types", func() { + registry.Register("GET", "/users", &epoch.EndpointDefinition{ + Method: "GET", + PathPattern: "/users", + ResponseType: reflect.TypeOf(TestUserResponse{}), + }) + + direction := generator.getDirectionForType(reflect.TypeOf(TestUserResponse{})) + Expect(direction).To(Equal(SchemaDirectionResponse)) + }) + + It("should default to response for unknown types", func() { + // Type not registered in any endpoint + direction := generator.getDirectionForType(reflect.TypeOf(NestedMetadata{})) + Expect(direction).To(Equal(SchemaDirectionResponse)) + }) + + It("should handle types used in both request and response", func() { + // Register same type as both request and response + registry.Register("POST", "/users", &epoch.EndpointDefinition{ + Method: "POST", + PathPattern: "/users", + RequestType: reflect.TypeOf(TestUserRequest{}), + ResponseType: reflect.TypeOf(TestUserRequest{}), + }) + + // Should return request since it checks request first + direction := generator.getDirectionForType(reflect.TypeOf(TestUserRequest{})) + Expect(direction).To(Equal(SchemaDirectionRequest)) + }) + }) })