hal

package module
v1.2.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: 13 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. CollectionPage also emits a project-specific _embedded.items convention; arbitrary embedded resources are not exposed.

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 — this is a server-side serialization library.
  • 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.
  • AddEmbedded Helpers — arbitrary embedded resources are not exposed. Collection emits the project-specific _embedded.items convention below; top-level _embedded in wrapped user data is rejected.
  • Media-Type Negotiation — choose and negotiate the media type in your handlers.

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.
  • 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.

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())

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. 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; empty or whitespace-bearing names and empty hrefs return ErrInvalidCurie. RegisterCurie panics immediately for an empty/whitespace prefix or an empty href.

Generated declarations set templated: true. User declarations are not required to set it, and href template syntax is not validated (see RFC 6570; validation is out of scope).

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. URI syntax, RFC 6570 templates, and link relation registries are not validated.
  5. Static and precomputed envelopes are immutable. AddLink on them records a deferred ErrAddLinkPrecomputed, returned by marshaling. An empty relation is also recorded as a deferred error.
  6. 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" without requiring changes to the underlying data models. CollectionPage also emits the project-specific "_embedded.items" convention; arbitrary embedded resources are not exposed.

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, and strict-mode checks on the generated path.

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

This section is empty.

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 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 empty or whitespace-bearing name, or an empty href.

Functions

func GetVersion

func GetVersion() string

GetVersion returns the version string (e.g., "v1.2.0" or "v1.2.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 if prefix is empty or contains whitespace, or if href is empty.

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})

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 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) 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.

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 if prefix is empty or contains whitespace, or if href is empty.

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 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
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 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 handle that can be reused across envelopes and instances.

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
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