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
4 changes: 4 additions & 0 deletions .changes/unreleased/fix-graphql-cid-link-serialization.yaml
Original file line number Diff line number Diff line change
@@ -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
180 changes: 169 additions & 11 deletions internal/graphql/schema/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions internal/graphql/types/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
66 changes: 65 additions & 1 deletion internal/graphql/types/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading