Skip to content

Commit 8526ead

Browse files
leo-aa88cursoragent
andcommitted
feat: unify JSON success envelope as {"data":...} (#120)
- Add pkg/httpresp OK/Created generic helpers - Health, books, login, and register use the same envelope - Document in README; extend Swagger models; fix E2E token path BREAKING CHANGE: login and register success JSON moved under data; clients must read data.token and data.message respectively. Closes #120 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e92a2ce commit 8526ead

13 files changed

Lines changed: 255 additions & 36 deletions

File tree

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,17 @@ http://localhost:8001/swagger/index.html
191191
- `POST /api/v1/register`: Register a new user.
192192
- `GET /swagger/*`: Swagger UI (no `X-API-Key`).
193193

194+
### JSON responses
195+
196+
Successful `/api/v1` JSON responses use a single envelope: **`{"data": ...}`** (implemented in `pkg/httpresp`). Examples: `GET /api/v1/` returns `{"data":"ok"}`; book list and book CRUD return the resource or collection inside `data`; `POST /api/v1/login` returns `{"data":{"token":"<jwt>"}}`; `POST /api/v1/register` returns `{"data":{"message":"Registration successful"}}`.
197+
198+
Error responses use **`{"error":"..."}`** (`pkg/httperr`). RFC 7807-style problem details are not used yet.
199+
194200
### Authentication
195201

196202
Under **`/api/v1`**, every route **except** `GET /api/v1/` (health) requires the **`X-API-Key`** header matching **`API_SECRET_KEY`** (service-to-service gate).
197203

198-
Book **mutations** (`POST`, `PUT`, `PATCH`, and `DELETE` on `/api/v1/books` and `/api/v1/books/:id`) also require a valid user JWT in `Authorization: Bearer <token>` (obtain via `/api/v1/register` and `/api/v1/login`). Book **reads** (`GET` list and `GET` by id) require the API key only.
204+
Book **mutations** (`POST`, `PUT`, `PATCH`, and `DELETE` on `/api/v1/books` and `/api/v1/books/:id`) also require a valid user JWT in `Authorization: Bearer <token>` (obtain a token from `POST /api/v1/login`; the JWT string is at **`data.token`** in the JSON body). Book **reads** (`GET` list and `GET` by id) require the API key only.
199205

200206
```bash
201207
curl -H "X-API-Key: <YOUR_API_KEY>" http://localhost:8001/api/v1/books
@@ -269,7 +275,7 @@ pytest -v tests/e2e.py
269275

270276
The tests will perform the following actions:
271277

272-
1. Register a new user and obtain a JWT token.
278+
1. Register a new user, log in, and obtain a JWT from the login response (`data.token`).
273279
2. Create a new book in the system.
274280
3. Retrieve all books and verify the created book is present.
275281
4. Retrieve a specific book by its ID.

docs/docs.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ const docTemplate = `{
3939
"summary": "ping example",
4040
"responses": {
4141
"200": {
42-
"description": "OK",
42+
"description": "Health payload in standard envelope",
4343
"schema": {
44-
"type": "string"
44+
"$ref": "#/definitions/models.HealthOKBody"
4545
}
4646
}
4747
}
@@ -442,9 +442,9 @@ const docTemplate = `{
442442
],
443443
"responses": {
444444
"200": {
445-
"description": "JWT Token",
445+
"description": "JWT in standard envelope",
446446
"schema": {
447-
"type": "string"
447+
"$ref": "#/definitions/models.LoginAPIResponse"
448448
}
449449
},
450450
"400": {
@@ -499,9 +499,9 @@ const docTemplate = `{
499499
],
500500
"responses": {
501501
"201": {
502-
"description": "Successfully registered",
502+
"description": "Registration message in standard envelope",
503503
"schema": {
504-
"type": "string"
504+
"$ref": "#/definitions/models.RegisterAPIResponse"
505505
}
506506
},
507507
"400": {
@@ -565,6 +565,30 @@ const docTemplate = `{
565565
}
566566
}
567567
},
568+
"models.HealthOKBody": {
569+
"type": "object",
570+
"properties": {
571+
"data": {
572+
"type": "string"
573+
}
574+
}
575+
},
576+
"models.LoginAPIResponse": {
577+
"type": "object",
578+
"properties": {
579+
"data": {
580+
"$ref": "#/definitions/models.LoginTokenBody"
581+
}
582+
}
583+
},
584+
"models.LoginTokenBody": {
585+
"type": "object",
586+
"properties": {
587+
"token": {
588+
"type": "string"
589+
}
590+
}
591+
},
568592
"models.LoginUser": {
569593
"type": "object",
570594
"required": [
@@ -591,6 +615,22 @@ const docTemplate = `{
591615
}
592616
}
593617
},
618+
"models.RegisterAPIResponse": {
619+
"type": "object",
620+
"properties": {
621+
"data": {
622+
"$ref": "#/definitions/models.RegisterSuccessBody"
623+
}
624+
}
625+
},
626+
"models.RegisterSuccessBody": {
627+
"type": "object",
628+
"properties": {
629+
"message": {
630+
"type": "string"
631+
}
632+
}
633+
},
594634
"models.ReplaceBook": {
595635
"type": "object",
596636
"required": [

docs/swagger.json

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@
3333
"summary": "ping example",
3434
"responses": {
3535
"200": {
36-
"description": "OK",
36+
"description": "Health payload in standard envelope",
3737
"schema": {
38-
"type": "string"
38+
"$ref": "#/definitions/models.HealthOKBody"
3939
}
4040
}
4141
}
@@ -436,9 +436,9 @@
436436
],
437437
"responses": {
438438
"200": {
439-
"description": "JWT Token",
439+
"description": "JWT in standard envelope",
440440
"schema": {
441-
"type": "string"
441+
"$ref": "#/definitions/models.LoginAPIResponse"
442442
}
443443
},
444444
"400": {
@@ -493,9 +493,9 @@
493493
],
494494
"responses": {
495495
"201": {
496-
"description": "Successfully registered",
496+
"description": "Registration message in standard envelope",
497497
"schema": {
498-
"type": "string"
498+
"$ref": "#/definitions/models.RegisterAPIResponse"
499499
}
500500
},
501501
"400": {
@@ -559,6 +559,30 @@
559559
}
560560
}
561561
},
562+
"models.HealthOKBody": {
563+
"type": "object",
564+
"properties": {
565+
"data": {
566+
"type": "string"
567+
}
568+
}
569+
},
570+
"models.LoginAPIResponse": {
571+
"type": "object",
572+
"properties": {
573+
"data": {
574+
"$ref": "#/definitions/models.LoginTokenBody"
575+
}
576+
}
577+
},
578+
"models.LoginTokenBody": {
579+
"type": "object",
580+
"properties": {
581+
"token": {
582+
"type": "string"
583+
}
584+
}
585+
},
562586
"models.LoginUser": {
563587
"type": "object",
564588
"required": [
@@ -585,6 +609,22 @@
585609
}
586610
}
587611
},
612+
"models.RegisterAPIResponse": {
613+
"type": "object",
614+
"properties": {
615+
"data": {
616+
"$ref": "#/definitions/models.RegisterSuccessBody"
617+
}
618+
}
619+
},
620+
"models.RegisterSuccessBody": {
621+
"type": "object",
622+
"properties": {
623+
"message": {
624+
"type": "string"
625+
}
626+
}
627+
},
588628
"models.ReplaceBook": {
589629
"type": "object",
590630
"required": [

docs/swagger.yaml

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ definitions:
2525
- author
2626
- title
2727
type: object
28+
models.HealthOKBody:
29+
properties:
30+
data:
31+
type: string
32+
type: object
33+
models.LoginAPIResponse:
34+
properties:
35+
data:
36+
$ref: '#/definitions/models.LoginTokenBody'
37+
type: object
38+
models.LoginTokenBody:
39+
properties:
40+
token:
41+
type: string
42+
type: object
2843
models.LoginUser:
2944
properties:
3045
password:
@@ -42,6 +57,16 @@ definitions:
4257
title:
4358
type: string
4459
type: object
60+
models.RegisterAPIResponse:
61+
properties:
62+
data:
63+
$ref: '#/definitions/models.RegisterSuccessBody'
64+
type: object
65+
models.RegisterSuccessBody:
66+
properties:
67+
message:
68+
type: string
69+
type: object
4570
models.ReplaceBook:
4671
properties:
4772
author:
@@ -79,9 +104,9 @@ paths:
79104
- application/json
80105
responses:
81106
"200":
82-
description: OK
107+
description: Health payload in standard envelope
83108
schema:
84-
type: string
109+
$ref: '#/definitions/models.HealthOKBody'
85110
summary: ping example
86111
tags:
87112
- example
@@ -338,9 +363,9 @@ paths:
338363
- application/json
339364
responses:
340365
"200":
341-
description: JWT Token
366+
description: JWT in standard envelope
342367
schema:
343-
type: string
368+
$ref: '#/definitions/models.LoginAPIResponse'
344369
"400":
345370
description: Bad Request
346371
schema:
@@ -374,9 +399,9 @@ paths:
374399
- application/json
375400
responses:
376401
"201":
377-
description: Successfully registered
402+
description: Registration message in standard envelope
378403
schema:
379-
type: string
404+
$ref: '#/definitions/models.RegisterAPIResponse'
380405
"400":
381406
description: Bad Request
382407
schema:

pkg/api/books.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"golang-rest-api-template/pkg/cache"
1010
"golang-rest-api-template/pkg/httperr"
11+
"golang-rest-api-template/pkg/httpresp"
1112
"golang-rest-api-template/pkg/middleware"
1213
"golang-rest-api-template/pkg/models"
1314
"golang-rest-api-template/pkg/repository"
@@ -105,10 +106,10 @@ func contextUserID(c *gin.Context) (uint, bool) {
105106
// @Tags example
106107
// @Accept json
107108
// @Produce json
108-
// @Success 200 {string} ok
109+
// @Success 200 {object} models.HealthOKBody "Health payload in standard envelope"
109110
// @Router / [get]
110111
func (h *bookHandler) Healthcheck(c *gin.Context) {
111-
c.JSON(http.StatusOK, "ok")
112+
httpresp.OK(c, "ok")
112113
}
113114

114115
// FindBooks godoc
@@ -144,7 +145,7 @@ func (h *bookHandler) FindBooks(c *gin.Context) {
144145
}
145146
return
146147
}
147-
c.JSON(http.StatusOK, gin.H{"data": books})
148+
httpresp.OK(c, books)
148149
}
149150

150151
// CreateBook godoc
@@ -177,7 +178,7 @@ func (h *bookHandler) CreateBook(c *gin.Context) {
177178
httperr.Write(c, http.StatusInternalServerError, "Failed to create book")
178179
return
179180
}
180-
c.JSON(http.StatusCreated, gin.H{"data": book})
181+
httpresp.Created(c, book)
181182
}
182183

183184
// FindBook godoc
@@ -204,7 +205,7 @@ func (h *bookHandler) FindBook(c *gin.Context) {
204205
httperr.Write(c, http.StatusInternalServerError, "Failed to load book")
205206
return
206207
}
207-
c.JSON(http.StatusOK, gin.H{"data": book})
208+
httpresp.OK(c, book)
208209
}
209210

210211
// UpdateBook godoc
@@ -252,7 +253,7 @@ func (h *bookHandler) UpdateBook(c *gin.Context) {
252253
httperr.Write(c, http.StatusInternalServerError, "Failed to update book")
253254
return
254255
}
255-
c.JSON(http.StatusOK, gin.H{"data": book})
256+
httpresp.OK(c, book)
256257
}
257258

258259
// PatchBook godoc
@@ -304,7 +305,7 @@ func (h *bookHandler) PatchBook(c *gin.Context) {
304305
httperr.Write(c, http.StatusInternalServerError, "Failed to update book")
305306
return
306307
}
307-
c.JSON(http.StatusOK, gin.H{"data": book})
308+
httpresp.OK(c, book)
308309
}
309310

310311
// DeleteBook godoc

pkg/api/books_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@ func TestHealthcheck(t *testing.T) {
6767
// Call the actual Healthcheck method
6868
h.Healthcheck(c)
6969

70-
// Check the response
7170
assert.Equal(t, http.StatusOK, recorder.Code)
72-
assert.Equal(t, "\"ok\"", recorder.Body.String())
71+
var health struct {
72+
Data string `json:"data"`
73+
}
74+
assert.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &health))
75+
assert.Equal(t, "ok", health.Data)
7376
}
7477

7578
func TestParseIDParamNilContext(t *testing.T) {

0 commit comments

Comments
 (0)