A high-performance, strictly compliant HAL (Hypertext Application Language) library for Go. It focuses on zero-reflection runtime execution, type safety, and allocation-efficient HAL augmentation during JSON serialization.
Target Audience: This library is designed for senior Go developers building high-throughput microservices where HAL compliance and allocation-efficient JSON serialization are non-negotiable. It is not intended for beginners or those looking for an "all-in-one" web framework.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
// Alias the import to ensure the `hal` prefix is stable
hal "github.com/Emin-ACIKGOZ/go-hal"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func init() {
// 1. Register: Define links for *User (must be pointer type)
hal.Register(func(ctx context.Context, u *User) []hal.Link {
return []hal.Link{
{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)},
}
})
}
func main() {
// 2. Wrap: Create the HAL envelope
user := &User{ID: 101, Name: "Alice"}
response := hal.Wrap(context.Background(), user)
// 3. Marshal: HAL metadata injection occurs during marshaling
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(response); err != nil {
panic(err)
}
// Output:
// {
// "id": 101,
// "name": "Alice",
// "_links": {
// "self": { "href": "/users/101" }
// }
// }
}To maintain speed, predictability, and simplicity, go-hal explicitly does not support:
-
Client-side Navigation This is a server-side serialization library only.
-
Code Generation No Go structs are generated from OpenAPI, and no OpenAPI is generated from Go code.
-
Router Integration Routes, HTTP methods, and URL construction are your responsibility. No Gin/Chi/Fiber inference.
-
Implicit Auto-Discovery Links are added only when you explicitly register a generator for a type.
-
Zero-Reflection Runtime Generators are compiled into type-safe closures at registration time. No
reflect.Callin the hot path. -
Byte-Splicing Performance Injects
_linksand_embeddeddirectly into JSON output without allocating intermediate maps. -
Strict HAL Semantics Correct handling of single vs. multiple links per relation, and object-vs-array polymorphism.
-
Dual-Mode API Use a global singleton for convenience or isolated instances for unit testing.
By default, hal.Wrap is permissive: if no generator is found for a type, the data is returned as-is.
Strict Mode exists to catch developer mistakes early. When enabled, it panics if:
- You wrap a value type
Tbut registered a generator for*T. - You wrap a type for which no generator exists at all.
Enable it during development or testing:
hal.DefaultInstance = hal.New(hal.WithStrictMode())The optional hal/openapi package bridges HAL with OpenAPI 3.0 schemas using kin-openapi.
- Injects valid HAL
_linksand_embeddedschemas into existing OpenAPI components - Models HAL link polymorphism (
Link | []Link) correctly usingoneOf - Allows contract-first teams to document HAL responses without hand-writing boilerplate
- It does not generate OpenAPI specs from Go code
- It does not validate runtime responses
- It does not infer links from registered generators
import (
openapi "github.com/Emin-ACIKGOZ/go-hal/openapi"
)
// Using kin-openapi
adapter := openapi.New(doc)
adapter.InjectLinkSchema()
adapter.MakeResource(userSchema) // Augments schema with HAL fieldsThis guide helps migrate from other HAL libraries to go-hal.
Before (halgo):
type User struct {
halgo.Links
Name string
}
user := User{
Links: halgo.Links{}.Self("/users/1"),
Name: "Alice",
}
json.Marshal(user)After (go-hal):
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
hal.Register(func(ctx context.Context, u *User) []hal.Link {
return []hal.Link{{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)}}
})
env := hal.Wrap(ctx, &User{ID: 1, Name: "Alice"})
json.Marshal(env)Before (pyhalboy):
resource = halboy.Resource()
resource.set_link('self', '/users/1')
resource.set_property('name', 'Alice')After (go-hal):
hal.Register(func(ctx context.Context, u *User) []hal.Link {
return []hal.Link{{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)}}
})
env := hal.Wrap(ctx, &User{ID: 1, Name: "Alice"})
json.Marshal(env)Before (Spring):
User user = new User();
user.setId(1);
ControllerLinkBuilder.linkTo(UserController.class)
.withRel("self")
.add(user);After (go-hal):
hal.Register(func(ctx context.Context, u *User) []hal.Link {
return []hal.Link{{Rel: "self", Href: "/users/1"}}
})
env := hal.Wrap(ctx, &User{ID: 1})
json.Marshal(env)| Feature | Other Libraries | go-hal |
|---|---|---|
| Reflection | Required | Zero at runtime |
| Link injection | At creation | At JSON marshal |
| Dependencies | Multiple | go-json only |
| Type Safety | Dynamic | Compile-time verified |
go-hal provides three optimization levels. Use the baseline for correctness; optimize only if profiling shows HAL injection is a bottleneck.
All benchmarks include JSON marshaling. Measurements are ns/op on amd64 Linux.
| Method | ns/op | vs Baseline | Best For |
|---|---|---|---|
| Register (dynamic) | 5,960 | baseline | Links that depend on instance data |
| RegisterStatic | 2,008 | 66% faster | Links fixed per type |
| WrapPrecomputed | 1,759 | 70% faster | Pre-serialized links (cached/batch) |
| Raw JSON (no HAL) | 659 | — | Performance ceiling (not applicable) |
Note on hand-coded HAL: Hand-writing HAL response structs is ~4.6x faster than go-hal's baseline (1,289 ns/op), but requires manual struct maintenance, offers no type safety for link generators, and is error-prone in large codebases. go-hal's cost is justified for maintainability.
Use Register() for dynamic links that depend on runtime data.
hal.Register(func(ctx context.Context, u *User) []hal.Link {
return []hal.Link{{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)}}
})
env := hal.Wrap(ctx, u)Use RegisterStatic() for links that are the same per type (just varying IDs).
inst := hal.New()
hal.RegisterStatic(inst, &User{}, []hal.Link{{Rel: "self", Href: "/users"}})
env := inst.Wrap(ctx, &User{ID: 42})
// Automatically uses pre-computed _linksUse WrapPrecomputed() when you pre-serialize links yourself.
links := []byte(`{"self":{"href":"/users/42"}}`)
env := inst.WrapPrecomputed(ctx, user, links)This bypasses all link generation for maximum throughput.
go-hal's byte-splicing avoids intermediate allocations:
| Benchmark | Allocations per Op |
|---|---|
| Baseline | 14 |
| RegisterStatic | 5 |
| WrapPrecomputed | 5 |
- RegisterStatic: Use when links are identical for every instance of a type (rare). Saves ~3,900 ns/op per resource.
- WrapPrecomputed: Use when you generate links in a separate pass (e.g., caching layer, batch API). Saves ~4,200 ns/op per resource.
Rule of thumb: Optimize only if profiling shows HAL injection >5% of request latency. For most APIs, baseline performance is acceptable (6 µs per resource).
This library uses a deliberate byte-splicing technique to merge your struct's JSON output with HAL metadata.
-
Your data must marshal to a JSON object (
{...}) Root arrays or primitives are not valid HAL resources. -
Your data must not be
nil(unless you intentionally want an empty HAL response).
If you encounter invalid JSON (e.g. {,"_links":...}), inspect the raw JSON output of your data struct.
go-hal relies on detecting the opening { and closing } of your payload.
MIT