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 20 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
*APIErrorwith status code, message, and URL. - Rate limit tracking —
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders are captured after each request. - Context propagation — all methods accept
context.Contextas 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)
func (c *Client) HTTPClient() *http.Client
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 pageTotalCount int— from theX-Total-Countresponse headerPage int— the current page numberHasMore 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()
-
parser.go —
LoadSpec()reads the JSON spec.ExtractTypes()converts swagger definitions into an intermediate representation (GoTypewithGoFieldchildren).DetectCRUDPairs()finds matchingCreate*Option/Edit*Optionpairs. -
generator.go —
Generate()groups types by domain using prefix matching (thetypeGroupingmap), then renders each group into a separate.gofile usingtext/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:
- Explicit flag values (passed as function arguments)
- Environment variables (
FORGE_URL,FORGE_TOKEN) - Built-in defaults (
http://localhost:3000for URL; no default for token)
NewForgeFromConfig() wraps this into a one-liner that returns an error if no token is available from any source.