HATEOAS in go-codex¶
Prerequisite shipped:
codex.Template[T](build+match engine for every{varName}pattern) andcodex.Param/MergedParam[T]/NewParam[T](the shared validate/merge primitive underneathrest.PathParam/events.TopicParam/reqreply.TopicParam/ports.FilePathParam) are now shipped — seecodex/template.go/codex/param.goanddocs/concepts/codec.md's matching subsections. Any futureAffordance[T,V]-style HATEOAS link resolution described below would callTemplate[V].Codec().Encode/Builddirectly to resolve a link's href from typed vars, exactly the mechanism this doc's design review anticipated.
My Prompt to ChatGPT:¶
Look at the current state of my go-codex lib: DaniDeer/go-codex I want to support codec-defined HATEOAS for my api.rest definition and an easy, declarative workflow for the user working with the rest adapters/ports. One principle of go-codex is "one-struct-one-call" where everything (headers, cookies, query and path params and the body) can be derived from a codec-defined struct.
ChatGPT´s response¶
Yes. Looking at the current go-codex state, I think HATEOAS is a very natural next step, but I would avoid treating it as merely “add a Links field to response structs.”
The stronger idea is:
A REST codec should describe not only the representation of a resource, but also the actions that can be taken on that representation.
That fits your existing philosophy extremely well.
Your current architecture already has the right foundations: api/rest defines typed routes, codecs provide the data contract, and adapters/nethttp consumes the route contract. You have also already moved toward the “one declaration does everything” model with NewPathParam, where a parameter simultaneously participates in validation/spec generation and request construction/merging. (GitHub) The README explicitly describes the same route definition being reusable on the client side. (GitHub)
1. I would make HATEOAS a property of the codec¶
Imagine:
Instead of:
I'd let the codec define the representation and its affordances:
var UserCodec = rest.Resource(
codex.Struct[User](
codex.RequiredField("id", ...),
codex.RequiredField("name", ...),
codex.RequiredField("email", ...),
),
rest.Link("self", GetUser),
rest.Link("update", UpdateUser),
rest.Link("delete", DeleteUser),
)
The important distinction is that GetUser, UpdateUser, and DeleteUser aren't arbitrary URLs.
They are typed REST route contracts.
That gives you a very powerful relationship:
User codec
│
├── representation
│
├── validation
│
├── schema
│
└── affordances
│
├── GET /users/{id}
├── PATCH /users/{id}
└── DELETE /users/{id}
And consequently:
┌──────────────┐
│ UserCodec │
└──────┬───────┘
│
┌────────────┼─────────────┐
▼ ▼ ▼
JSON/YAML OpenAPI HATEOAS
│ │ │
▼ ▼ ▼
representation contract actions
That is much more aligned with the central go-codex idea than having a separate HATEOAS framework bolted onto REST.
2. The really interesting part: links should point to routes, not strings¶
I'd strongly avoid an API like:
because that throws away the type information you already have.
Instead:
where:
var GetUser = rest.NewRoute[GetUserRequest, User](
"GET",
"/users/{id}",
getUserRequestCodec,
UserCodec,
)
Now the link knows:
- HTTP method
- URI template
- path parameters
- query parameters
- headers
- cookies
- request body
- request codec
- response codec
- OpenAPI metadata
This is where your one-struct-one-call principle becomes extremely powerful.
3. I'd actually call this an "affordance", not just a "link"¶
HATEOAS has two levels:
Link¶
Action / affordance¶
And potentially:
Your route already contains almost all of this information.
So I'd introduce something conceptually like:
or, more likely, a non-generic internal representation plus generic constructors.
The key idea:
A HATEOAS affordance should reference a
rest.Route, not independently describe an HTTP operation.
That prevents the route and its HATEOAS representation from drifting apart.
4. Then the client becomes really interesting¶
Suppose the server returns:
{
"id": "42",
"name": "Alice",
"_links": {
"self": {
"href": "/users/42"
},
"delete": {
"href": "/users/42",
"method": "DELETE"
},
"orders": {
"href": "/users/42/orders"
}
}
}
Instead of making the consumer manually construct URLs:
I'd like the declarative workflow to become something like:
and then:
or perhaps:
But I'd go one step further.
5. The response should carry typed affordances¶
Conceptually:
Then:
returns something like:
with:
And:
could potentially be typed.
The important thing is that the server response is no longer merely:
but:
That is true HATEOAS, rather than simply emitting _links.
6. But there is a subtle problem: runtime links are dynamic¶
This is where I would introduce a distinction between:
Route¶
A compile-time API contract:
and:
Link¶
A runtime realization:
So:
This is important because the route contains the URI template, whereas the link contains the resolved URI.
7. This connects beautifully to your existing merged parameter work¶
You currently have this concept:
rest.NewPathParam(
"id",
codex.String().Refine(validate.UUID),
func(r GetUserReq) string { return r.ID },
func(r *GetUserReq, v string) { r.ID = v },
)
The repository explicitly describes this as allowing the same declaration to participate in validation, OpenAPI, and automatic merging into the request. (GitHub)
I'd extend that concept rather than create another parameter system.
For example:
var GetUser = rest.NewRoute[GetUserRequest, User](
"GET",
"/users/{id}",
getUserCodec,
UserCodec,
rest.NewPathParam(
"id",
codex.String(),
func(r GetUserRequest) string { return r.ID },
func(r *GetUserRequest, v string) { r.ID = v },
),
)
Now the route knows exactly how to turn:
into:
HATEOAS can reuse that mechanism in reverse.
Given:
the client can resolve:
into the request struct.
That's extremely powerful.
8. This leads to what I think is the killer feature¶
You want:
an easy, declarative workflow for the user working with the REST adapters/ports.
I would make the port declaration itself declarative.
Something like:
type UserPort struct {
Get rest.Port[GetUserRequest, User]
Create rest.Port[CreateUserRequest, User]
Update rest.Port[UpdateUserRequest, User]
Delete rest.Port[DeleteUserRequest, Empty]
}
Or even:
var Users = rest.ResourcePort(
rest.GET(GetUser),
rest.POST(CreateUser),
rest.PATCH(UpdateUser),
rest.DELETE(DeleteUser),
)
Then an adapter is simply:
and application code becomes:
No HTTP details.
No URL construction.
No query encoding.
No header construction.
No cookie handling.
No JSON marshaling.
Those all remain properties of the codec/route.
9. And HATEOAS becomes the dynamic version of the same port¶
This is the architectural model I'd aim for:
REST contract
│
┌───────────┴───────────┐
│ │
Routes Resources
│ │
│ HATEOAS links
│ │
└───────────┬───────────┘
│
REST Port
│
┌──────────┴──────────┐
│ │
Server Adapter Client Adapter
│ │
net/http net/http
So there isn't really a separate "HATEOAS subsystem".
It's another projection of the same REST contract.
That's very go-codex.
10. I'd also make links codec-defined at the representation level¶
For example:
var UserCodec = rest.ResourceCodec(
codex.Struct[User](
...
),
rest.Affordances(
rest.Self(GetUser),
rest.Rel("orders", ListUserOrders),
rest.Rel("update", UpdateUser),
rest.Rel("delete", DeleteUser),
),
)
The codec can then derive:
JSON¶
{
"id": "42",
"name": "Alice",
"_links": {
"self": {
"href": "/users/42"
},
"orders": {
"href": "/users/42/orders"
},
"update": {
"href": "/users/42",
"method": "PATCH"
}
}
}
OpenAPI¶
The link information could contribute to operation/link metadata.
Client¶
The same definition enables:
Documentation¶
The codec can explain the resource's available state transitions.
One declaration.
Multiple projections.
Again exactly the core go-codex principle. The repository currently describes the library as deriving JSON/YAML/OpenAPI/AsyncAPI from one codec, so HATEOAS fits this "derive another representation" philosophy particularly well. (GitHub)
11. I would separate Link and Action¶
I'd probably model the concepts roughly as:
but internally:
Then distinguish:
from:
because a GET link and a DELETE affordance have different semantics.
Potentially:
rest.Self(GetUser)
rest.Rel("orders", ListOrders)
rest.Action("update", UpdateUser)
rest.Action("delete", DeleteUser)
That gives you a very pleasant DSL.
12. Don't put HATEOAS directly into every domain struct¶
This is the one design decision I'd be particularly careful about.
I would not encourage:
because now your domain model becomes HTTP-aware.
Instead:
and:
or perhaps have the codec produce a representation wrapper.
That preserves your hexagonal architecture nicely:
The domain doesn't know HATEOAS exists.
13. The "one-struct-one-call" principle could become even stronger¶
I'd phrase the principle slightly more broadly:
One struct describes one interaction.
For a request:
the codec/route derives:
path → ID
body → Name, Email
headers → codec-defined fields
query → codec-defined fields
cookies → codec-defined fields
For a HATEOAS link:
So a link doesn't need to expose a pile of HTTP primitives to the application.
The application works with the same request struct it would have used with the statically declared route.
That is a really elegant model.
14. Example of the workflow I'd aim for¶
Imagine an API:
GET /orders/{id}
PATCH /orders/{id}
DELETE /orders/{id}
POST /orders/{id}/cancel
GET /orders/{id}/items
The user declares:
var Order = rest.Resource(
OrderCodec,
rest.Self(GetOrder),
rest.Action("update", UpdateOrder),
rest.Action("delete", DeleteOrder),
rest.Action("cancel", CancelOrder),
rest.Rel("items", ListOrderItems),
)
The server automatically produces:
{
"id": "123",
"status": "pending",
"_links": {
"self": {
"href": "/orders/123"
},
"update": {
"href": "/orders/123",
"method": "PATCH"
},
"delete": {
"href": "/orders/123",
"method": "DELETE"
},
"cancel": {
"href": "/orders/123/cancel",
"method": "POST"
},
"items": {
"href": "/orders/123/items"
}
}
}
Then a client can do conceptually:
order, err := orders.Get(ctx, GetOrderRequest{
ID: "123",
})
if order.Can("cancel") {
order, err = order.Cancel(ctx)
}
The really nice part is that Cancel doesn't need to know how /orders/123/cancel is constructed. The link already contains the resolved route information.
15. I would make the adapter generic over the transport¶
Your existing nethttp adapter is already positioned as the concrete HTTP implementation; the directory currently contains separate adapter, binding, client and cookie concerns. (GitHub)
I'd therefore keep the REST port transport-independent:
or preferably a very small internal transport abstraction.
Then:
The port should never know about http.Client.
The adapter should know about:
http.Requesthttp.Response- cookies
- connection
- TLS
- retries
- transport
while api/rest knows about:
- method
- URI template
- request codec
- response codec
- parameters
- affordances
- contract
This keeps the architecture clean.
16. One additional idea: Follow¶
I'd expose a generic primitive underneath the typed conveniences:
This gives you true hypermedia navigation.
Then higher-level APIs can be syntactic sugar:
which internally is:
That gives you two modes:
Typed/static¶
Hypermedia/dynamic¶
And both ultimately use the same route/codec machinery.
17. The architecture I'd pursue¶
I think the cleanest evolution of go-codex is:
codex.Codec[T]
│
▼
┌─────────────────┐
│ REST Resource │
│ │
│ representation │
│ affordances │
└────────┬────────┘
│
┌───────────┴───────────┐
▼ ▼
Route HATEOAS
│ │
request/response runtime Link
codecs │
│ │
└───────────┬───────────┘
▼
REST Port
│
┌─────────┴─────────┐
▼ ▼
Server Adapter Client Adapter
│ │
net/http net/http
And the central invariant becomes:
There must be exactly one source of truth for an HTTP interaction: the route + codecs.
No duplicated URL templates.
No duplicated request structs.
No duplicated parameter definitions.
No manually maintained HATEOAS URLs.
No separately maintained OpenAPI operation.
That would make HATEOAS a first-class consequence of your existing design, rather than an additional feature.
My recommendation for the API shape¶
I'd explore these four primitives first:
with:
and internally:
Then build the adapter API around:
with typed wrappers layered on top.
That gives you a very small conceptual core while allowing an extremely ergonomic API.
And importantly, I would not start by designing the JSON _links representation. Start with the internal relationship:
Once that is right, HAL, JSON:API-style links, Siren, plain _links, or your own representation can all become renderers. Your current architecture is explicitly built around deriving multiple external representations from one contract, so this keeps HATEOAS consistent with the rest of go-codex. (GitHub)
If you want to push this further, I think the most interesting next design question is how rest.Resource[T] should interact with Route[Req, Resp] and Go generics—because getting that relationship right could make the entire client-side REST API almost disappear behind typed ports. (GitHub)