diff --git a/.changes/unreleased/fix-graphql-cid-link-serialization.yaml b/.changes/unreleased/fix-graphql-cid-link-serialization.yaml new file mode 100644 index 0000000..6f759b8 --- /dev/null +++ b/.changes/unreleased/fix-graphql-cid-link-serialization.yaml @@ -0,0 +1,4 @@ +kind: fixed +body: Fix typed GraphQL responses for ATProto CID links so blob refs and cid-link fields return plain CID strings instead of Go map-formatted values. +custom: + Affects: user diff --git a/internal/graphql/schema/builder_test.go b/internal/graphql/schema/builder_test.go index a40ab4b..749c97d 100644 --- a/internal/graphql/schema/builder_test.go +++ b/internal/graphql/schema/builder_test.go @@ -883,28 +883,35 @@ func TestEmptyConnection(t *testing.T) { func setupCoercionTestDB(t *testing.T, recordJSON string) context.Context { t.Helper() + return setupSchemaRecordTestDB(t, &repositories.Record{ + URI: "at://did:plc:test/org.hypercerts.claim.activity/rkey1", + CID: "bafyreiabc123", + DID: "did:plc:test", + Collection: "org.hypercerts.claim.activity", + JSON: recordJSON, + RKey: "rkey1", + }) +} + +// setupSchemaRecordTestDB creates an in-memory SQLite database with migrations applied, +// inserts a single record, and returns a context that carries the repositories. +func setupSchemaRecordTestDB(t *testing.T, rec *repositories.Record) context.Context { + t.Helper() + exec, err := sqlite.NewExecutor("sqlite::memory:") if err != nil { - t.Fatalf("setupCoercionTestDB: failed to create SQLite executor: %v", err) + t.Fatalf("setupSchemaRecordTestDB: failed to create SQLite executor: %v", err) } t.Cleanup(func() { exec.Close() }) ctx := context.Background() if err := migrations.Run(ctx, exec); err != nil { - t.Fatalf("setupCoercionTestDB: failed to run migrations: %v", err) + t.Fatalf("setupSchemaRecordTestDB: failed to run migrations: %v", err) } records := repositories.NewRecordsRepository(exec) - rec := &repositories.Record{ - URI: "at://did:plc:test/org.hypercerts.claim.activity/rkey1", - CID: "bafyreiabc123", - DID: "did:plc:test", - Collection: "org.hypercerts.claim.activity", - JSON: recordJSON, - RKey: "rkey1", - } if err := records.BatchInsert(ctx, []*repositories.Record{rec}); err != nil { - t.Fatalf("setupCoercionTestDB: failed to insert record: %v", err) + t.Fatalf("setupSchemaRecordTestDB: failed to insert record: %v", err) } repos := &resolver.Repositories{ @@ -913,6 +920,157 @@ func setupCoercionTestDB(t *testing.T, recordJSON string) context.Context { return resolver.WithRepositories(ctx, repos) } +func buildCIDLinkRegressionSchema(t *testing.T) *graphql.Schema { + t.Helper() + + registry := lexicon.NewRegistry() + registry.Register(&lexicon.Lexicon{ + ID: "com.example.cidlink.record", + Defs: lexicon.Defs{ + Main: &lexicon.RecordDef{ + Type: "record", + Key: "tid", + Properties: []lexicon.PropertyEntry{ + {Name: "image", Property: lexicon.Property{Type: lexicon.TypeBlob}}, + {Name: "root", Property: lexicon.Property{Type: lexicon.TypeCIDLink}}, + }, + }, + }, + }) + + schema, err := NewBuilder(registry).Build() + if err != nil { + t.Fatalf("buildCIDLinkRegressionSchema: failed to build schema: %v", err) + } + return schema +} + +func TestCIDLinkSerializationInBuiltSchema(t *testing.T) { + const cid = "bafkreidlp6sdj6jkroakvmbntpy2clsj77foijheae5byt3iwz7d2k542a" + const recordURI = "at://did:plc:test/com.example.cidlink.record/rkey1" + + ctx := setupSchemaRecordTestDB(t, &repositories.Record{ + URI: recordURI, + CID: "bafyreirecordcid", + DID: "did:plc:test", + Collection: "com.example.cidlink.record", + JSON: `{"image":{"ref":{"$link":"` + cid + `"},"mimeType":"image/png"},"root":{"$link":"` + cid + `"}}`, + RKey: "rkey1", + }) + schema := buildCIDLinkRegressionSchema(t) + + query := `{ + comExampleCidlinkRecord(first: 10) { + edges { + node { + image { ref mimeType } + root + } + } + } + comExampleCidlinkRecordByUri(uri: "at://did:plc:test/com.example.cidlink.record/rkey1") { + image { ref } + root + } + records(collection: "com.example.cidlink.record", first: 10) { + edges { + node { value } + } + } + }` + + result := graphql.Do(graphql.Params{ + Schema: *schema, + RequestString: query, + Context: ctx, + }) + if len(result.Errors) > 0 { + t.Fatalf("TestCIDLinkSerializationInBuiltSchema: unexpected GraphQL errors: %v", result.Errors) + } + + data, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatalf("result.Data is %T, want map[string]interface{}", result.Data) + } + + collectionNode := firstConnectionNode(t, data["comExampleCidlinkRecord"], "comExampleCidlinkRecord") + assertCIDLinkFields(t, collectionNode, cid, "collection query") + + byURIRecord, ok := data["comExampleCidlinkRecordByUri"].(map[string]interface{}) + if !ok { + t.Fatalf("comExampleCidlinkRecordByUri is %T, want map[string]interface{}", data["comExampleCidlinkRecordByUri"]) + } + assertCIDLinkFields(t, byURIRecord, cid, "ByUri query") + + rawNode := firstConnectionNode(t, data["records"], "records") + value, ok := rawNode["value"].(map[string]interface{}) + if !ok { + t.Fatalf("records node value is %T, want map[string]interface{}", rawNode["value"]) + } + assertRawCIDLinkShape(t, value, cid) +} + +func firstConnectionNode(t *testing.T, connectionValue interface{}, fieldName string) map[string]interface{} { + t.Helper() + + conn, ok := connectionValue.(map[string]interface{}) + if !ok { + t.Fatalf("%s is %T, want map[string]interface{}", fieldName, connectionValue) + } + edges, ok := conn["edges"].([]interface{}) + if !ok || len(edges) == 0 { + t.Fatalf("%s edges = %v, want at least one edge", fieldName, conn["edges"]) + } + edge, ok := edges[0].(map[string]interface{}) + if !ok { + t.Fatalf("%s edge[0] is %T, want map[string]interface{}", fieldName, edges[0]) + } + node, ok := edge["node"].(map[string]interface{}) + if !ok { + t.Fatalf("%s node is %T, want map[string]interface{}", fieldName, edge["node"]) + } + return node +} + +func assertCIDLinkFields(t *testing.T, record map[string]interface{}, cid, path string) { + t.Helper() + + image, ok := record["image"].(map[string]interface{}) + if !ok { + t.Fatalf("%s image is %T, want map[string]interface{}", path, record["image"]) + } + if got := image["ref"]; got != cid { + t.Fatalf("%s image.ref = %v, want %q", path, got, cid) + } + if got := record["root"]; got != cid { + t.Fatalf("%s root = %v, want %q", path, got, cid) + } +} + +func assertRawCIDLinkShape(t *testing.T, value map[string]interface{}, cid string) { + t.Helper() + + root, ok := value["root"].(map[string]interface{}) + if !ok { + t.Fatalf("raw root is %T, want map[string]interface{}", value["root"]) + } + if got := root["$link"]; got != cid { + t.Fatalf("raw root $link = %v, want %q", got, cid) + } + + image, ok := value["image"].(map[string]interface{}) + if !ok { + t.Fatalf("raw image is %T, want map[string]interface{}", value["image"]) + } + ref, ok := image["ref"].(map[string]interface{}) + if !ok { + t.Fatalf("raw image.ref is %T, want map[string]interface{}", image["ref"]) + } + if got := ref["$link"]; got != cid { + t.Fatalf("raw image.ref $link = %v, want %q", got, cid) + } +} + // buildActivitySchema builds a GraphQL schema from the org.hypercerts.claim.activity lexicon. func buildActivitySchema(t *testing.T) *graphql.Schema { t.Helper() diff --git a/internal/graphql/types/mapper.go b/internal/graphql/types/mapper.go index 1de0062..854f944 100644 --- a/internal/graphql/types/mapper.go +++ b/internal/graphql/types/mapper.go @@ -2,6 +2,8 @@ package types //nolint:revive // package name is descriptive within graphql context import ( + "fmt" + "github.com/graphql-go/graphql" "github.com/graphql-go/graphql/language/ast" @@ -76,6 +78,19 @@ func (m *Mapper) initBlobType() { "ref": &graphql.Field{ Type: graphql.NewNonNull(graphql.String), Description: "CID reference to the blob", + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + blob, ok := p.Source.(map[string]any) + if !ok { + return nil, fmt.Errorf("blob.ref expected source to be a map[string]any, got %T", p.Source) + } + + ref, ok := extractCIDLinkString(blob["ref"]) + if !ok { + return nil, fmt.Errorf("blob.ref expected ref to be a CID string or {\"$link\": string} object") + } + + return ref, nil + }, }, "mimeType": &graphql.Field{ Type: graphql.NewNonNull(graphql.String), @@ -89,6 +104,31 @@ func (m *Mapper) initBlobType() { }) } +// extractCIDLinkString returns the plain CID string from values encoded as either +// an already-normalized CID string or an AT Protocol CID link object. +func extractCIDLinkString(value any) (string, bool) { + switch typedValue := value.(type) { + case string: + return typedValue, true + case map[string]any: + if len(typedValue) != 1 { + return "", false + } + + link, ok := typedValue["$link"].(string) + return link, ok + case map[string]string: + if len(typedValue) != 1 { + return "", false + } + + link, ok := typedValue["$link"] + return link, ok + default: + return "", false + } +} + // MapPrimitiveType maps a lexicon primitive type to a GraphQL type. // This handles basic types without refs. func (m *Mapper) MapPrimitiveType(lexiconType, format string) graphql.Output { diff --git a/internal/graphql/types/object.go b/internal/graphql/types/object.go index 4edc904..ebbf1fd 100644 --- a/internal/graphql/types/object.go +++ b/internal/graphql/types/object.go @@ -3,6 +3,7 @@ package types //nolint:revive // package name is descriptive within graphql cont import ( "fmt" "log/slog" + "reflect" "github.com/graphql-go/graphql" @@ -174,10 +175,73 @@ func (b *ObjectBuilder) buildField(contextLexiconID, name string, prop *lexicon. fieldType = graphql.NewNonNull(fieldType) } - return &graphql.Field{ + field := &graphql.Field{ Type: fieldType, Description: prop.Description, } + + if prop.Type == lexicon.TypeCIDLink { + field.Resolve = resolveCIDLinkField(name) + } + if prop.Type == lexicon.TypeArray && prop.Items != nil && prop.Items.Type == lexicon.TypeCIDLink { + field.Resolve = resolveCIDLinkArrayField(name) + } + + return field +} + +func resolveCIDLinkField(name string) graphql.FieldResolveFn { + return func(p graphql.ResolveParams) (interface{}, error) { + value := sourceMapValue(p.Source, name) + if value == nil { + return nil, nil + } + + cid, ok := extractCIDLinkString(value) + if !ok { + return nil, nil + } + + return cid, nil + } +} + +func resolveCIDLinkArrayField(name string) graphql.FieldResolveFn { + return func(p graphql.ResolveParams) (interface{}, error) { + value := sourceMapValue(p.Source, name) + if value == nil { + return nil, nil + } + + items := reflect.ValueOf(value) + if items.Kind() != reflect.Slice && items.Kind() != reflect.Array { + return nil, nil + } + + cids := make([]any, items.Len()) + for i := 0; i < items.Len(); i++ { + item := items.Index(i).Interface() + if item == nil { + continue + } + + cid, ok := extractCIDLinkString(item) + if ok { + cids[i] = cid + } + } + + return cids, nil + } +} + +func sourceMapValue(source any, name string) any { + sourceMap, ok := source.(map[string]any) + if !ok { + return nil + } + + return sourceMap[name] } // resolveRefType resolves a ref to a GraphQL type. diff --git a/internal/graphql/types/types_test.go b/internal/graphql/types/types_test.go index 96f78c3..1e458fc 100644 --- a/internal/graphql/types/types_test.go +++ b/internal/graphql/types/types_test.go @@ -1,10 +1,12 @@ package types //nolint:revive // package name is descriptive within graphql context import ( + "strings" "testing" "time" "github.com/graphql-go/graphql" + "github.com/graphql-go/graphql/gqlerrors" "github.com/GainForest/hyperindex/internal/lexicon" ) @@ -83,6 +85,97 @@ func TestMapper_ObjectTypeCache(t *testing.T) { } } +func TestMapper_BlobRefResolver(t *testing.T) { + tests := []struct { + name string + ref any + wantRef string + }{ + { + name: "link object", + ref: map[string]any{"$link": "bafkreihyperindexcid"}, + wantRef: "bafkreihyperindexcid", + }, + { + name: "string", + ref: "bafkreialreadystring", + wantRef: "bafkreialreadystring", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotData, gotErrors := executeBlobRefQuery(t, tt.ref) + if len(gotErrors) > 0 { + t.Fatalf("Blob.ref query returned errors: %v", gotErrors) + } + + blob, ok := gotData["blob"].(map[string]any) + if !ok { + t.Fatalf("blob result = %T, want map[string]any", gotData["blob"]) + } + + if gotRef := blob["ref"]; gotRef != tt.wantRef { + t.Fatalf("Blob.ref = %v, want %q", gotRef, tt.wantRef) + } + }) + } +} + +func TestMapper_BlobRefResolverMalformedRefDoesNotStringifyMap(t *testing.T) { + gotData, gotErrors := executeBlobRefQuery(t, map[string]any{"$link": 123}) + if len(gotErrors) == 0 { + blob, ok := gotData["blob"].(map[string]any) + if ok { + if gotRef, ok := blob["ref"].(string); ok && strings.Contains(gotRef, "map[$link:") { + t.Fatalf("malformed Blob.ref returned stringified map: %q", gotRef) + } + } + + return + } + + for _, err := range gotErrors { + if strings.Contains(err.Message, "map[$link:") { + t.Fatalf("malformed Blob.ref error stringified map: %q", err.Message) + } + } +} + +func executeBlobRefQuery(t *testing.T, ref any) (map[string]any, []gqlerrors.FormattedError) { + t.Helper() + + mapper := NewMapper() + schema, err := graphql.NewSchema(graphql.SchemaConfig{ + Query: graphql.NewObject(graphql.ObjectConfig{ + Name: "Query", + Fields: graphql.Fields{ + "blob": &graphql.Field{ + Type: mapper.BlobType, + Resolve: func(_ graphql.ResolveParams) (interface{}, error) { + return map[string]any{ + "ref": ref, + "mimeType": "image/png", + "size": 123, + }, nil + }, + }, + }, + }), + }) + if err != nil { + t.Fatalf("failed to build test schema: %v", err) + } + + result := graphql.Do(graphql.Params{ + Schema: schema, + RequestString: "{ blob { ref } }", + }) + + data, _ := result.Data.(map[string]any) + return data, result.Errors +} + func TestMapper_UnionTypeCache(t *testing.T) { m := NewMapper() @@ -312,6 +405,174 @@ func TestObjectBuilder_BuildRecordType_SkipsReservedFields(t *testing.T) { } } +func TestObjectBuilder_GeneratedCIDLinkFieldResolver(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + { + name: "link object", + value: map[string]any{"$link": "bafkreigeneratedlink"}, + want: "bafkreigeneratedlink", + }, + { + name: "string", + value: "bafkreialreadygeneratedstring", + want: "bafkreialreadygeneratedstring", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotData, gotErrors := executeGeneratedCIDLinkQuery(t, tt.value) + if len(gotErrors) > 0 { + t.Fatalf("generated cid-link query returned errors: %v", gotErrors) + } + + record, ok := gotData["record"].(map[string]any) + if !ok { + t.Fatalf("record result = %T, want map[string]any", gotData["record"]) + } + + if gotRef := record["root"]; gotRef != tt.want { + t.Fatalf("record.root = %v, want %q", gotRef, tt.want) + } + if gotRef, ok := record["root"].(string); ok && strings.Contains(gotRef, "map[$link:") { + t.Fatalf("generated cid-link returned stringified map: %q", gotRef) + } + }) + } +} + +func TestObjectBuilder_GeneratedCIDLinkArrayFieldResolver(t *testing.T) { + gotData, gotErrors := executeGeneratedCIDLinkArrayQuery(t, []any{ + "bafkreifirstcid", + map[string]any{"$link": "bafkreisecondcid"}, + }) + if len(gotErrors) > 0 { + t.Fatalf("generated cid-link array query returned errors: %v", gotErrors) + } + + record, ok := gotData["record"].(map[string]any) + if !ok { + t.Fatalf("record result = %T, want map[string]any", gotData["record"]) + } + + gotRefs, ok := record["refs"].([]any) + if !ok { + t.Fatalf("record.refs = %T, want []any", record["refs"]) + } + + wantRefs := []string{"bafkreifirstcid", "bafkreisecondcid"} + if len(gotRefs) != len(wantRefs) { + t.Fatalf("record.refs length = %d, want %d", len(gotRefs), len(wantRefs)) + } + for i, wantRef := range wantRefs { + if gotRefs[i] != wantRef { + t.Fatalf("record.refs[%d] = %v, want %q", i, gotRefs[i], wantRef) + } + if gotRef, ok := gotRefs[i].(string); ok && strings.Contains(gotRef, "map[$link:") { + t.Fatalf("generated cid-link array returned stringified map: %q", gotRef) + } + } +} + +func executeGeneratedCIDLinkQuery(t *testing.T, value any) (map[string]any, []gqlerrors.FormattedError) { + t.Helper() + + recordType := generatedCIDLinkRecordType(t, lexicon.Property{ + Type: lexicon.TypeCIDLink, + }) + + return executeGeneratedRecordQuery(t, recordType, map[string]any{ + "uri": "at://did:example:alice/com.example.test.record/1", + "cid": "bafkreirecordcid", + "did": "did:example:alice", + "rkey": "1", + "root": value, + }, "{ record { root } }") +} + +func executeGeneratedCIDLinkArrayQuery(t *testing.T, value any) (map[string]any, []gqlerrors.FormattedError) { + t.Helper() + + recordType := generatedCIDLinkRecordType(t, lexicon.Property{ + Type: lexicon.TypeArray, + Items: &lexicon.ArrayItems{ + Type: lexicon.TypeCIDLink, + }, + }) + + return executeGeneratedRecordQuery(t, recordType, map[string]any{ + "uri": "at://did:example:alice/com.example.test.record/1", + "cid": "bafkreirecordcid", + "did": "did:example:alice", + "rkey": "1", + "refs": value, + }, "{ record { refs } }") +} + +func generatedCIDLinkRecordType(t *testing.T, property lexicon.Property) *graphql.Object { + t.Helper() + + registry := lexicon.NewRegistry() + mapper := NewMapper() + builder := NewObjectBuilder(mapper, registry) + + fieldName := "root" + if property.Type == lexicon.TypeArray { + fieldName = "refs" + } + + recordDef := &lexicon.RecordDef{ + Type: "record", + Key: "tid", + Properties: []lexicon.PropertyEntry{ + { + Name: fieldName, + Property: property, + }, + }, + } + + return builder.BuildRecordType("com.example.test.cidlink", recordDef) +} + +func executeGeneratedRecordQuery( + t *testing.T, + recordType *graphql.Object, + record map[string]any, + query string, +) (map[string]any, []gqlerrors.FormattedError) { + t.Helper() + + schema, err := graphql.NewSchema(graphql.SchemaConfig{ + Query: graphql.NewObject(graphql.ObjectConfig{ + Name: "Query", + Fields: graphql.Fields{ + "record": &graphql.Field{ + Type: recordType, + Resolve: func(_ graphql.ResolveParams) (interface{}, error) { + return record, nil + }, + }, + }, + }), + }) + if err != nil { + t.Fatalf("failed to build test schema: %v", err) + } + + result := graphql.Do(graphql.Params{ + Schema: schema, + RequestString: query, + }) + + data, _ := result.Data.(map[string]any) + return data, result.Errors +} + func TestObjectBuilder_BuildObjectType(t *testing.T) { registry := lexicon.NewRegistry() mapper := NewMapper()