Development
This guide covers the tools, setup, and conventions needed to work on OSAPI.
Prerequisites
Install tools using mise:
mise install
- Go - OSAPI is written in Go. We always support the latest two major Go versions, so make sure your version is recent enough.
- Node.js - Required as a runtime for tools like
@redocly/cli. - Bun - JavaScript package manager used for Docusaurus docs and installing tooling.
- just - Task runner used for building, testing, formatting, and other
development workflows. Install with
brew install just. - NATS CLI - Command-line tools for interacting with NATS. Useful for debugging
and monitoring during development. Install with
brew install nats-io/nats-tools/nats.
Claude Code
If you use Claude Code for development, install these plugins from the default marketplace:
/plugin install commit-commands@claude-plugins-official
/plugin install superpowers@claude-plugins-official
- commit-commands — provides
/commitand/commit-push-prslash commands that follow the project's commit conventions automatically. - superpowers — provides structured workflows for planning, TDD, debugging, code review, and git worktree isolation.
Setup
Fetch shared justfiles and install all dependencies:
just fetch
just deps
Code style
Go code should be formatted by gofumpt and linted using
golangci-lint. Markdown and TypeScript files should be
formatted and linted by Prettier. This style is enforced by CI.
just go::fmt-check # Check formatting
just go::fmt # Auto-fix formatting
just go::vet # Run linter
Running your changes
To run OSAPI with working changes:
go run main.go overlay
Documentation
OSAPI uses Docusaurus to host a documentation server. Content is written in
Markdown and located in the docs/docs directory. All Markdown documents should
have an 80 character line wrap limit (enforced by Prettier).
just docs::start # Start local docs server (requires bun)
just docs::build # Build docs for production
just docs::fmt-check # Check docs formatting
Testing
See the Testing page for details on running tests and listing just recipes.
just test # Run all tests (lint + unit + coverage)
just go::unit # Run unit tests only
just go::unit-int # Run integration tests (requires running osapi)
Unit tests should follow the Go convention of being located in a file named
*_test.go in the same package as the code being tested. Integration tests are
located in test/integration/ and use a //go:build integration tag. They
build and start a real osapi binary, so they require no external setup.
Use testify/suite with table-driven patterns and validateFunc callbacks.
One suite method per function under test. All scenarios for a function
(success, error codes, transport failures, nil responses) belong as rows in a
single table — never split into separate TestFoo, TestFooError,
TestFooNilResponse methods.
File naming
Avoid generic file names like helpers.go or utils.go. Name files after what
they contain.
Input Validation
All user input is validated through the internal/validation package, which
wraps go-playground/validator. Validation rules are declared in OpenAPI specs
via x-oapi-codegen-extra-tags and enforced at runtime by handler calls to
validation.Struct() or validation.Var().
Validation rules
- Required fields use
validate: "required,..."— the field must be present and non-zero. - Optional fields use
validate: "omitempty,..."— validation is skipped when the field is absent or zero-valued. - Enum constraints use
validate: "oneof=a b c"to restrict values. - Cross-field validation uses
required_without/excluded_withfor mutually exclusive fields (e.g., cronschedulevsinterval).
Update endpoints with all-optional fields
When a PUT endpoint has all optional fields (e.g., user update, group update,
cron update), use validation.AtLeastOneField(request.Body) to reject empty
bodies with a 400. This prevents clients from sending meaningless no-op updates
or, worse, triggering destructive defaults. Place this call after
validation.Struct():
if errMsg, ok := validation.Struct(request.Body); !ok {
return gen.PutXxx400JSONResponse{Error: &errMsg}, nil
}
if errMsg, ok := validation.AtLeastOneField(request.Body); !ok {
return gen.PutXxx400JSONResponse{Error: &errMsg}, nil
}
Defense-in-depth pattern
When validation.Struct() cannot currently fail (all fields use omitempty),
keep the call with a comment explaining why. This guards against future field
additions breaking validation silently:
// Defense in depth: current fields use omitempty so validation
// always passes, but guards against future field additions.
if errMsg, ok := validation.Struct(request.Body); !ok {
return gen.PostXxx400JSONResponse{Error: &errMsg}, nil
}
This pattern applies to action endpoints (power, docker stop) where an empty
body is valid — unlike update endpoints which must use AtLeastOneField.
Before committing
Run just ready before committing to ensure generated code, package docs,
formatting, and lint are all up to date:
just ready
Branching
All changes should be developed on feature branches. Create a branch from main
using the naming convention type/short-description, where type matches the
Conventional Commits type:
feat/add-retry-logicfix/null-pointer-crashdocs/update-api-referencerefactor/simplify-handlerchore/update-dependencies
When using Claude Code's /commit command, a branch will be created
automatically if you are on main.
Commit messages
Follow Conventional Commits with the 50/72 rule:
- Subject line: max 50 characters, imperative mood, capitalized, no period
- Body: wrap at 72 characters, separated from subject by a blank line
- Format:
type(scope): description - Types:
feat,fix,docs,style,refactor,perf,test,chore - Summarize the "what" and "why", not the "how"
Try to write meaningful commit messages and avoid having too many commits on a PR. Most PRs should likely have a single commit (although for bigger PRs it may be reasonable to split it in a few). Git squash and rebase is your friend!