Releases: caspel26/goninja
Release list
v0.7.0
Added
- Partial updates now use generated patch models with field-presence tracking, so omitted fields are not overwritten.
- Runtime, OpenAPI, Chi, Echo, and Gin integrations support the partial-update flow.
- The generator now performs semantic validation of field types before writing output, including relation-target validation.
- Added documentation and prototype coverage for partial updates.
v0.6.2
Fixed
- The generator now rejects unresolved relation types, named scalar types, and external types before it writes generated files.
- Relations must target an annotated goninja model in the same generation run, preventing invalid generated references such as
StatusRetrieve. - Validation retains the no-partial-output guarantee: invalid models leave the output directory unchanged.
Verification
go test ./...
v0.6.1
Fixed
- Generated list queries now use GORM column names instead of public JSON names.
SELECT, filters, and ordering honourgorm:"column:..."or GORM’s default naming strategy, while API and OpenAPI names stay unchanged.
Added
- A Performance & Benchmarks guide with the tracked request-path baselines, raw samples, run commands, and CI regression gate. The figures are documented as reproducible workload baselines, not cross-framework throughput claims.
v0.6.0
Added
-
Listnow selects only the<Model>Listcolumns, notSELECT *—internal/codegen/ir.go,internal/codegen/templates/model.go.tmplA generated
Listquery read every column of every row via a plain
Find(&items), then discarded whatever<Model>Listdoesn't carry once
scanning it into the DB-shaped struct — on a model with a large
retrieve-only field (a bio, a body, a blob), real per-request I/O
spent reading data the response never contains.Model.ListSelectColumns
derives thelist-tagged column list at generation time; the generated
file gets a<model>ListColumnswhitelist andList's query becomes
q.Select(<model>ListColumns).Limit(...).Offset(...).Find(&items).
Applied afterCount, sototalstill reflectsCOUNT(*)over every
matching row, unaffected by the narrowerSelect.Skipped (falling back to the original
SELECT *) when alist-tagged
field is itself a relation, since a relation isn't a column and can't be
named in aSELECT—Listnever preloads relations regardless (see
Phase 0's list/retrieve split), so that field is already empty in a list
response either way; the fallback only avoids emitting a broken query.
Verified with a realgormquery-logger capture overAuthorResource
(examples/prototype/list_select_test.go): the emittedSELECTnames
onlyid, name, created_at, neverbio(AuthorRetrieve-only), and the
COUNTquery is unaffected.
Changed
-
OpenAPI construction moved out of generated code into
goninja.BuildResourceOpenAPI—resource_openapi.go(new),internal/codegen/templates/model.go.tmplEvery generated file carried ~165 lines building its own OpenAPI paths,
operations, list envelope, id parameter and per-route security — code
where only the strings differed between models. That contradicted the
architecture the rest of the runtime already follows: a generated
handler callsRespondJSON/Validaterather than carrying a copy, so a
fix lands once instead of per model.Generated
OpenAPI()now passes the genuinely per-model half — the
List/Retrieve/Create/Update schemas and thefilter-derived query
parameters — as agoninja.ResourceDoc, and the shared structure lives
in the runtime. A generated file drops ~25% (the prototype's three
models: 2362 → 1780 lines). Verified behaviour-preserving by diffing the
full generated OpenAPI document before and after: byte-identical except
the intended grammar fix below.The tradeoff, stated plainly: OpenAPI construction is no longer readable
in your own repo. It runs once at mount rather than per request, and the
whole request path stays generated and inspectable, so this doesn't
touch the "read the code that serves your API" guarantee — but it is
less code you can read locally. -
Registerand the OpenAPI document now sharegoninja.ActionPath—resource_openapi.goBoth computed an Action's mount path independently (
<base>, plus
/{id}whenDetail, plusUrlPath). They now call one function, so a
route's mounted path and its documented path can't drift apart.
Fixed
-
Operation summaries read "Create an author", not "Create a author" —
resource_openapi.goThe generated summary hardcoded
"a ". Now chosen from the model name,
including the "u"-sounds-like-"you" cases so the most common model name
of all reads "Create a user" rather than "Create an user". Fixing it in
the runtime means it lands for existing generated code on the next
regeneration, rather than being baked into every consumer's files. -
A
time.Timefilterfield made the generated file import"strconv"without using it —internal/codegen/ir.goA regression introduced by v0.5.0's own
time.Timefilter fix.
Model.NeedsStrconvcounted every non-stringfilterfield as needing
"strconv"— correct before that fix, when anything not
bool/string/float fell through to astrconv.ParseIntbranch, but a
time.Timefield now parses withtime.Parseand never touches
strconv. A model whose only non-stringfilterfield is a
time.Timetherefore imported it and never used it:"strconv" imported and not used, a hard compile error. Found by the same
external consumer project that reported the v0.5.0 bug this fix
introduced.TestGenerate_EveryScalarFilterTypeCompiles, added in v0.5.0 to catch
exactly this class of bug, provably could not: it puts every scalar
type in one model, so bool/int/float fields always make"strconv"
legitimately used and mask the unused-import case. The new
TestGenerate_EachScalarFilterTypeCompilesAlonecompiles one model per
type in isolation, which is what actually pins import correctness —
verified by reverting the fix and confirming the per-type test fails on
the exact error while the all-types one still passes.
examples/prototype/models/author.go'sCreatedAtis now permanently
filter-tagged as the end-to-end proof (Authorhas onlystring
filters otherwise, so it reproduces the shape;Bookcan't — its
float/bool filters mask it), withauthor_resource_test.gocovering
the runtime half over real HTTP.
v0.5.0
Added
-
Action.Auth,Config.StrictAuth,goninja.Actions, and variadicNew<Model>Resourceconstructors —actions.go,config.go,resource.go,internal/codegen/templates/model.go.tmplAction.Auth *RouteAuth, if set, decides an action's auth directly,
right where the action is declared —ProtectAction/SecurityForAction
consult it before falling back toResourceConfig.Auth/
Config.DefaultAuth.Routeskeyed byRoute(a.Name)(the only option
before this). Found via a real external consumer project: a growing set
of custom actions each needing to be re-listed by name in a separate
auth policy is easy to get out of sync with — add a new action, forget
to list it there, and it's silently public.Config.StrictAuth: truecatches exactly that class of mistake (for
CRUD routes too, not just actions) at startup instead of silently:
generatedRegister(mux)now callsBaseResource.CheckStrictAuthwith
every route it's about to mount, before mounting any of them, and it
panics naming every one with no explicit auth decision anywhere — not
protected-or-public, just undecided.RouteAuth{Public: true}is
itself an explicit decision and doesn't trigger it; only a route nobody
mentioned at all does.false(the default) changes nothing for
existing apps.Every generated
New<Model>Resource(db *gorm.DB)is now
New<Model>Resource(db *gorm.DB, opts ...<Model>Option)— variadic, so
every existing zero-option call site keeps compiling unchanged.
goninja.Actions[R interface{ SetActions(...Action) }, A any](build func(R, A) []Action, arg A) func(R)builds one of these
Options from an action-builder function, so actions attach right at
construction —api.NewBookResource(db, goninja.Actions(bookActions, actionAuth))— instead of a separate
SetActionscall afterward.examples/prototype/main.gouses this for
bothTaskandBook.
Fixed
-
A
filter-taggedtime.Timefield failed to compile —internal/codegen/templates/model.go.tmpl,internal/codegen/ir.goparse<Model>Filters' exact-match branch only special-cased
bool/string/float; atime.Timefield fell through to the int64
fallback, generating the invalid conversiontime.Time(n). Found via a
real external consumer project filtering on a timestamp field — exactly
the kind of bug dogfooding was meant to surface. Fixed by special-casing
time.Timetotime.Parse(time.RFC3339, v);Model.UsesTime()also
needed to recognize afilter-onlytime.Timefield (previously only
checkedlist/retrieve/create/update) so"time"gets imported
in that case too.examples/prototype/models/book.go'sCreatedAtis
now permanentlyfilter-tagged as the regression proof (was
list,retrieveonly);internal/codegen's
TestGenerate_EveryScalarFilterTypeCompilesactually compiles a
generated file covering every recognized scalar type in one pass —
every prior codegen test only checked generated source text, which is
why this slipped through:go/format.Sourceformats syntax, it doesn't
type-check.
v0.4.0
Added
-
API.SetErrorMapper,Config.DefaultErrorMapper, andgoninja.NewErrorMapper/NewErrorMapping—api.go,config.go,mapper.goAPI.SetErrorMapper(mappings ...ErrorMapping)registers one or more
per-error-type handlers on the app object itself, applied by both
MountandMountWithConfigto every resource that hasn't called its
ownSetErrorMapper, without requiring aConfigbuilt by hand just to
reachMountWithConfig. It takesErrorMappings rather than a whole
ErrorMapperso mappings from different files compose safely into one
list: a plainErrorMapperhas no way to say "I didn't recognize this
error, try the next one" (DefaultErrorMapperanswers every error), so
chaining wholeErrorMappers would let an earlier one silently swallow
everything after it —ErrorMapping's ownMatchesavoids that.
Config.DefaultErrorMapperstill exists for setting a whole
ErrorMapperexplicitly on theConfigpassed toMountWithConfig
(wins overAPI.SetErrorMapperwhen both are set — a resource's own
BaseResource.SetErrorMapperstill takes a wholeErrorMappertoo, for
full control at that scope).NewErrorMapper/NewErrorMapping[T]build
anErrorMapping/compose them into a plainErrorMapper, matching via
errors.AslikeDefaultErrorMapperitself, instead of a hand-written
MapErrorswitch. Resolution order per resource: its own
SetErrorMapperwins if set, elseConfig.DefaultErrorMapper
(explicit, orAPI.SetErrorMapper's value), else the package
DefaultErrorMapper.
v0.3.1
Changed
-
docsui.SpecSourcerenamed todocsui.SpecProvider—docsui/docs.goA SonarQube naming-convention fix (single-method interfaces should end
in-er), matching the existingopenapi.OpenAPIProviderprecedent. A
breaking rename for any caller referencing the type by name directly —
pre-alpha, no compatibility shim. -
adapters/{gin,echo,chi}test suites excluded from the duplication metric —sonar-project.propertiesadapter_test.go/docs_test.goare intentionally near-identical across
the three adapter modules (the same httptest suite written once and
adapted per router, per each being a deliberately separate Go module
with no shared test dependency) — this was tripping the
new_duplicated_lines_densityquality gate despite the actual adapter
implementation code having 0% duplication. No code changes.
v0.3.0
Added
-
Router adapters for gin, echo, and chi —
router(new),adapters/gin,adapters/echo,adapters/chi(new modules)goninja.Resource.Registernow mounts ongoninja.Router, a one-method
interface*http.ServeMuxalready satisfies — plainnet/httpusage is
unchanged. Each adapter translates a generated route's stdlib-style
pattern ("GET /books/{id}") into its router's own syntax and binds the
matched path value back onto the request viaSetPathValue, so a
generated handler'sreq.PathValue("id")call needs no changes at all —
only route registration is router-specific. Each adapter is its own Go
module (owngo.mod), so gin/echo/chi are never a dependency of a plain
net/httpproject. -
Benchmark suite —
examples/prototype/benchmark_test.goThree benchmarks (
go test -bench=.viamake bench) covering base
list serialization, filter-clause building, and the automatic-Preload
costRetrievepays on a relation field — the baseline for future
optimization work. -
Benchmark regression check in CI —
scripts/bench-regression.sh,scripts/testdata/bench-baseline.txt,.github/workflows/bench.ymlmake bench-checkruns the benchmark suite (-count=10) and compares
it against the committed baseline withbenchstat, failing a PR if any
benchmark'ssec/op/B/op/allocs/opregresses by more than 25% and
the difference is statistically significant (benchstat's own~
already filters out noise below that). The comparison table is also
written to the GitHub Actions job summary, so a reviewer sees the actual
numbers, not just pass/fail, and a self-contained HTML report
(reports/bench-report.html, gitignored) is uploaded as a CI artifact
for a more readable view than raw logs.make bench-baselinemoves the
baseline deliberately, after confirming a numbers change is expected. -
make bench-profile—MakefileRuns the benchmark suite with
-cpuprofile/-memprofileand prints a
top-10go tool pprofsummary for each, for finding what's actually
worth optimizing rather than just whether something regressed.
Changed
-
Resource.Registertakesgoninja.Routerinstead of*http.ServeMux—api.go,docsui/docs.go,internal/codegen/templates/model.go.tmplA breaking interface change for any hand-written
Resource(not
generated ones, which pick this up automatically on regeneration) —
pre-alpha, no compatibility shim, same approach as the 0.2.0 auth
redesign.
v0.2.0
Warning
Pre-alpha. Everything below is implemented and tested, but the API may
change without notice and there is no compatibility guarantee yet.
Install
go install github.com/caspel26/goninja/cmd/goninja@v0.2.0
go get github.com/caspel26/goninja@v0.2.0Requires Go 1.25+.
What's new since v0.1.0
Explicit generator validation. goninja generate now rejects a model it
cannot turn into working code, instead of silently emitting a file that
fails to compile. Every problem across every model is reported in one run,
naming the offending file and field, and nothing is written when validation
fails. Rejected: a missing goninja-tagged ID field, an ID typed
anything but int64/string, a pointer relation field, byid on a
non-relation field, and filter on a relation field.
?order= is validated, not silently ignored. An unrecognized ordering
field used to fall through and return unordered results with a 200 —
indistinguishable from a correctly sorted response. It now returns a 400
before the query ever runs. The whitelist that makes ordering
injection-safe is unchanged.
goninja.CodedError and an optional Code field. NotFound,
ValidationError, BadRequest, and the new Unauthorized error type all
implement CodedError (error plus ErrorCode() string). Left unset, each
keeps its existing default JSON "code"; setting Code lets a specific
failure carry a more precise machine-readable identifier than the HTTP
status alone provides — the new order-validation error, for example, sets
Code: "INVALID_ORDER_FIELD".
goninja.Unauthorized, and a consistent 401 body. Every configured
Authenticator declining a request used to produce a plain-text 401 via
http.Error. It now goes through the same Respond path as every other
framework error, returning
{"code":"UNAUTHORIZED","error":"unauthorized"} like the rest.
Versioned documentation. goninja.dev now serves
the current working tree at the root and a frozen snapshot of each released
minor series at /vX.Y/, switchable from a navbar selector that also shows
the live GitHub star count. A Changelog
page was added to the site, searchable by version, alongside the root
CHANGELOG.md.
CI hardening. GitHub Actions workflow permissions are now scoped
per-job rather than granted workflow-wide.
Documentation
goninja.dev — Getting
Started ·
Guides · Tag
Reference ·
Examples ·
Changelog
Known limitations
- GORM and
net/httpare assumed; there's no adapter layer for other ORMs or
routers. - A model's primary key must be a field literally named
ID(int64or a
stringUUID). - Relation fields must be a struct value or a slice of one — pointer relations
aren't supported. - No OpenAPI example values are generated.
Full diff: v0.1.0...v0.2.0
v0.1.0 — first pre-alpha release
Annotate a Go struct, run one command, and get a complete CRUD REST API — as
ordinary Go source you commit and can read.
Warning
Pre-alpha. Everything below is implemented and tested, but the API may
change without notice and there is no compatibility guarantee yet.
Install
go install github.com/caspel26/goninja/cmd/goninja@v0.1.0
go get github.com/caspel26/goninja@v0.1.0Requires Go 1.25+.
What's in it
Generation. goninja generate writes one <model>_generated.go per
model — separate List/Retrieve/Create/Update output types, handlers,
queries and an OpenAPI fragment, all go/format-ed. -watch regenerates on
save.
Querying. filter-tagged fields become exact-match filters, with
_min/_max range filters on numeric fields. Limit/offset pagination behind
a {items, total, limit, offset} envelope, and ?order=-field ordering
resolved against a per-model whitelist.
Relations without N+1. list never preloads; retrieve preloads every
relation it carries. Belongs-to and has-many are both supported, and the
byid modifier exposes a related ID instead of nesting the full object.
Validation and errors. validate tags apply to input only, returning
per-field 422s. NotFound/ValidationError/BadRequest map to 404/422/400
through a pluggable ErrorMapper; anything else is a generic 500 that never
leaks the underlying error.
Auth. Authenticator objects, tried in order, with per-route policy —
and the security schemes they describe end up in the generated OpenAPI
document, so what's enforced and what's documented can't drift apart.
HTTPBearer, HTTPBasic, APIKeyHeader and CookieKey ship built in.
Extension points. Before/after hooks, method overrides, custom mount
paths, restricted route sets, and Action for non-CRUD endpoints — none of
which require touching a generated file. Writes run inside a transaction.
Docs UI. One MountDocs call serves the merged OpenAPI document plus a
rendered UI. Swagger UI and ReDoc are both embedded — no external CDN — and
DocsUI is an interface if you want something else.
Testing. goninjatest.NewDB and NewServer drive a real generated
resource over HTTP against in-memory SQLite, no Postgres required.
Documentation
goninja.dev — Getting
Started ·
Guides · Tag
Reference ·
Examples
Known limitations
- GORM and
net/httpare assumed; there's no adapter layer for other ORMs or
routers. - A model's primary key must be a field literally named
ID(int64or a
stringUUID). - Relation fields must be a struct value or a slice of one — pointer relations
aren't supported. - An unknown
?order=field is ignored rather than rejected. - No OpenAPI example values are generated.