diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3b1162c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,281 @@ +--- +title: Architecture +description: 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 ` 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 tracking** — `X-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 + +```go +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: + +```go +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`: + +```go +type APIError struct { + StatusCode int + Message string + URL string +} +``` + +Three helper functions allow classification without type-asserting: + +```go +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: + +```go +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: + +```go +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 + +```go +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: + +```go +var DefaultList = ListOptions{Page: 1, Limit: 50} +``` + +### ListAll — all pages + +```go +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 + +```go +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: + +```go +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.go** — `LoadSpec()` 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.go** — `Generate()` 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 + +```bash +go generate ./types/... +``` + +Or manually: + +```bash +go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/ +``` + +The `generate.go` file in `types/` contains the `//go:generate` directive: + +```go +//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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ae9f377 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,232 @@ +--- +title: Development +description: Building, testing, linting, and contributing to go-forge. +--- + +# Development + +This guide covers everything needed to build, test, and contribute to go-forge. + + +## Prerequisites + +- **Go 1.26** or later +- **golangci-lint** (recommended for linting) +- A Forgejo instance and API token (only needed for manual/integration testing — the test suite uses `httptest` and requires no live server) + + +## Building + +go-forge is a library, so there is nothing to compile for normal use. The only binary in the repository is the code generator: + +```bash +go build ./cmd/forgegen/ +``` + +The `core build` CLI can also produce cross-compiled binaries of the generator for distribution. Build configuration is in `.core/build.yaml`: + +```bash +core build # Builds forgegen for all configured targets +``` + + +## Running tests + +All tests use the standard `testing` package with `net/http/httptest` for HTTP stubbing. No live Forgejo instance is required. + +```bash +# Run the full suite +go test ./... + +# Run a specific test by name +go test -v -run TestClient_Good_Get ./... + +# Run tests with race detection +go test -race ./... + +# Generate a coverage report +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +Alternatively, if you have the `core` CLI installed: + +```bash +core go test +core go cov # Generate coverage +core go cov --open # Open coverage report in browser +``` + +### Test naming convention + +Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern: + +- **`_Good`** — Happy-path tests confirming correct behaviour. +- **`_Bad`** — Expected error conditions (e.g. 404, 500 responses). +- **`_Ugly`** — Edge cases, panics, and boundary conditions. + +Examples: +``` +TestClient_Good_Get +TestClient_Bad_ServerError +TestClient_Bad_NotFound +TestClient_Good_ContextCancellation +TestResource_Good_ListAll +``` + + +## Linting + +The project uses `golangci-lint` with the configuration in `.golangci.yml`: + +```bash +golangci-lint run ./... +``` + +Or with the `core` CLI: + +```bash +core go lint +``` + +Enabled linters: `govet`, `errcheck`, `staticcheck`, `unused`, `gosimple`, `ineffassign`, `typecheck`, `gocritic`, `gofmt`. + + +## Formatting and vetting + +```bash +gofmt -w . +go vet ./... +``` + +Or: + +```bash +core go fmt +core go vet +``` + +For a full quality-assurance pass (format, vet, lint, and test): + +```bash +core go qa # Standard checks +core go qa full # Adds race detection, vulnerability scanning, and security checks +``` + + +## Regenerating types + +When the Forgejo API changes (after a Forgejo upgrade), regenerate the types: + +1. Download the updated `swagger.v1.json` from your Forgejo instance at `/swagger.json` and place it in `testdata/swagger.v1.json`. + +2. Run the generator: + ```bash + go generate ./types/... + ``` + + Or manually: + ```bash + go run ./cmd/forgegen/ -spec testdata/swagger.v1.json -out types/ + ``` + +3. Review the diff. The generator produces deterministic output (types are sorted alphabetically within each file), so the diff will show only genuine API changes. + +4. Run the test suite to confirm nothing is broken. + +The `types/generate.go` file holds the `//go:generate` directive that wires everything together: + +```go +//go:generate go run ../cmd/forgegen/ -spec ../testdata/swagger.v1.json -out . +``` + + +## Adding a new service + +To add coverage for a new Forgejo API domain: + +1. **Create the service file** (e.g. `topics.go`). If the API follows a standard CRUD pattern, embed `Resource[T, C, U]`: + + ```go + type TopicService struct { + Resource[types.Topic, types.CreateTopicOption, types.EditTopicOption] + } + + func newTopicService(c *Client) *TopicService { + return &TopicService{ + Resource: *NewResource[types.Topic, types.CreateTopicOption, types.EditTopicOption]( + c, "/api/v1/repos/{owner}/{repo}/topics/{topic}", + ), + } + } + ``` + + If the endpoints are heterogeneous, hold a `*Client` directly instead: + + ```go + type TopicService struct { + client *Client + } + ``` + +2. **Add action methods** for any operations beyond standard CRUD: + + ```go + func (s *TopicService) ListRepoTopics(ctx context.Context, owner, repo string) ([]types.Topic, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo) + return ListAll[types.Topic](ctx, s.client, path, nil) + } + ``` + +3. **Wire it up** in `forge.go`: + - Add a field to the `Forge` struct. + - Initialise it in `NewForge()`. + +4. **Write tests** in `topics_test.go` using `httptest.NewServer` to stub the HTTP responses. + +5. Every list method should provide both a `ListX` (returns `[]T`) and an `IterX` (returns `iter.Seq2[T, error]`) variant. + + +## Coding standards + +- **UK English** in all comments and documentation: organisation, colour, centre, licence (noun). +- **`context.Context`** as the first parameter of every exported method. +- **Errors** wrapped as `*APIError` with `StatusCode`, `Message`, and `URL`. +- **Conventional commits**: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`. +- **Generated code** must not be edited by hand. All changes go through the swagger spec and the generator. +- **Licence**: EUPL-1.2. All contributions are licensed under the same terms. + + +## Commit messages + +Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +feat: add topic service for repository topics +fix: handle empty response body in DeleteWithBody +docs: update architecture diagram for pagination +refactor: extract rate limit parsing into helper +chore: regenerate types from Forgejo 10.1 swagger spec +``` + +Include the co-author trailer: + +``` +Co-Authored-By: Virgil +``` + + +## Project structure at a glance + +``` +. +├── .core/ +│ ├── build.yaml Build targets for the forgegen binary +│ └── release.yaml Release publishing configuration +├── .golangci.yml Linter configuration +├── cmd/forgegen/ Code generator (swagger -> Go types) +├── testdata/ swagger.v1.json spec file +├── types/ 229 generated types (36 files) +├── *.go Library source (client, services, pagination) +└── *_test.go Tests (httptest-based, no live server needed) +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..53ce237 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,151 @@ +--- +title: go-forge +description: Full-coverage Go client for the Forgejo API with generics-based CRUD, pagination, and code-generated types. +--- + +# go-forge + +`forge.lthn.ai/core/go-forge` is a Go client library for the [Forgejo](https://forgejo.org) REST API. It provides typed access to 18 API domains (repositories, issues, pull requests, organisations, and more) through a single top-level `Forge` client. Types are generated directly from Forgejo's `swagger.v1.json` specification, keeping the library in lockstep with the server. + +**Module path:** `forge.lthn.ai/core/go-forge` +**Go version:** 1.26+ +**Licence:** EUPL-1.2 + + +## Quick start + +```go +package main + +import ( + "context" + "fmt" + "log" + + "forge.lthn.ai/core/go-forge" +) + +func main() { + // Create a client with your Forgejo URL and API token. + f := forge.NewForge("https://forge.lthn.ai", "your-token") + + ctx := context.Background() + + // List repositories for an organisation (first page, 50 per page). + result, err := f.Repos.List(ctx, forge.Params{"org": "core"}, forge.DefaultList) + if err != nil { + log.Fatal(err) + } + for _, repo := range result.Items { + fmt.Println(repo.Name) + } + + // Get a single repository. + repo, err := f.Repos.Get(ctx, forge.Params{"owner": "core", "repo": "go-forge"}) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s — %s\n", repo.FullName, repo.Description) +} +``` + +### Configuration from environment + +If you prefer to resolve the URL and token from environment variables rather than hard-coding them, use `NewForgeFromConfig`: + +```go +// Priority: flags > env (FORGE_URL, FORGE_TOKEN) > defaults (http://localhost:3000) +f, err := forge.NewForgeFromConfig("", "", forge.WithUserAgent("my-tool/1.0")) +if err != nil { + log.Fatal(err) // no token configured +} +``` + +Environment variables: + +| Variable | Purpose | Default | +|---------------|--------------------------------------|--------------------------| +| `FORGE_URL` | Base URL of the Forgejo instance | `http://localhost:3000` | +| `FORGE_TOKEN` | API token for authentication | (none -- required) | + + +## Package layout + +``` +go-forge/ +├── client.go HTTP client, auth, error handling, rate limits +├── config.go Config resolution: flags > env > defaults +├── forge.go Top-level Forge struct aggregating all 18 services +├── resource.go Generic Resource[T, C, U] for CRUD operations +├── pagination.go ListPage, ListAll, ListIter — paginated requests +├── params.go Path variable resolution ({owner}/{repo} -> values) +├── repos.go RepoService — repositories, forks, transfers, mirrors +├── issues.go IssueService — issues, comments, labels, reactions +├── pulls.go PullService — pull requests, merges, reviews +├── orgs.go OrgService — organisations, members +├── users.go UserService — users, followers, stars +├── teams.go TeamService — teams, members, repositories +├── admin.go AdminService — site admin, cron, user management +├── branches.go BranchService — branches, branch protections +├── releases.go ReleaseService — releases, assets, tags +├── labels.go LabelService — repo and org labels +├── webhooks.go WebhookService — repo and org webhooks +├── notifications.go NotificationService — notifications, threads +├── packages.go PackageService — package registry +├── actions.go ActionsService — CI/CD secrets, variables, dispatches +├── contents.go ContentService — file read/write/delete +├── wiki.go WikiService — wiki pages +├── commits.go CommitService — statuses, notes +├── misc.go MiscService — markdown, licences, gitignore, version +├── types/ 229 generated Go types from swagger.v1.json +│ ├── generate.go go:generate directive +│ ├── repo.go Repository, CreateRepoOption, EditRepoOption, ... +│ ├── issue.go Issue, CreateIssueOption, ... +│ ├── pr.go PullRequest, CreatePullRequestOption, ... +│ └── ... (36 files total, grouped by domain) +├── cmd/forgegen/ Code generator: swagger spec -> types/*.go +│ ├── main.go CLI entry point +│ ├── parser.go Swagger spec parsing, type extraction, CRUD pair detection +│ └── generator.go Template-based Go source file generation +└── testdata/ + └── swagger.v1.json Forgejo API specification (input for codegen) +``` + + +## Services + +The `Forge` struct exposes 18 service fields, each handling a different API domain: + +| Service | Struct | Embedding | Domain | +|-----------------|---------------------|----------------------------------|--------------------------------------| +| `Repos` | `RepoService` | `Resource[Repository, ...]` | Repositories, forks, transfers | +| `Issues` | `IssueService` | `Resource[Issue, ...]` | Issues, comments, labels, reactions | +| `Pulls` | `PullService` | `Resource[PullRequest, ...]` | Pull requests, merges, reviews | +| `Orgs` | `OrgService` | `Resource[Organization, ...]` | Organisations, members | +| `Users` | `UserService` | `Resource[User, ...]` | Users, followers, stars | +| `Teams` | `TeamService` | `Resource[Team, ...]` | Teams, members, repos | +| `Admin` | `AdminService` | (standalone) | Site admin, cron, user management | +| `Branches` | `BranchService` | `Resource[Branch, ...]` | Branches, protections | +| `Releases` | `ReleaseService` | `Resource[Release, ...]` | Releases, assets, tags | +| `Labels` | `LabelService` | (standalone) | Repo and org labels | +| `Webhooks` | `WebhookService` | `Resource[Hook, ...]` | Repo and org webhooks | +| `Notifications` | `NotificationService` | (standalone) | Notifications, threads | +| `Packages` | `PackageService` | (standalone) | Package registry | +| `Actions` | `ActionsService` | (standalone) | CI/CD secrets, variables, dispatches | +| `Contents` | `ContentService` | (standalone) | File read/write/delete | +| `Wiki` | `WikiService` | (standalone) | Wiki pages | +| `Commits` | `CommitService` | (standalone) | Commit statuses, git notes | +| `Misc` | `MiscService` | (standalone) | Markdown, licences, gitignore, version | + +Services that embed `Resource[T, C, U]` inherit `List`, `ListAll`, `Iter`, `Get`, `Create`, `Update`, and `Delete` methods automatically. Standalone services have hand-written methods because their API endpoints are heterogeneous and do not fit a uniform CRUD pattern. + + +## Dependencies + +This module has **zero external dependencies**. It relies solely on the Go standard library (`net/http`, `encoding/json`, `context`, `iter`, etc.) and requires Go 1.26 or later. + +``` +module forge.lthn.ai/core/go-forge + +go 1.26.0 +```