Help us shape the future of Go! Take the 2026 Go Developer Survey

hal

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: MIT Imports: 17 Imported by: 0

README

go-hal

A HAL (Hypertext Application Language) serializer for Go. It wraps Go values in an envelope and injects _links during JSON marshaling, with typed link generators, reusable precomputed links, CURIEs, and an optional OpenAPI adapter. AddEmbedded emits arbitrary embedded resources, and CollectionPage also emits a project-specific _embedded.items convention.

The implementation targets the supported subset of draft-kelly-json-hal-11 (an expired IETF Informational draft, not an RFC). Compliance is deliberately scoped: see COMPLIANCE.md for the support matrix, validation boundaries, and the list of features that are out of scope. In particular, application/hal+json is not an IANA-registered media type; the registered vendor type is application/vnd.hal+json.

Requirements

Go 1.26 or newer. Go 1.22, 1.24, and 1.25 are end-of-life and are no longer tested. The previous v1.1.0 release remains available for older toolchains.

Minimal Example

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() {
	// Register a generator for *User.
	hal.Register(func(ctx context.Context, u *User) []hal.Link {
		return []hal.Link{
			{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)},
		}
	})
}

func main() {
	user := &User{ID: 101, Name: "Alice"}
	response := hal.Wrap(context.Background(), user)

	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" }
	//   }
	// }
}

Both Envelope values and *Envelope pointers marshal as HAL resources. AddLink remains a pointer method. A nil *Envelope marshals as JSON null, following encoding/json semantics.

Non-Goals

go-hal does not provide:

  • Client-side Navigation — out of scope for this module, which is a server-side serializer. Draft-11 makes client cache-pattern support OPTIONAL (§8.4) and asks a client to notify only when it traverses a link carrying deprecation (§5.4). No halclient package is provided; emitted documents are ordinary JSON and can be consumed by any conforming HAL client.
  • Code Generation — no OpenAPI-to-Go or Go-to-OpenAPI generation.
  • Router Integration — URL construction and HTTP methods remain yours.
  • Implicit Auto-Discovery — links are generated only for registered types. No self or profile link is added automatically.
  • Media-Type Negotiation — ContentType formats a value; choosing and negotiating the media type in handlers remains yours.

Features

  • Typed Generators — the generic Register / RegisterInstance API binds generators without reflect.Call at runtime. The deprecated Instance.RegisterInstance(any) form still uses reflection.
  • HAL Link Semantics — one relation value is an object; repeated values become an array of objects.
  • Dual-Mode API — a global singleton for convenience or isolated instances for unit testing.
  • Validated Precomputed Links — compile once, reuse across envelopes and instances.
  • CURIE Support — registered prefixes and user declarations are merged with user-wins precedence; invalid or duplicate declarations are rejected.
  • Embedded Resources — AddEmbedded for raw objects, maps, slices, and nested *Envelope values, with depth/cycle protection and the §8.4 cache pattern via AddCachedLink.
  • Defensive JSON Handling — reserved HAL keys, escaped reserved keys, duplicate keys in precomputed input, invalid UTF-8, and non-object roots are rejected before output is produced. WithStrictJSON extends duplicate-key checks to each marshaled Data object and raw embedded resource, inherited transitively by embedded child envelopes.
  • Opt-in Strict Modes — WithStrictLinks, WithStrictRelations, WithStrictCuries, and WithStrictJSON are independent; defaults stay permissive apart from the documented invalid-name/collision hardening.
  • Media Helpers — MediaTypeHAL, MediaTypeVendorHAL, and ContentType(profiles...).

Embedded Resources

parent := inst.Wrap(ctx, &Book{Title: "The Way of Zen"})
parent.AddEmbedded("author", &Person{Name: "Alan Watts"})
parent.AddCachedLink(
    hal.Link{Rel: "author", Href: "/people/alan-watts"},
    &Person{Name: "Alan Watts"},
)

An embedded value is a Resource Object. One value emits an object; repeated values emit an array. Slices flatten one level, one-element slices emit an object, and empty slices are a no-op. Raw structs, maps, and object json.RawMessage values are embedded as-is and are not auto-wrapped or link-generated; use inst.Wrap when the child needs its own links. Direct children must be *Envelope (not Envelope values) so depth and cycles can be bounded. The maximum embedded depth is 32; cycles return ErrEmbeddedDepthExceeded.

Static and precomputed envelopes also accept AddEmbedded; the cached _links bytes are preserved and _embedded is spliced in. They still reject AddLink and AddCachedLink with ErrAddLinkPrecomputed.

Strict Mode

