From 7e291939071fcf98f1b066b1dc20482fbf83d0e9 Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Tue, 23 Jun 2026 10:56:15 +0100 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: 55c1f9065f0a411cec138ac7c44bd08869edc094 --- internal/routes/openapi.go | 13 ++++++- internal/routes/openapi_test.go | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/routes/openapi.go b/internal/routes/openapi.go index 9d74248..ee7f3bf 100644 --- a/internal/routes/openapi.go +++ b/internal/routes/openapi.go @@ -329,7 +329,18 @@ func resolveSchemaValue(value any, components *openapiComponents, seen map[strin if ref, ok := v["$ref"].(string); ok { return resolveSchemaRef(ref, v, components, seen) } - for _, key := range []string{"properties", "items", "additionalProperties", "allOf", "anyOf", "oneOf", "not"} { + // properties is a map of name->schema, not a schema itself; resolve each + // member so $refs nested under object properties are followed too. + if props, ok := v["properties"].(map[string]any); ok { + for name, propSchema := range props { + resolved, err := resolveSchemaValue(propSchema, components, seen) + if err != nil { + return nil, err + } + props[name] = resolved + } + } + for _, key := range []string{"items", "additionalProperties", "allOf", "anyOf", "oneOf", "not"} { resolved, ok, err := resolveSchemaField(v[key], components, seen) if err != nil { return nil, err diff --git a/internal/routes/openapi_test.go b/internal/routes/openapi_test.go index 1b1bae3..f19ea1c 100644 --- a/internal/routes/openapi_test.go +++ b/internal/routes/openapi_test.go @@ -487,3 +487,72 @@ func TestLoadRegistryFromOpenAPI_ResolvesRequestBodyRefs(t *testing.T) { t.Error("expected resolved schema to include 'name' property") } } + +// TestLoadRegistryFromOpenAPI_ResolvesNestedPropertyRefs covers a $ref sitting +// under a component's properties (e.g. a discriminated request whose payload is +// nested one level down). The resolver must follow it, not leave a bare $ref. +func TestLoadRegistryFromOpenAPI_ResolvesNestedPropertyRefs(t *testing.T) { + spec := `{ + "openapi": "3.1.0", + "info": {"title": "Test", "version": "1.0.0"}, + "components": { + "schemas": { + "UpdateThing": { + "type": "object", + "properties": {"detail": {"$ref": "#/components/schemas/Detail"}} + }, + "Detail": { + "discriminator": {"propertyName": "update_type"}, + "oneOf": [ + {"type": "object", "properties": {"update_type": {"type": "string", "enum": ["main"]}, "name": {"type": "string"}}}, + {"type": "object", "properties": {"update_type": {"type": "string", "enum": ["state"]}, "state": {"type": "string"}}} + ] + } + } + }, + "paths": { + "/v1/environments/{environment_id}/things/{id}": { + "put": { + "summary": "Update thing", + "parameters": [ + {"name": "environment_id", "in": "path", "required": true, "schema": {"type": "string"}}, + {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateThing"}}} + } + } + } + } + }` + + reg, err := LoadRegistryFromOpenAPI([]byte(spec)) + if err != nil { + t.Fatal(err) + } + route := reg.FindRoute("things", "update") + if route == nil { + t.Fatal("things.update not found") + } + + var obj map[string]any + if err := json.Unmarshal(route.RequestBodySchema, &obj); err != nil { + t.Fatal(err) + } + props, _ := obj["properties"].(map[string]any) + detail, ok := props["detail"].(map[string]any) + if !ok { + t.Fatal("expected 'detail' property in resolved body") + } + if _, isRef := detail["$ref"]; isRef { + t.Fatal("nested 'detail' $ref was not resolved") + } + branches, ok := detail["oneOf"].([]any) + if !ok || len(branches) != 2 { + t.Fatalf("expected resolved 'detail' to carry a 2-branch oneOf, got %v", detail["oneOf"]) + } + if detail["discriminator"] == nil { + t.Error("expected resolved 'detail' to keep its discriminator") + } +}