Skip to content

Commit 24db422

Browse files
Merge pull request #356 from trakrf/feat/tra-742-bb43-f1-f2-openapi-hygiene
feat(api): TRA-742 — BB43 F1+F2 openapi hygiene (contact URL + asset/location field-doc symmetry)
2 parents 07b5edb + e1a1e32 commit 24db422

7 files changed

Lines changed: 37 additions & 127 deletions

File tree

backend/internal/handlers/swaggerspec/swaggerspec.go

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@
99
package swaggerspec
1010

1111
import (
12-
"bytes"
1312
_ "embed"
1413
"net/http"
15-
"os"
16-
"sync"
1714
)
1815

1916
//go:embed openapi.internal.json
@@ -28,44 +25,6 @@ var publicJSON []byte
2825
//go:embed openapi.public.yaml
2926
var publicYAML []byte
3027

31-
// canonicalContactURL is the production-canonical info.contact.url emitted
32-
// by apispec/postprocess.go. The committed spec hard-codes it because the
33-
// build produces one artifact for every environment; environment-awareness
34-
// lives at serve time below (TRA-717 / BB34 F4).
35-
const canonicalContactURL = "https://app.trakrf.id/api"
36-
37-
// previewContactURL is the equivalent on the preview deployment. The two
38-
// servers entries in info.servers[] already advertise both endpoints; this
39-
// keeps the contact URL aligned with whichever environment the running
40-
// backend is serving.
41-
const previewContactURL = "https://app.preview.trakrf.id/api"
42-
43-
// publicJSONServed / publicYAMLServed are the byte slices returned by the
44-
// public spec handlers. They equal the embedded bytes in production and on
45-
// non-preview environments; on preview they carry the preview contact URL.
46-
// The substitution runs once at init() and the slices are immutable
47-
// thereafter.
48-
var (
49-
publicSpecOnce sync.Once
50-
publicJSONServed []byte
51-
publicYAMLServed []byte
52-
)
53-
54-
func resolvePublicSpec() {
55-
publicJSONServed = publicJSON
56-
publicYAMLServed = publicYAML
57-
if os.Getenv("APP_ENV") != "preview" {
58-
return
59-
}
60-
// Targeted substitution: canonicalContactURL ("https://app.trakrf.id/api",
61-
// trailing /api) appears only on info.contact.url. The servers[] entries
62-
// use the bare hostnames without /api, so this does not affect them.
63-
publicJSONServed = bytes.ReplaceAll(publicJSON,
64-
[]byte(canonicalContactURL), []byte(previewContactURL))
65-
publicYAMLServed = bytes.ReplaceAll(publicYAML,
66-
[]byte(canonicalContactURL), []byte(previewContactURL))
67-
}
68-
6928
// ServeJSON writes the embedded internal OpenAPI spec as JSON.
7029
func ServeJSON(w http.ResponseWriter, _ *http.Request) {
7130
w.Header().Set("Content-Type", "application/json")
@@ -80,14 +39,12 @@ func ServeYAML(w http.ResponseWriter, _ *http.Request) {
8039

8140
// ServePublicJSON writes the embedded public OpenAPI spec as JSON.
8241
func ServePublicJSON(w http.ResponseWriter, _ *http.Request) {
83-
publicSpecOnce.Do(resolvePublicSpec)
8442
w.Header().Set("Content-Type", "application/json")
85-
_, _ = w.Write(publicJSONServed)
43+
_, _ = w.Write(publicJSON)
8644
}
8745

8846
// ServePublicYAML writes the embedded public OpenAPI spec as YAML.
8947
func ServePublicYAML(w http.ResponseWriter, _ *http.Request) {
90-
publicSpecOnce.Do(resolvePublicSpec)
9148
w.Header().Set("Content-Type", "application/yaml")
92-
_, _ = w.Write(publicYAMLServed)
49+
_, _ = w.Write(publicYAML)
9350
}

backend/internal/handlers/swaggerspec/swaggerspec_test.go

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,11 @@ import (
44
"encoding/json"
55
"net/http"
66
"net/http/httptest"
7-
"strings"
8-
"sync"
97
"testing"
108

11-
"github.com/stretchr/testify/assert"
129
"github.com/stretchr/testify/require"
1310
)
1411

15-
// resetPublicSpecResolution lets per-test environment changes take effect.
16-
// Production code resolves the spec lazily via sync.Once; tests need to
17-
// re-run the resolver after flipping APP_ENV.
18-
func resetPublicSpecResolution(t *testing.T) {
19-
t.Helper()
20-
publicSpecOnce = sync.Once{}
21-
publicJSONServed = nil
22-
publicYAMLServed = nil
23-
t.Cleanup(func() {
24-
publicSpecOnce = sync.Once{}
25-
publicJSONServed = nil
26-
publicYAMLServed = nil
27-
})
28-
}
29-
3012
func TestServePublicJSON(t *testing.T) {
3113
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
3214
rec := httptest.NewRecorder()
@@ -52,50 +34,3 @@ func TestServePublicYAML(t *testing.T) {
5234
require.NotEmpty(t, rec.Body.Bytes(), "body must be non-empty")
5335
require.Contains(t, rec.Body.String(), "openapi:", "body should contain YAML key 'openapi:'")
5436
}
55-
56-
// TRA-717 / BB34 F4: info.contact.url is environment-aware at serve
57-
// time. The committed spec hard-codes the production canonical URL;
58-
// when APP_ENV=preview the backend swaps it to the preview equivalent
59-
// so a spec pulled from app.preview.trakrf.id/api/v1/openapi.* reads
60-
// preview, matching the env-aware servers[] block.
61-
func TestPublicSpec_ContactURLPreviewSubstitution(t *testing.T) {
62-
resetPublicSpecResolution(t)
63-
t.Setenv("APP_ENV", "preview")
64-
65-
for _, c := range []struct {
66-
name string
67-
handler http.HandlerFunc
68-
}{
69-
{"json", ServePublicJSON},
70-
{"yaml", ServePublicYAML},
71-
} {
72-
c := c
73-
t.Run(c.name, func(t *testing.T) {
74-
resetPublicSpecResolution(t)
75-
t.Setenv("APP_ENV", "preview")
76-
rec := httptest.NewRecorder()
77-
c.handler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
78-
body := rec.Body.String()
79-
assert.Contains(t, body, previewContactURL,
80-
"preview spec must carry the preview contact URL")
81-
// The production marketing-app contact URL (with /api suffix) must
82-
// be absent. Bare-hostname servers[] entries stay untouched.
83-
assert.False(t, strings.Contains(body, canonicalContactURL),
84-
"preview spec must not carry the production contact URL")
85-
})
86-
}
87-
}
88-
89-
// On production (and any non-preview environment), the served spec
90-
// equals the committed bytes verbatim — no substitution.
91-
func TestPublicSpec_ContactURLProductionUnchanged(t *testing.T) {
92-
resetPublicSpecResolution(t)
93-
t.Setenv("APP_ENV", "production")
94-
95-
rec := httptest.NewRecorder()
96-
ServePublicJSON(rec, httptest.NewRequest(http.MethodGet, "/", nil))
97-
assert.Contains(t, rec.Body.String(), canonicalContactURL,
98-
"non-preview spec must carry the production contact URL")
99-
assert.False(t, strings.Contains(rec.Body.String(), previewContactURL),
100-
"non-preview spec must not carry the preview contact URL")
101-
}

backend/internal/models/asset/asset.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ type Asset struct {
3333
// (GET /assets/{id}, GET /assets/{id}/history, GET /reports/asset-locations).
3434
// See PublicRejectCreateFields in the assets handler.
3535
type CreateAssetRequest struct {
36-
OrgID int `json:"-" swaggerignore:"true"`
37-
ExternalKey string `json:"external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern"`
38-
Name string `json:"name" validate:"required,min=1,max=255,no_control_chars"`
39-
Description *string `json:"description,omitempty" validate:"omitempty,min=1,max=1024,no_control_chars"`
36+
OrgID int `json:"-" swaggerignore:"true"`
37+
// external_key is optional. Omit to receive a server-assigned key in the
38+
// format ASSET-NNNN (per-organization sequence). When supplied, must
39+
// satisfy the external_key_pattern.
40+
ExternalKey string `json:"external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern" example:"forklift-3"`
41+
Name string `json:"name" validate:"required,min=1,max=255,no_control_chars" example:"Forklift 3"`
42+
Description *string `json:"description,omitempty" validate:"omitempty,min=1,max=1024,no_control_chars" example:"Main warehouse forklift"`
4043
ValidFrom *shared.FlexibleDate `json:"valid_from,omitempty" swaggertype:"string" example:"2025-01-01T00:00:00Z"`
4144
ValidTo *shared.FlexibleDate `json:"valid_to,omitempty" swaggertype:"string" example:"2026-01-01T00:00:00Z"`
4245
Metadata map[string]any `json:"metadata,omitempty"`
@@ -101,8 +104,8 @@ var PublicReadOnlyFields = []string{"id", "created_at", "updated_at", "deleted_a
101104
// never written by PATCH; the only valid use is to echo the current value
102105
// back. The handler nils them out after the echo check passes.
103106
type UpdateAssetRequest struct {
104-
Name *string `json:"name" validate:"omitempty,min=1,max=255,no_control_chars"`
105-
Description *string `json:"description" validate:"omitempty,min=1,max=1024,no_control_chars"`
107+
Name *string `json:"name" validate:"omitempty,min=1,max=255,no_control_chars" example:"Forklift 3"`
108+
Description *string `json:"description" validate:"omitempty,min=1,max=1024,no_control_chars" example:"Updated description"`
106109
ValidFrom *shared.FlexibleDate `json:"valid_from,omitempty" swaggertype:"string" example:"2025-01-01T00:00:00Z"`
107110
ValidTo *shared.FlexibleDate `json:"valid_to,omitempty" swaggertype:"string" example:"2026-01-01T00:00:00Z"`
108111
// TRA-699 natural-key echo fields. Decoded so the handler can compare
@@ -116,7 +119,7 @@ type UpdateAssetRequest struct {
116119
ClearDescription bool `json:"-" swaggerignore:"true"`
117120
ClearValidTo bool `json:"-" swaggerignore:"true"`
118121
Metadata *map[string]any `json:"metadata"`
119-
IsActive *bool `json:"is_active"`
122+
IsActive *bool `json:"is_active" example:"true"`
120123
}
121124

122125
// PublicRejectPatchFields names the JSON keys that PATCH /api/v1/assets/{id}

backend/internal/tools/apispec/postprocess.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,11 @@ func postprocessPublic(doc *openapi3.T) error {
142142
doc.Info.Contact.Email = "support@trakrf.id"
143143
}
144144
if doc.Info.Contact.URL == "" {
145-
// Production canonical. The build emits a single artifact for every
146-
// environment, so the committed spec must pin one value. The backend
147-
// swaps this to the preview equivalent at serve time when
148-
// APP_ENV=preview — see swaggerspec.resolvePublicSpec (TRA-717 / BB34
149-
// F4). Keep the bare-hostname servers[] entries below untouched so
150-
// the substitution remains targeted to contact.url alone.
151-
doc.Info.Contact.URL = "https://app.trakrf.id/api"
145+
// Per OpenAPI 3.0, info.contact.url is "the URL pointing to the
146+
// contact information". Generator-default Help / Contact links
147+
// land integrators on docs rather than the running API host;
148+
// the bare-hostname servers[] entries below cover env routing.
149+
doc.Info.Contact.URL = "https://docs.trakrf.id/"
152150
}
153151
doc.Servers = openapi3.Servers{
154152
{

backend/internal/tools/apispec/postprocess_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ func TestPostprocess_SetsPublicInfoAndServers(t *testing.T) {
303303
assert.Equal(t, "1.0.0", doc.Info.Version,
304304
"info.version must be semver per Zalando must-use-semantic-versioning (TRA-672)")
305305
require.NotNil(t, doc.Info.Contact, "info.contact must be present per Zalando must-have-info-contact-url (TRA-672)")
306-
assert.Equal(t, "https://app.trakrf.id/api", doc.Info.Contact.URL)
306+
assert.Equal(t, "https://docs.trakrf.id/", doc.Info.Contact.URL)
307307
assert.Equal(t, "support@trakrf.id", doc.Info.Contact.Email)
308308
require.Len(t, doc.Servers, 2)
309309

docs/api/openapi.public.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,13 +461,16 @@
461461
"additionalProperties": false,
462462
"properties": {
463463
"description": {
464+
"example": "Main warehouse forklift",
464465
"maxLength": 1024,
465466
"minLength": 1,
466467
"nullable": true,
467468
"pattern": "^[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]*$",
468469
"type": "string"
469470
},
470471
"external_key": {
472+
"description": "external_key is optional. Omit to receive a server-assigned key in the\nformat ASSET-NNNN (per-organization sequence). When supplied, must\nsatisfy the external_key_pattern.",
473+
"example": "forklift-3",
471474
"maxLength": 255,
472475
"minLength": 1,
473476
"pattern": "^[A-Za-z0-9-]+$",
@@ -482,6 +485,7 @@
482485
"type": "object"
483486
},
484487
"name": {
488+
"example": "Forklift 3",
485489
"maxLength": 255,
486490
"minLength": 1,
487491
"pattern": "^[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]*$",
@@ -1184,20 +1188,23 @@
11841188
"UpdateAssetRequest": {
11851189
"properties": {
11861190
"description": {
1191+
"example": "Updated description",
11871192
"maxLength": 1024,
11881193
"minLength": 1,
11891194
"nullable": true,
11901195
"pattern": "^[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]*$",
11911196
"type": "string"
11921197
},
11931198
"is_active": {
1199+
"example": true,
11941200
"type": "boolean"
11951201
},
11961202
"metadata": {
11971203
"additionalProperties": {},
11981204
"type": "object"
11991205
},
12001206
"name": {
1207+
"example": "Forklift 3",
12011208
"maxLength": 255,
12021209
"minLength": 1,
12031210
"pattern": "^[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]*$",
@@ -1303,7 +1310,7 @@
13031310
"contact": {
13041311
"email": "support@trakrf.id",
13051312
"name": "TrakRF Support",
1306-
"url": "https://app.trakrf.id/api"
1313+
"url": "https://docs.trakrf.id/"
13071314
},
13081315
"description": "TrakRF public REST API. See /api for the customer-facing reference.\n\nSpec available as YAML (/api/openapi.yaml) and JSON (/api/openapi.json).\n\nHTTP method coverage (HEAD, OPTIONS, 405 / Allow): /api/http-method-coverage\n\nSurrogate ID width: declared `format: int64` on the wire so SDK regeneration does not break when the ID namespace eventually outgrows int32. Service-side ID generation stays within int32 (2^31-1) during v1; values above that bound are rejected with 400 validation_error / `too_large`. The wider wire type is a long-horizon contract, not a claim that current values exceed int32.\n\nNullable field interpretation: OpenAPI 3.0's `nullable: true` keyword is interpreted differently across codegen targets. Verified-working: `openapi-typescript@7.x` emits `string | null` correctly, and the `openapi-generator-cli` python target emits `Optional[StrictStr]` correctly — both round-trip CRUD against null-bearing responses unmodified. Known-broken: `datamodel-codegen@0.57.0` emits `nullable: true` read-shape fields as non-Optional required fields, so Pydantic validation fails on every nullable field that is actually null. Integrators using `datamodel-codegen` should either switch to the `openapi-generator-cli` python target or apply `--use-annotated --use-union-operator` with a custom post-processing pass.",
13091316
"license": {

docs/api/openapi.public.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,12 +339,18 @@ components:
339339
additionalProperties: false
340340
properties:
341341
description:
342+
example: Main warehouse forklift
342343
maxLength: 1024
343344
minLength: 1
344345
nullable: true
345346
pattern: ^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$
346347
type: string
347348
external_key:
349+
description: |-
350+
external_key is optional. Omit to receive a server-assigned key in the
351+
format ASSET-NNNN (per-organization sequence). When supplied, must
352+
satisfy the external_key_pattern.
353+
example: forklift-3
348354
maxLength: 255
349355
minLength: 1
350356
pattern: ^[A-Za-z0-9-]+$
@@ -356,6 +362,7 @@ components:
356362
additionalProperties: {}
357363
type: object
358364
name:
365+
example: Forklift 3
359366
maxLength: 255
360367
minLength: 1
361368
pattern: ^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$
@@ -880,17 +887,20 @@ components:
880887
UpdateAssetRequest:
881888
properties:
882889
description:
890+
example: Updated description
883891
maxLength: 1024
884892
minLength: 1
885893
nullable: true
886894
pattern: ^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$
887895
type: string
888896
is_active:
897+
example: true
889898
type: boolean
890899
metadata:
891900
additionalProperties: {}
892901
type: object
893902
name:
903+
example: Forklift 3
894904
maxLength: 255
895905
minLength: 1
896906
pattern: ^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$
@@ -970,7 +980,7 @@ info:
970980
contact:
971981
email: support@trakrf.id
972982
name: TrakRF Support
973-
url: https://app.trakrf.id/api
983+
url: https://docs.trakrf.id/
974984
description: |-
975985
TrakRF public REST API. See /api for the customer-facing reference.
976986

0 commit comments

Comments
 (0)