Skip to content

Commit b6628ee

Browse files
authored
Fix Column.color schema drift: structured object, not string (#71)
* Fix Column.color schema drift: now a structured Color object The live API returns column color as a {name, value} object, but the Smithy spec typed Column.color as a plain String. Typed-SDK consumers crashed with "json: cannot unmarshal object into Go struct field" when calling Columns().Get/List. Fixed by changing Column.color in spec/fizzy.smithy from String to the existing Color struct, then regenerating openapi.json and all five SDK surfaces (Go, TypeScript, Ruby, Kotlin, Swift). Added tests in each language that decode a column response with an object-shaped color, so this drift cannot regress silently. Create/Update column inputs remain a string (the color value, e.g. "var(--color-card-1)"), matching the upstream API docs. * Address Column.color review feedback
1 parent e568ff6 commit b6628ee

54 files changed

Lines changed: 450 additions & 142 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

go/oapi-codegen.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ output-options:
1313
skip-fmt: false
1414
skip-prune: false
1515
prefer-skip-optional-pointer: true
16+
overlay:
17+
path: oapi-overlay.yaml

go/oapi-overlay.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
overlay: 1.0.0
2+
info:
3+
title: Fizzy Go codegen overrides
4+
version: 0.0.0
5+
actions:
6+
- target: $.components.schemas.Column.properties.color['$ref']
7+
description: Replace Column.color ref with allOf so oapi-codegen honors field extensions.
8+
remove: true
9+
- target: $.components.schemas.Column.properties.color
10+
description: Preserve optional pointer semantics for Column.color.
11+
update:
12+
allOf:
13+
- $ref: '#/components/schemas/Color'
14+
x-go-type-skip-optional-pointer: false
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package fizzy
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/basecamp/fizzy-sdk/go/pkg/generated"
12+
)
13+
14+
// TestGetColumnDecodesColorObject pins the Column.color schema as a structured
15+
// object (Color{name, value}) rather than a string. The live API returns
16+
// "color": {"name": "Blue", "value": "var(--color-card-1)"}, which previously
17+
// failed to unmarshal because the SDK typed it as a string.
18+
func TestGetColumnDecodesColorObject(t *testing.T) {
19+
body := `{
20+
"id": "abc123",
21+
"name": "In Progress",
22+
"color": {"name": "Blue", "value": "var(--color-card-1)"},
23+
"created_at": "2026-04-30T00:00:00Z",
24+
"cards_url": "https://example.com/cards"
25+
}`
26+
27+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+
w.Header().Set("Content-Type", "application/json")
29+
w.WriteHeader(http.StatusOK)
30+
_, _ = w.Write([]byte(body))
31+
}))
32+
defer server.Close()
33+
34+
client := NewClient(&Config{BaseURL: server.URL}, &StaticTokenProvider{Token: "test"})
35+
col, _, err := client.ForAccount("999").Columns().Get(context.Background(), "board1", "abc123")
36+
if err != nil {
37+
t.Fatalf("Get: %v", err)
38+
}
39+
if col.Color == nil {
40+
t.Fatal("Color is nil, want decoded Color object")
41+
}
42+
if col.Color.Name != "Blue" {
43+
t.Errorf("Color.Name = %q, want Blue", col.Color.Name)
44+
}
45+
if col.Color.Value != "var(--color-card-1)" {
46+
t.Errorf("Color.Value = %q, want var(--color-card-1)", col.Color.Value)
47+
}
48+
}
49+
50+
func TestColumnColorOptionalPointerOmitsAbsentColor(t *testing.T) {
51+
var col generated.Column
52+
if err := json.Unmarshal([]byte(`{"id":"abc123","name":"No Color","created_at":"2026-04-30T00:00:00Z"}`), &col); err != nil {
53+
t.Fatalf("Unmarshal: %v", err)
54+
}
55+
if col.Color != nil {
56+
t.Fatalf("Color = %+v, want nil for absent optional field", col.Color)
57+
}
58+
encoded, err := json.Marshal(col)
59+
if err != nil {
60+
t.Fatalf("Marshal: %v", err)
61+
}
62+
if strings.Contains(string(encoded), `"color"`) {
63+
t.Fatalf("encoded Column contains absent color: %s", encoded)
64+
}
65+
}
66+
67+
// TestListColumnsDecodesColorObject mirrors the Get test for the list endpoint.
68+
func TestListColumnsDecodesColorObject(t *testing.T) {
69+
body := `[
70+
{"id": "c1", "name": "Triage", "color": {"name": "Gray", "value": "var(--color-card-1)"}, "created_at": "2026-04-30T00:00:00Z"},
71+
{"id": "c2", "name": "Done", "color": {"name": "Lime", "value": "var(--color-card-4)"}, "created_at": "2026-04-30T00:00:00Z"}
72+
]`
73+
74+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
w.Header().Set("Content-Type", "application/json")
76+
w.WriteHeader(http.StatusOK)
77+
_, _ = w.Write([]byte(body))
78+
}))
79+
defer server.Close()
80+
81+
client := NewClient(&Config{BaseURL: server.URL}, &StaticTokenProvider{Token: "test"})
82+
cols, _, err := client.ForAccount("999").Columns().List(context.Background(), "board1")
83+
if err != nil {
84+
t.Fatalf("List: %v", err)
85+
}
86+
if len(cols) != 2 {
87+
t.Fatalf("len(cols) = %d, want 2", len(cols))
88+
}
89+
if cols[0].Color == nil || cols[1].Color == nil {
90+
t.Fatalf("colors = %+v / %+v, want decoded Color objects", cols[0].Color, cols[1].Color)
91+
}
92+
if cols[0].Color.Name != "Gray" || cols[1].Color.Value != "var(--color-card-4)" {
93+
t.Errorf("unexpected colors: %+v / %+v", cols[0].Color, cols[1].Color)
94+
}
95+
}

