Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions references/reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions references/reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
Loading