diff --git a/references/reference.go b/references/reference.go index 9fde75a..f1082df 100644 --- a/references/reference.go +++ b/references/reference.go @@ -4,11 +4,16 @@ import ( "errors" "fmt" "net/url" + "regexp" "strings" "github.com/speakeasy-api/openapi/jsonpointer" ) +// componentNameRegex matches valid OpenAPI component names according to the spec. +// Component names must match: ^[a-zA-Z0-9\.\-_]+$ +var componentNameRegex = regexp.MustCompile(`^[a-zA-Z0-9.\-_]+$`) + type Reference string var _ fmt.Stringer = (*Reference)(nil) @@ -65,6 +70,48 @@ func (r Reference) Validate() error { if err := jp.Validate(); err != nil { return fmt.Errorf("invalid reference JSON pointer: %w", err) } + + // Validate OpenAPI component references have valid component names + if err := r.validateComponentReference(jp); err != nil { + return err + } + } + + return nil +} + +// validateComponentReference validates that component references have valid component names. +// According to the OpenAPI spec, component names must match: ^[a-zA-Z0-9\.\-_]+$ +func (r Reference) validateComponentReference(jp jsonpointer.JSONPointer) error { + jpStr := string(jp) + + // Check if this is a component reference + if !strings.HasPrefix(jpStr, "/components/") { + return nil + } + + // Split the pointer into parts + parts := strings.Split(strings.TrimPrefix(jpStr, "/"), "/") + + // parts[0] is "components", parts[1] is the component type (schemas, parameters, etc.) + // parts[2] should be the component name + if len(parts) < 3 { + // Reference ends at component type (e.g., #/components/schemas/) + return errors.New("invalid reference: component name cannot be empty") + } + + componentName := parts[2] + if componentName == "" { + return errors.New("invalid reference: component name cannot be empty") + } + + // Unescape the component name before validating (JSON pointer escaping: ~0 = ~, ~1 = /) + unescapedName := strings.ReplaceAll(componentName, "~1", "/") + unescapedName = strings.ReplaceAll(unescapedName, "~0", "~") + + // Validate component name matches the required pattern + if !componentNameRegex.MatchString(unescapedName) { + return fmt.Errorf("invalid reference: component name %q must match pattern ^[a-zA-Z0-9.\\-_]+$", unescapedName) } return nil diff --git a/references/reference_test.go b/references/reference_test.go index 3407cf0..1a9a4f4 100644 --- a/references/reference_test.go +++ b/references/reference_test.go @@ -46,10 +46,6 @@ func TestReference_Validate_Success(t *testing.T) { name: "JSON pointer with array index", ref: "#/paths/~1users~1{id}/get/responses/200/content/application~1json/examples/0", }, - { - name: "JSON pointer with escaped characters", - ref: "#/components/schemas/User~1Profile/properties/user~0name", - }, { name: "file URI", ref: "file:///path/to/schema.yaml#/User", @@ -103,6 +99,41 @@ func TestReference_Validate_Error(t *testing.T) { ref: "https://example .com/api.yaml#/User", expectError: "invalid reference URI", }, + { + name: "empty component name - schemas", + ref: "#/components/schemas/", + expectError: "component name cannot be empty", + }, + { + name: "empty component name - parameters", + ref: "#/components/parameters/", + expectError: "component name cannot be empty", + }, + { + name: "empty component name - responses", + ref: "#/components/responses/", + expectError: "component name cannot be empty", + }, + { + name: "missing component name - schemas", + ref: "#/components/schemas", + expectError: "component name cannot be empty", + }, + { + name: "component name with space", + ref: "#/components/schemas/User Schema", + expectError: "must match pattern", + }, + { + name: "component name with special characters", + ref: "#/components/schemas/User@Schema", + expectError: "must match pattern", + }, + { + name: "component name starting with slash", + ref: "#/components/schemas//UserSchema", + expectError: "component name cannot be empty", + }, } for _, tt := range tests {