By default, hal.Wrap is permissive: if no generator is found for a type, the data is returned without generated links.

Strict Mode catches generator mismatches on the generated path. It panics when:

  1. You wrap a value type T but registered a generator for *T.
  2. You wrap a struct value or pointer with no registered generator.

Static and precomputed envelopes do not invoke generators, so they bypass these checks:

hal.DefaultInstance = hal.New(hal.WithStrictMode())

These options are independent of WithStrictMode and of each other:

  • WithStrictLinks() validates href URI references and RFC 6570 templates, enforces templated consistency, and validates type, deprecation, profile, and hreflang. Media ranges such as */* and unsafe template literal bytes are rejected; brackets and multiple fragments are checked by the URI-reference validator.
  • WithStrictRelations() accepts lowercase RFC 8288 reg-rel-type tokens, absolute URI schemes, and declared CURIEs; it checks grammar only (IANA registration is not consulted) and rejects relative references, :x, mixed-case bare tokens, and RFC 8288-invalid underscores such as a_b. An undeclared acme:widget remains an opaque URI. Default mode accepts a_b and does not fold it as a bare-token collision; CURIE prefixes may use _. Enforcing the full grammar and case-variant collisions by default is reserved for v2.0.0.
  • WithStrictCuries() requires ASCII NCName-subset names and a single {rel}/{+rel} template with templated: true. Struct-supplied declarations are normalized first, so a template derives the flag; only precomputed declarations require it explicitly.
  • WithStrictJSON() scans each non-empty marshaled Data object and raw embedded resource, inherits the policy into child envelopes, and returns ErrDuplicateJSONKey / *DuplicateKeyError.
inst := hal.New(
    hal.WithStrictLinks(),
    hal.WithStrictRelations(),
    hal.WithStrictCuries(),
    hal.WithStrictJSON(),
)

ValidateLink performs the link checks for one-off validation. Strict link/relation/CURIE policy is applied to AddLink, generated links, RegisterStatic, Collection, and precomputed wrapping. Use CompilePrecomputedLinksStrict for reusable handles; strict instances reject non-strict handles and the zero handle remains valid.

Default hardening still rejects blank/control/invalid-UTF-8 relation names, bare-token case collisions, and CURIE names containing a colon or control character.

Collections

CollectionPage and Collection are a project convention, not part of HAL:

page := inst.Collection(ctx, users, total, hal.Link{Rel: "self", Href: "/users"})

count and total are always serialized, so total: 0 stays observable. Empty _links and _embedded maps are omitted. Embedded items are wrapped through the instance, so they keep their own links.

There are two entry points:

// Validates and copies on every call. Convenient for one-off input.
env := inst.WrapPrecomputed(ctx, user, linksJSON)

// Validate once, then reuse. Treat the handle as read-only and instance-independent.
compiled, err := hal.CompilePrecomputedLinks(linksJSON)
if err != nil {
	return err
}
env := inst.WrapPrecomputedLinks(user, compiled)

CompilePrecomputedLinks owns its bytes: mutating the input slice afterward does not affect the handle. CompilePrecomputedLinksStrict additionally validates strict links and CURIEs. A strict instance rejects a non-empty handle compiled without the matching strict flags; an empty (zero) handle is always valid. A nil handle has WrapRaw semantics, while a non-nil zero handle means an empty link set. A hand-built handle whose JSON was never validated is rejected at marshal time. Static and precomputed envelopes reject AddLink with ErrAddLinkPrecomputed; use a generated or raw envelope if you need to add links dynamically.

RegisterStatic serializes links once per instance and type. It panics for a nil instance, a nil or non-pointer target, empty relations, or invalid/duplicate CURIE declarations. Registered CURIEs resolve at wrap time, so a late registration on the same instance is reflected. Only compiled PrecomputedLinks handles are reusable across instances.

CURIEs

inst.RegisterCurie("acme", "https://docs.example.com/rels/{rel}")

When a relation such as acme:widget is present, the matching registered prefix is emitted in a curies array. Unknown prefixes keep the relation but produce no declaration. User-supplied curies declarations win over registered declarations with the same name. Duplicate names within one source return ErrDuplicateCurie; names that are empty, whitespace/control-bearing, non-UTF-8, or contain a colon and empty hrefs return ErrInvalidCurie. WithStrictCuries additionally requires the ASCII NCName subset. RegisterCurie panics for invalid names, empty hrefs, or strict href/templated violations.

Generated declarations derive templated from the registered href template, so a declaration never claims templated: true for a non-template. Default mode does not require user declarations to set it and does not validate href templates. WithStrictCuries() requires a single {rel} or {+rel} expression and templated: true.

Embedded *Envelope children inherit parent CURIE declarations. A child-local declaration overrides the parent for that child and its descendants; children do not emit redundant declarations for inherited prefixes.

Draft §8.1 recommends a self link on each Resource Object, and §7.1 recommends a root profile link when the media type carries a profile parameter. Both are authored explicitly; the serializer never injects or mutates links during marshaling. Collection already emits its own self link and never adds a profile.

profiles := []string{"https://example.com/profiles/order"}
ct, err := hal.ContentType(profiles...)
if err != nil {
	return err
}

env := inst.Wrap(ctx, order)
env.AddLink(hal.Link{Rel: "self", Href: "/orders/523"}) // relative self is fine
for _, profile := range profiles {
	env.AddLink(hal.Link{Rel: "profile", Href: profile}) // absolute profile URIs
}
_ = ct // serve as the response Content-Type

self applies to every Resource Object, including embedded ones. The root profile link applies to the document root; embedded resources do not inherit it. A manually built CollectionPage sets its own root profile in Links.

HAL §5.1 allows an href to be a URI or an RFC 6570 URI Template, and §5.2 recommends templated: true for templates. The library derives the flag on AddLink, AddCachedLink, RegisterStatic, Collection self links, and struct-supplied curies declarations:

env.AddLink(hal.Link{Rel: "find", Href: "/orders{?id}"})
// {"find":{"href":"/orders{?id}","templated":true}}

Derivation requires a literal {, a successful template parse, at least one expression, and no reserved operator (=, ,, !, @, |). %7B/%7D are data, not expressions. Malformed templates and reserved-operator hrefs are not advertised as templates: default mode emits them unchanged, and WithStrictLinks/ValidateLink rejects them. Set Templated: true yourself to skip the one-time parse; that is the fast path.

href: "" is a valid RFC 3986 same-document reference and stays accepted by default. Strict link validation rejects empty and whitespace-only hrefs as authoring bugs. Precomputed link objects and other cached bytes are preserved; only the curies member may be spliced to add generated embedded-only declarations. A template without templated: true is emitted verbatim by default and rejected by strict link or CURIE validation.

OpenAPI Adapter (hal/openapi)

The optional adapter augments OpenAPI 3.0 schemas using kin-openapi.

What it does
  • Injects a HAL _links / _embedded schema into existing components
  • Models Link | []Link with oneOf
  • Preserves an existing custom Link schema
  • Marks HAL collection fields read-only and tolerates nil documents/schemas
  • Models the _embedded.items collection convention
What it does not do
  • 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"
)

adapter := openapi.New(doc)
adapter.InjectLinkSchema()
adapter.MakeResource(userSchema)

Migration Guide

From raff/halgo (Go)

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)
From pyhalboy (Python)

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)
From Spring HATEOAS (Java)

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)
Key Differences
Feature Other Libraries go-hal
Reflection Often required Typed generators avoid reflect.Call; reflection is used for registration and the deprecated API
Link injection At creation At JSON marshal
Dependencies Varies goccy/go-json for encoding; optional kin-openapi adapter
Type Safety Dynamic Compile-time checked typed generators

Performance

HAL injection necessarily serializes your data and builds link metadata. The exact cost depends on the payload, encoder, link count, and whether CURIEs are resolved. Do not rely on fixed numbers from another machine: measure your own path.

go test -run='^$' -bench=. -benchmem -count=10 ./...

The three approaches are:

  1. Generated links (Register / Wrap): flexible, per-request links.
  2. Static links (RegisterStatic): serialized once per type; best for links that do not vary per instance.
  3. Compiled precomputed links (CompilePrecomputedLinks + WrapPrecomputedLinks): validate once and reuse; best when links are produced in a separate pass or cache. WrapPrecomputed is the safe one-shot form and pays validation plus a copy on every call.

CURIE resolution and reserved-field checks still run at wrap/marshal time, so precomputed links are not a blanket "zero-cost" path. The pull-request workflow publishes an advisory benchstat comparison; it does not fail builds.

Constraints and Validation

  1. Data must marshal to a JSON object ({...}) or null. nil, typed-nil pointers, and empty objects are normalized to an empty resource. Root arrays and scalars are rejected.
  2. User data must not contain top-level _links or _embedded; this includes escaped spellings such as \u005flinks. Conflicts return ErrHALFieldConflict.
  3. Precomputed link input is scanned for duplicate JSON keys. Arbitrary user state is not re-scanned for duplicate keys after the encoder produces it; use a strict encoder if that matters.
  4. href is required on Link Objects. By default URI syntax, RFC 6570 templates, and relation registries are not validated; supported template hrefs derive templated: true, while empty hrefs remain accepted as RFC 3986 same-document references. Enable WithStrictLinks/WithStrictRelations or call ValidateLink for strict validation.
  5. Static and precomputed envelopes are immutable for links. AddLink and AddCachedLink record a deferred ErrAddLinkPrecomputed; AddEmbedded is allowed and spliced into the output. Invalid relations are deferred as ErrInvalidRelation.
  6. Embedded envelopes are limited to depth 32 and checked for cycles on each marshal. Raw embedded graphs are caller-owned.
  7. Instance registration is concurrency-safe. An Envelope may be marshaled concurrently only if it is not mutated.

License

MIT

Documentation

Overview

Package hal implements the Hypertext Application Language (HAL) for JSON.

It provides a mechanism to wrap existing Go structs in an envelope that injects HAL "_links" and "_embedded" fields without requiring changes to the underlying data models. AddEmbedded supports arbitrary Resource Objects and the §8.4 cache pattern; CollectionPage also emits the project-specific "_embedded.items" convention.

The package relies on a central or instance-based registry to map Go types to link generation functions. It supports CURIEs (Compact URIs), reusable precomputed links, stable link shapes, media-type helpers, and independent opt-in strict policies for links, relations, CURIEs, and JSON. Template hrefs gain templated=true on emission, and generated documents declare CURIEs for embedded-only relation prefixes. Client-side decoding and navigation are out of scope for this server-side serializer: draft-11 has no client-side MUST, makes the hypertext cache pattern client-optional (§8.4), and places the deprecation notification SHOULD on clients that traverse a link (§5.4).

The target is the supported subset of draft-kelly-json-hal-11, an expired Informational draft rather than an RFC. See COMPLIANCE.md for the support matrix, validation boundaries, and out-of-scope features.

All methods on Instance are safe for concurrent use. Envelopes may be marshaled concurrently while they are not being mutated.

Index

Constants

View Source
const MediaTypeHAL = "application/hal+json"

MediaTypeHAL is the media type named by draft-kelly-json-hal-11. It is not registered with IANA.

View Source
const MediaTypeVendorHAL = "application/vnd.hal+json"

MediaTypeVendorHAL is the IANA-registered vendor HAL media type. Its 2011 IANA registration defines no optional parameters.

Variables

View Source
var DefaultInstance = New()

DefaultInstance is the global singleton registry used by package-level functions. If you need isolated registries for testing, create your own with New().

View Source
var ErrAddLinkPrecomputed = errors.New("hal: cannot add links to a static or precomputed envelope")

ErrAddLinkPrecomputed is returned when AddLink is called on an envelope whose links are static or precomputed. Those links are immutable in the envelope.

View Source
var ErrDuplicateCurie = errors.New("hal: duplicate CURIE declaration")

ErrDuplicateCurie is returned when CURIE declarations in one source reuse a name. Duplicate declarations are ambiguous and not interoperable.

View Source
var ErrDuplicateJSONKey = errors.New("hal: duplicate JSON key")

ErrDuplicateJSONKey is returned when JSON contains duplicate object member names. Duplicate names are non-interoperable and cannot be detected after decoding into a map.

View Source
var ErrEmbeddedDepthExceeded = errors.New("hal: embedded resource depth exceeded")

ErrEmbeddedDepthExceeded is returned when embedded envelopes form a cycle or exceed the maximum nesting depth during marshaling.

View Source
var ErrHALFieldConflict = errors.New("hal: data already contains reserved HAL field")

ErrHALFieldConflict is returned when user data already contains a reserved top-level HAL property. Emitting those fields would create duplicate JSON object keys, which are not interoperable.

View Source
var ErrInvalidCurie = errors.New("hal: invalid CURIE declaration")

ErrInvalidCurie is returned when a CURIE declaration has an unusable name (empty, whitespace/control-bearing, non-UTF-8, colon-bearing, or failing the strict ASCII NCName subset), an empty href, or a strict href/templated violation.

View Source
var ErrInvalidEmbedded = errors.New("hal: invalid embedded resource")

ErrInvalidEmbedded is returned when an embedded value cannot be represented as a HAL Resource Object.

View Source
var ErrInvalidLink = errors.New("hal: invalid link")

ErrInvalidLink is returned when a Link fails validation. By default links are permissive; ValidateLink and instances configured with WithStrictLinks apply the full checks.

View Source
var ErrInvalidProfile = errors.New("hal: invalid profile")

ErrInvalidProfile is returned when a ContentType profile is not an absolute URI. It is specific to ContentType; strict Link.Profile validation wraps ErrInvalidLink instead.

View Source
var ErrInvalidRelation = errors.New("hal: invalid link relation")

ErrInvalidRelation is returned for a blank, control-bearing, non-UTF-8, or otherwise unusable link relation.

View Source
var ErrLinkShape = errors.New("hal: incompatible link shape")

ErrLinkShape is returned when a configured shape cannot represent the links present, such as ShapeObject with repeated links.

Functions

func ContentType added in v1.3.0

func ContentType(profiles ...string) (string, error)

ContentType builds an application/hal+json content type with an optional RFC 6906 profile parameter. Zero profiles returns the bare media type; each non-empty profile must be an absolute URI and is joined into one space- separated profile value. The bytes are preserved as supplied; schemes other than http/https (urn:, mailto:, tag:) are accepted.

func GetVersion

func GetVersion() string

GetVersion returns the version string (e.g., "v1.4.0" or "v1.4.0-beta")

func Register

func Register[T any](gen func(context.Context, *T) []Link)

Register registers a generator function for type T on the DefaultInstance. The generator is called at runtime to produce links for wrapped resources.

Type Requirements

  • T must be a pointer type (e.g., *User not User)
  • Generators are registered for the pointee type

Example

hal.Register(func(ctx context.Context, u *User) []Link {
    return []Link{
        {Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)},
        {Rel: "edit", Href: fmt.Sprintf("/users/%d/edit", u.ID)},
    }
})

func RegisterCurie

func RegisterCurie(prefix, href string)

RegisterCurie registers a CURIE (Compact URI) prefix on the DefaultInstance. CURIEs allow shortened link relations like "acme:widget" that expand to full URIs. It panics for an unusable prefix (empty, whitespace/control-bearing, non-UTF-8, colon-bearing, or failing the strict NCName subset), an empty href, or a strict href/templated violation.

Example

hal.RegisterCurie("acme", "https://docs.example.com/rels/{rel}")

func RegisterInstance

func RegisterInstance[T any](i *Instance, gen func(context.Context, *T) []Link)

RegisterInstance binds a strongly-typed generator function to the provided Instance. The generator will be invoked whenever Wrap is called with a value of type *T.

func RegisterStatic

func RegisterStatic(i *Instance, target any, links []Link)

RegisterStatic registers pre-computed links for a type. Links are serialized once at registration time and resolve registered CURIEs at wrap time. Best for links that do not change per resource instance.

It panics for a nil instance, a nil or non-pointer target, an empty relation, or invalid/duplicate CURIE declarations. A typed-nil pointer target such as (*T)(nil) is accepted. Passing no links registers an empty link set that suppresses generators and emits no _links.

Example

inst := hal.New()
hal.RegisterStatic(inst, &User{}, []hal.Link{
    {Rel: "self", Href: "/users"},
})

// At runtime - uses pre-computed links automatically
env := inst.Wrap(ctx, &User{ID: 42})
func ValidateLink(l Link) error

ValidateLink validates a Link Object without requiring a relation namespace: href URI/template consistency and the optional type, deprecation, profile, and hreflang fields. Rel, name, title, and method are not validated.

ValidateLink validates the normalized, as-emitted link: a supported template href derives templated automatically. Callers that marshal a Link struct directly, outside an Envelope, must set Templated themselves.

Types

type CollectionPage

type CollectionPage struct {
	Links    map[string]any `json:"_links,omitempty"`
	Embedded map[string]any `json:"_embedded,omitempty"`
	Count    int            `json:"count"`
	Total    int            `json:"total"`
}

CollectionPage represents a project-specific HAL collection convention. It includes navigation links, embedded items, and pagination metadata.

Count and Total are always serialized so a total of zero stays observable; they are not part of the HAL specification. Empty _links and _embedded maps are omitted.

func Collection

func Collection[T any](ctx context.Context, items []*T, total int, selfLink Link) *CollectionPage

Collection creates a CollectionPage using the DefaultInstance.

type DuplicateKeyError added in v1.3.0

type DuplicateKeyError struct {
	Key    string
	Offset int64
}

DuplicateKeyError reports the first duplicate object key and its byte offset.

func (*DuplicateKeyError) Error added in v1.3.0

func (e *DuplicateKeyError) Error() string

func (*DuplicateKeyError) Unwrap added in v1.3.0

func (e *DuplicateKeyError) Unwrap() error

Unwrap supports errors.Is(err, ErrDuplicateJSONKey).

type Envelope

type Envelope struct {
	Data any // The user's struct
	// contains filtered or unexported fields
}

Envelope is the container for your data with HAL metadata. It wraps your Go value and injects _links during JSON serialization. CollectionPage separately emits the project-specific _embedded.items shape.

To create an Envelope:

// Using global instance
env := hal.Wrap(ctx, &MyStruct{...})

// Using isolated instance
inst := hal.New()
env := inst.Wrap(ctx, &MyStruct{...})

Envelope implements json.Marshaler with a value receiver, so both Envelope and *Envelope marshal as HAL resources. AddLink remains pointer-only. Static and precomputed envelopes reject AddLink with ErrAddLinkPrecomputed. An Envelope may be marshaled repeatedly or concurrently while it is not being mutated.

func Wrap

func Wrap(ctx context.Context, data any) *Envelope

Wrap wraps data in a HAL Envelope using the DefaultInstance. The envelope will inject _links during JSON serialization based on registered generators.

Example

user := &User{ID: 42, Name: "Alice"}
env := hal.Wrap(context.Background(), user)
json.Marshal(env)
func (e *Envelope) AddCachedLink(link Link, resource any)

AddCachedLink adds a link and embeds the same relation's resource, the HAL hypertext cache pattern from draft §8.4. The operation is atomic: if the relation or resource is invalid, neither the link nor the embedded value is added. On static or precomputed envelopes it records the deferred ErrAddLinkPrecomputed and adds nothing.

func (*Envelope) AddEmbedded added in v1.3.0

func (e *Envelope) AddEmbedded(rel string, value any)

AddEmbedded appends an embedded resource under rel, following HAL §4.1.2.

One value is emitted as an object; repeated values form an array. A slice is flattened one level, a one-element slice is emitted as an object, and an empty slice is a no-op. Nested envelopes must be *Envelope values so the marshaller can bound depth and detect cycles without copying identity.

Raw structs, maps, and object json.RawMessage values are embedded as-is; they are not automatically wrapped or link-generated. Nil, typed-nil, scalar, []byte, Envelope values, and non-object RawMessage values are rejected with a deferred ErrInvalidEmbedded.

AddEmbedded is a pointer method and is not safe to call concurrently with marshaling. It works on generated, raw, static, and precomputed envelopes.

func (e *Envelope) AddLink(l Link)

AddLink appends a link to the envelope. If a link with the same Relation (Rel) already exists, it is converted to a slice of links as per HAL relation multiplicity.

AddLink is a pointer method. On static or precomputed envelopes it does not add the link and defers ErrAddLinkPrecomputed to marshaling. An empty or whitespace-only relation is also deferred as an error. It is not safe to call AddLink concurrently with marshaling.

func (Envelope) MarshalJSON

func (e Envelope) MarshalJSON() ([]byte, error)

MarshalJSON implements the json.Marshaler interface. It serializes the wrapped Data and splices in the HAL "_links" and "_embedded" fields into the resulting JSON object. The value receiver is preserved so Envelope values and *Envelope pointers both marshal; direct *Envelope children are marshaled manually with a shared depth and ancestor scope.

type Generator

type Generator func(ctx context.Context, v any) []Link

Generator defines a function that produces HAL links for a specific type. The function receives the data to generate links for and returns a slice of Link objects.

Example

Generator: func(ctx context.Context, u *User) []Link {
    return []Link{{Rel: "self", Href: fmt.Sprintf("/users/%d", u.ID)}}
}

type Instance

type Instance struct {
	// contains filtered or unexported fields
}

Instance maintains a registry of link generators and CURIE definitions. It is safe for concurrent use.

Creating an Instance

// Basic instance
inst := hal.New()

// With strict mode
inst := hal.New(hal.WithStrictMode())

func New

func New(opts ...InstanceOption) *Instance

New creates a new HAL Instance.

The instance maintains its own registry of generators, allowing for isolated testing. Options can be provided to configure the instance:

inst := hal.New(hal.WithStrictMode())

func (*Instance) Collection

func (i *Instance) Collection(ctx context.Context, items any, total int, selfLink Link) *CollectionPage

Collection wraps a slice of items into a HAL CollectionPage. It iterates over the items, wraps each one using the registered generators, and constructs the embedded items list. Generators are registered for *T, so use []*T to get per-item links; []T does not match a *T generator.

This method panics if items is not a slice.

func (*Instance) RegisterCurie

func (i *Instance) RegisterCurie(prefix, href string)

RegisterCurie adds a CURIE (Compact URI) mapping to the instance. These are used to shorten link relations in the output JSON.

It panics for an unusable prefix (empty, whitespace/control-bearing, non-UTF-8, colon-bearing, or failing the strict NCName subset), an empty href, or a strict href/templated violation. Generated declarations derive templated from the href template.

func (*Instance) RegisterInstance

func (i *Instance) RegisterInstance(gen any)

RegisterInstance registers a generator using reflection. Deprecated: Use the generic function RegisterInstance[T] instead.

func (*Instance) RegisteredTypes

func (i *Instance) RegisteredTypes() []reflect.Type

RegisteredTypes returns a list of all Go types that have a generator registered. This is a Read-Only introspection hook useful for adapters (like OpenAPI).

func (*Instance) Wrap

func (i *Instance) Wrap(ctx context.Context, data any) *Envelope

Wrap creates a HAL Envelope with computed links. It first checks for pre-computed links (via RegisterStatic), then falls back to generators. If static links were registered for the data's type, those links take precedence and generators are not called.

func (*Instance) WrapPrecomputed

func (i *Instance) WrapPrecomputed(_ context.Context, data any, linksJSON []byte) *Envelope

WrapPrecomputed wraps data with caller-supplied links JSON. It validates and copies the input and computes the CURIE prefixes on every call. For a hot path, validate once with CompilePrecomputedLinks and reuse the handle with WrapPrecomputedLinks.

Input Format

linksJSON may be raw link relations or the wrapped form:

{"self": {"href": "/users/42"}}
{"_links": {"self": {"href": "/users/42"}}}

Invalid input produces a deferred error during marshaling.

Example

links := []byte(`{"self":{"href":"/users/42"}}`)
env := inst.WrapPrecomputed(ctx, &User{ID: 42}, links)
json.Marshal(env)
func (i *Instance) WrapPrecomputedLinks(data any, links *PrecomputedLinks) *Envelope

WrapPrecomputedLinks wraps data with an already-compiled link handle. The handle is reusable across envelopes and instances; its JSON is not copied or revalidated. A nil handle has WrapRaw semantics, while a non-nil zero handle means an empty link set.

func (*Instance) WrapRaw

func (i *Instance) WrapRaw(data any) *Envelope

WrapRaw creates an envelope without computing links. Use this for cases where context-based link generation is not needed.

type InstanceOption

type InstanceOption func(*Instance)

InstanceOption configures a new HAL Instance. Apply options when creating a new Instance:

inst := hal.New(hal.WithStrictMode())

func WithLinkShape added in v1.3.0

func WithLinkShape(shape LinkShape) InstanceOption

WithLinkShape sets the default relation shape. ShapeAuto keeps the default object-for-one/array-for-many behavior; ShapeArray always emits arrays and is the draft §4.1.1 stable-shape profile; ShapeObject requires at most one link per relation.

func WithRelationShape added in v1.3.0

func WithRelationShape(rel string, shape LinkShape) InstanceOption

WithRelationShape overrides the default shape for one relation. Later calls for the same relation replace the earlier override.

func WithStrictCuries added in v1.3.0

func WithStrictCuries() InstanceOption

WithStrictCuries enables strict CURIE declaration validation on AddLink, merge, RegisterCurie, RegisterStatic, and precomputed wrapping. It is independent of WithStrictMode.

Strict names use the ASCII NCName subset [A-Za-z_][A-Za-z0-9._-]*.

func WithStrictJSON added in v1.3.0

func WithStrictJSON() InstanceOption

WithStrictJSON enables duplicate-key scanning of marshaled Resource Objects. It scans each envelope's non-empty Data object and raw embedded values, and the policy is inherited by embedded child envelopes (the effective policy is parent OR child). It does not rescan final output or precomputed/static link JSON (those are scanned at compile time). DuplicateKeyError.Offset is relative to the scanned value, not the final document. Default marshaling stays permissive.

func WithStrictLinks() InstanceOption

WithStrictLinks enables link validation on AddLink, generated links, static links, Collection self links, and precomputed wrapping. It is independent of WithStrictMode. By default links are permissive; ValidateLink can be called directly for one-off checks.

Strict links accept absolute and relative URI references, derive templated=true for supported template hrefs, require templated=false/absent for URI hrefs, reject empty/whitespace-only hrefs, and validate type, deprecation, profile, and hreflang syntax.

func WithStrictMode

func WithStrictMode() InstanceOption

WithStrictMode enables panic on generator mismatches.

It applies only to Instance.Wrap on the generated path. When Data is a non-nil struct or pointer-to-struct, Wrap panics if:

  1. You pass a value type T but registered a generator for *T
  2. No generator is registered for the exact type

Static links, WrapRaw, WrapPrecomputed, WrapPrecomputedLinks, nil data, and non-struct values bypass these checks. Strict mode does not validate links or JSON.

Example

inst := hal.New(hal.WithStrictMode()) // panics on generator mismatches

func WithStrictRelations added in v1.3.0

func WithStrictRelations() InstanceOption

WithStrictRelations enables strict relation grammar on AddLink, AddEmbedded, generated links, static links, and precomputed wrapping. It is independent of WithStrictMode.

Strict relations accept lowercase RFC 8288 reg-rel-type tokens, any absolute URI scheme (including urn:, mailto:, and tag:), and declared CURIEs. Relation grammar is checked lexically; IANA registration status is not consulted. Relative references, ":x", and mixed-case bare tokens are rejected. An undeclared "acme:widget" is accepted as an opaque URI because a non-authority scheme form is indistinguishable from an absolute URI without declaration context.

type Link struct {
	Rel         string `json:"-"` // Used for map keys, not serialized directly
	Href        string `json:"href"`
	Templated   bool   `json:"templated,omitempty"`
	Type        string `json:"type,omitempty"`
	Deprecation string `json:"deprecation,omitempty"` // OPTIONAL: link to deprecation info
	Name        string `json:"name,omitempty"`
	Profile     string `json:"profile,omitempty"` // OPTIONAL: URI hint about target
	Title       string `json:"title,omitempty"`
	HrefLang    string `json:"hreflang,omitempty"` // OPTIONAL: language of target
	Method      string `json:"method,omitempty"`   // Non-standard: common hint for HTTP methods
}

Link represents a HAL Hypermedia link. Implements draft-kelly-json-hal Section 5 Link Object.

All fields (except Rel and Method) are compliant with the HAL specification. Rel is used internally as the map key and is not serialized. Method is a non-standard extension for HTTP method hints.

Link{
    Rel:       "self",
    Href:      "/users/123",
    Templated: false,
    Title:     "Current User",
}

type LinkShape added in v1.3.0

type LinkShape uint8

LinkShape controls whether a relation is serialized as one object or always as an array. ShapeAuto keeps the HAL default: one value is an object and repeated values form an array.

const (
	// ShapeAuto emits an object for one link and an array for repeated links.
	ShapeAuto LinkShape = iota
	// ShapeArray always emits an array, even for one link.
	ShapeArray
	// ShapeObject requires at most one link and emits an object.
	ShapeObject
)

func (LinkShape) String added in v1.3.0

func (s LinkShape) String() string
type PrecomputedLinks struct {
	JSON []byte // wrapped {"_links":...}; read-only; owned by handle
	// contains filtered or unexported fields
}

PrecomputedLinks holds validated, wrapped pre-serialized links JSON.

The JSON field is read-only by convention and owned by the handle. Do not mutate it after compilation. Its zero value means an empty link set with no _links field. Use CompilePrecomputedLinks to build a handle from caller JSON.

func CompilePrecomputedLinks(linksJSON []byte) (*PrecomputedLinks, error)

CompilePrecomputedLinks validates and copies caller-supplied link JSON once, producing a structurally validated handle that can be reused across envelopes and instances. It does not opt into strict link or CURIE semantics.

func CompilePrecomputedLinksStrict added in v1.3.0

func CompilePrecomputedLinksStrict(linksJSON []byte) (*PrecomputedLinks, error)

CompilePrecomputedLinksStrict is CompilePrecomputedLinks plus strict link and strict CURIE validation. The resulting handle can be wrapped by strict instances without revalidation.

type VersionInfo

type VersionInfo struct {
	Major      int
	Minor      int
	Patch      int
	PreRelease string
}

VersionInfo holds version metadata

func GetVersionInfo

func GetVersionInfo() VersionInfo

GetVersionInfo returns detailed version information

Directories

Path Synopsis
internal
relation
Package relation contains the single relation classifier shared by link, embedded-resource, and CURIE handling.
Package relation contains the single relation classifier shared by link, embedded-resource, and CURIE handling.
uriref
Package uriref validates RFC 3986 URI references conservatively.
Package uriref validates RFC 3986 URI references conservatively.
uritemplate
Package uritemplate parses RFC 6570 URI Templates into a small AST.
Package uritemplate parses RFC 6570 URI Templates into a small AST.
Package openapi provides an adapter to augment OpenAPI 3.0 documents with HAL semantics.
Package openapi provides an adapter to augment OpenAPI 3.0 documents with HAL semantics.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL