go-forge/docs/architecture.md
Snider 761493c7f5
All checks were successful
Security Scan / security (push) Successful in 6s
Test / test (push) Successful in 1m8s
docs: add human-friendly documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:40 +00:00

10 KiB

title description
Architecture Internals of go-forge — the HTTP client, generic Resource pattern, pagination, code generation, and error handling.

Architecture

This document explains how go-forge is structured internally. It covers the layered design, key types, data flow for a typical API call, and how types are generated from the Forgejo swagger specification.

Layered design

go-forge is organised in three layers, each building on the one below:

┌─────────────────────────────────────────────────┐
│  Forge (top-level client)                       │
│  Aggregates 18 service structs                  │
├─────────────────────────────────────────────────┤
│  Service layer                                  │
│  RepoService, IssueService, PullService, ...    │
│  Embed Resource[T,C,U] or hold a *Client        │
├─────────────────────────────────────────────────┤
│  Foundation layer                               │
│  Client (HTTP), Resource[T,C,U] (generics),     │
│  Pagination, Params, Config                     │
└─────────────────────────────────────────────────┘

Client — the HTTP layer

Client (client.go) is the lowest-level component. It handles:

  • Authentication — every request includes an Authorization: token <token> header.
  • JSON marshalling — request bodies are marshalled to JSON; responses are decoded from JSON.
  • Error parsing — HTTP 4xx/5xx responses are converted to *APIError with status code, message, and URL.
  • Rate limit trackingX-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers are captured after each request.
  • Context propagation — all methods accept context.Context as their first parameter.

Key methods

func (c *Client) Get(ctx context.Context, path string, out any) error
func (c *Client) Post(ctx context.Context, path string, body, out any) error
func (c *Client) Patch(ctx context.Context, path string, body, out any) error
func (c *Client) Put(ctx context.Context, path string, body, out any) error
func (c *Client) Delete(ctx context.Context, path string) error
func (c *Client) DeleteWithBody(ctx context.Context, path string, body any) error
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error)
func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, error)

The Raw variants return the response body as []byte instead of decoding JSON. This is used by endpoints that return non-JSON content (e.g. the markdown rendering endpoint returns raw HTML).

Options

The client supports functional options:

f := forge.NewForge("https://forge.lthn.ai", "token",
    forge.WithHTTPClient(customHTTPClient),
    forge.WithUserAgent("my-agent/1.0"),
)

APIError — structured error handling

All API errors are returned as *APIError:

type APIError struct {
    StatusCode int
    Message    string
    URL        string
}

Three helper functions allow classification without type-asserting:

forge.IsNotFound(err)  // 404
forge.IsForbidden(err) // 403
forge.IsConflict(err)  // 409

These use errors.As internally, so they work correctly with wrapped errors.

Params — path variable resolution

API paths contain {placeholders} (e.g. /api/v1/repos/{owner}/{repo}). The Params type is a map[string]string that resolves these:

type Params map[string]string

path := forge.ResolvePath("/api/v1/repos/{owner}/{repo}", forge.Params{
    "owner": "core",
    "repo":  "go-forge",
})
// Result: "/api/v1/repos/core/go-forge"

Values are URL-path-escaped to handle special characters safely.

Resource[T, C, U] — the generic CRUD core

The heart of the library is Resource[T, C, U] (resource.go), a generic struct parameterised on three types:

  • T — the resource type (e.g. types.Repository)
  • C — the create-options type (e.g. types.CreateRepoOption)
  • U — the update-options type (e.g. types.EditRepoOption)

It provides seven methods that map directly to REST operations:

Method HTTP verb Description
List GET Single page of results with metadata
ListAll GET All results across all pages
Iter GET iter.Seq2[T, error] iterator over all
Get GET Single resource by path params
Create POST Create a new resource
Update PATCH Modify an existing resource
Delete DELETE Remove a resource

Services embed this struct to inherit CRUD for free:

type RepoService struct {
    Resource[types.Repository, types.CreateRepoOption, types.EditRepoOption]
}

func newRepoService(c *Client) *RepoService {
    return &RepoService{
        Resource: *NewResource[types.Repository, types.CreateRepoOption, types.EditRepoOption](
            c, "/api/v1/repos/{owner}/{repo}",
        ),
    }
}

Services that do not fit the CRUD pattern (e.g. AdminService, LabelService, NotificationService) hold a *Client directly and implement methods by hand.

Pagination

pagination.go provides three generic functions for paginated endpoints:

ListPage — single page

func ListPage[T any](ctx context.Context, c *Client, path string, query map[string]string, opts ListOptions) (*PagedResult[T], error)

Returns a PagedResult[T] containing:

  • Items []T — the results on this page
  • TotalCount int — from the X-Total-Count response header
  • Page int — the current page number
  • HasMore bool — whether more pages exist

ListOptions controls the page number (1-based) and items per page:

var DefaultList = ListOptions{Page: 1, Limit: 50}

ListAll — all pages

func ListAll[T any](ctx context.Context, c *Client, path string, query map[string]string) ([]T, error)

Fetches every page sequentially and returns the concatenated slice. Uses a page size of 50.

ListIter — range-over-func iterator

func ListIter[T any](ctx context.Context, c *Client, path string, query map[string]string) iter.Seq2[T, error]

Returns a Go 1.23+ range-over-func iterator that lazily fetches pages as you consume items:

for repo, err := range f.Repos.IterOrgRepos(ctx, "core") {
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(repo.Name)
}

Data flow of a typical API call

Here is the path a call like f.Repos.Get(ctx, params) takes:

1. Caller invokes f.Repos.Get(ctx, Params{"owner":"core", "repo":"go-forge"})
2. Resource.Get calls ResolvePath(r.path, params)
       "/api/v1/repos/{owner}/{repo}" -> "/api/v1/repos/core/go-forge"
3. Resource.Get calls Client.Get(ctx, resolvedPath, &out)
4. Client.doJSON builds http.Request:
       - Method: GET
       - URL: baseURL + resolvedPath
       - Headers: Authorization, Accept, User-Agent
5. Client.httpClient.Do(req) sends the request
6. Client reads response:
       - Updates rate limit from headers
       - If status >= 400: parseError -> return *APIError
       - If status < 400: json.Decode into &out
7. Resource.Get returns (*T, error) to caller

Code generation — types/

The types/ package contains 229 Go types generated from Forgejo's swagger.v1.json specification. The code generator lives at cmd/forgegen/.

Pipeline

swagger.v1.json  -->  parser.go  -->  GoType/GoField IR  -->  generator.go  -->  types/*.go
                      LoadSpec()      ExtractTypes()          Generate()
                                      DetectCRUDPairs()
  1. parser.goLoadSpec() reads the JSON spec. ExtractTypes() converts swagger definitions into an intermediate representation (GoType with GoField children). DetectCRUDPairs() finds matching Create*Option/Edit*Option pairs.

  2. generator.goGenerate() groups types by domain using prefix matching (the typeGrouping map), then renders each group into a separate .go file using text/template.

Running the generator

go generate ./types/...

Or manually:

go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/

The generate.go file in types/ contains the //go:generate directive:

//go:generate go run ../cmd/forgegen/ -spec ../testdata/swagger.v1.json -out .

Type grouping

Types are distributed across 36 files based on a name-prefix mapping. For example, types starting with Repository or Repo go into repo.go, types starting with Issue go into issue.go, and so on. The classifyType() function also strips Create/Edit/Delete prefixes and Option suffixes before matching, so CreateRepoOption lands in repo.go alongside Repository.

Type mapping from swagger to Go

Swagger type Swagger format Go type
string (none) string
string date-time time.Time
string binary []byte
integer int64 int64
integer int32 int32
integer (none) int
number float float32
number (none) float64
boolean bool
array []T
object map[string]any
$ref *RefType

Config resolution

config.go provides ResolveConfig() which resolves the Forgejo URL and API token with the following priority:

  1. Explicit flag values (passed as function arguments)
  2. Environment variables (FORGE_URL, FORGE_TOKEN)
  3. Built-in defaults (http://localhost:3000 for URL; no default for token)

NewForgeFromConfig() wraps this into a one-liner that returns an error if no token is available from any source.