go/pkg/generated/types.gen.go

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kotlin/generator/src/main/kotlin/com/basecamp/fizzy/generator/ModelEmitter.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,6 @@ class ModelEmitter(private val api: OpenApiParser) {
2626
?.toSet()
2727
?: emptySet()
2828

29-
val lines = mutableListOf<String>()
30-
lines += "package com.basecamp.fizzy.generated.models"
31-
lines += ""
32-
lines += "import kotlinx.serialization.SerialName"
33-
lines += "import kotlinx.serialization.Serializable"
34-
lines += "import kotlinx.serialization.json.JsonElement"
35-
lines += "import kotlinx.serialization.json.JsonObject"
36-
lines += ""
37-
lines += "/**"
38-
lines += " * $typeName entity from the Fizzy API."
39-
lines += " *"
40-
lines += " * @generated from OpenAPI spec -- do not edit directly"
41-
lines += " */"
42-
lines += "@Serializable"
43-
lines += "data class $typeName("
44-
4529
// Required fields first (no defaults), then optional fields (with defaults)
4630
val requiredProps = mutableListOf<Pair<String, JsonObject>>()
4731
val optionalProps = mutableListOf<Pair<String, JsonObject>>()
@@ -54,11 +38,17 @@ class ModelEmitter(private val api: OpenApiParser) {
5438
}
5539

5640
val propLines = mutableListOf<String>()
41+
var usesSerialName = false
42+
var usesJsonElement = false
43+
var usesJsonObject = false
5744
for ((propName, propObj) in requiredProps + optionalProps) {
5845
val isRequired = propName in requiredFields
5946
val kotlinType = resolvePropertyType(propObj, isRequired)
6047
val camelName = propName.snakeToCamelCase()
6148
val needsSerialName = camelName != propName
49+
usesSerialName = usesSerialName || needsSerialName
50+
usesJsonElement = usesJsonElement || kotlinType.contains("JsonElement")
51+
usesJsonObject = usesJsonObject || kotlinType.contains("JsonObject")
6252

6353
val propLine = buildString {
6454
if (needsSerialName) {
@@ -74,6 +64,21 @@ class ModelEmitter(private val api: OpenApiParser) {
7464
propLines += propLine
7565
}
7666

67+
val lines = mutableListOf<String>()
68+
lines += "package com.basecamp.fizzy.generated.models"
69+
lines += ""
70+
if (usesSerialName) lines += "import kotlinx.serialization.SerialName"
71+
lines += "import kotlinx.serialization.Serializable"
72+
if (usesJsonElement) lines += "import kotlinx.serialization.json.JsonElement"
73+
if (usesJsonObject) lines += "import kotlinx.serialization.json.JsonObject"
74+
lines += ""
75+
lines += "/**"
76+
lines += " * $typeName entity from the Fizzy API."
77+
lines += " *"
78+
lines += " * @generated from OpenAPI spec -- do not edit directly"
79+
lines += " */"
80+
lines += "@Serializable"
81+
lines += "data class $typeName("
7782
lines += propLines.joinToString(",\n")
7883
lines += ")"
7984

kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/models/Account.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.basecamp.fizzy.generated.models
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5-
import kotlinx.serialization.json.JsonElement
6-
import kotlinx.serialization.json.JsonObject
75

86
/**
97
* Account entity from the Fizzy API.

kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/models/Activity.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.basecamp.fizzy.generated.models
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5-
import kotlinx.serialization.json.JsonElement
6-
import kotlinx.serialization.json.JsonObject
75

86
/**
97
* Activity entity from the Fizzy API.

kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/models/ActivityEventable.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.basecamp.fizzy.generated.models
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5-
import kotlinx.serialization.json.JsonElement
6-
import kotlinx.serialization.json.JsonObject
75

86
/**
97
* ActivityEventable entity from the Fizzy API.

kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/models/ActivityParticulars.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.basecamp.fizzy.generated.models
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5-
import kotlinx.serialization.json.JsonElement
6-
import kotlinx.serialization.json.JsonObject
75

86
/**
97
* ActivityParticulars entity from the Fizzy API.

kotlin/sdk/src/commonMain/kotlin/com/basecamp/fizzy/generated/models/Board.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.basecamp.fizzy.generated.models
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5-
import kotlinx.serialization.json.JsonElement
6-
import kotlinx.serialization.json.JsonObject
75

86
/**
97
* Board entity from the Fizzy API.

0 commit comments

Comments
 (0)