feat: add API / JSON endpoint generation (Roadmap 2.4)#279
Conversation
New command: lvt gen api <resource> <fields...>
Generates pure HTTP REST JSON endpoints — no LiveTemplate, no WebSocket.
New package: pkg/cors/
- CORS middleware with functional options (WithOrigins, WithMethods, etc.)
- Preflight OPTIONS handling, origin validation, header configuration
API handler template (api/handler.go.tmpl):
- RESTful CRUD: HandleList, HandleGet, HandleCreate, HandleUpdate, HandleDelete
- Go 1.22+ method-based routing via http.ServeMux
- JSON envelope: {"data": ..., "meta": {"page", "per_page", "total", "total_pages"}}
- Error responses: {"error": {"code": "...", "message": "..."}}
- Proper status codes: 200/201/204/400/404/422
- Input validation with go-playground/validator
- Pagination via ?page=1&per_page=20 query params
API queries template (api/queries.sql.tmpl):
- ListResourcePaginated with LIMIT/OFFSET
- CountResource for pagination metadata
Generator (internal/generator/api.go):
- Reuses existing DB schema if already generated by lvt gen resource
- Generates schema/migration/queries if no existing schema
- Appends paginated queries alongside existing ones
- Injects route at /api/v1/<resource>/ into main.go
CLI (commands/api.go):
- Field parsing with type inference (reuses parseFieldsWithInference)
- Prints endpoint summary and next steps
Both multi and single kit templates included.
ContextAPI = "api" constant added to pkg/token/.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new lvt gen api scaffold that generates REST-style JSON CRUD endpoints (with pagination SQL) plus a reusable CORS middleware package, integrating API generation into both multi/single kits and expanding the test suite to cover generation + runtime compilation.
Changes:
- Introduces
lvt gen apiand theinternal/generator.GenerateAPIpipeline (handlers, tests, SQL, route injection, resource registration). - Adds
pkg/corsmiddleware with option-based configuration and unit tests. - Extends templates + golden/integration tests to validate generated API handler output and SQL pagination queries.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| testdata/golden/api_handler.go.golden | Golden snapshot for generated API handler output. |
| pkg/token/token.go | Adds ContextAPI token context constant for future bearer auth. |
| pkg/cors/cors.go | New CORS middleware with functional options and preflight handling. |
| pkg/cors/cors_test.go | Unit tests for CORS behavior (preflight, origins, credentials, headers). |
| internal/kits/system/single/templates/api/test.go.tmpl | API test template for single kit. |
| internal/kits/system/single/templates/api/queries.sql.tmpl | Adds paginated list + count queries template for single kit. |
| internal/kits/system/single/templates/api/handler.go.tmpl | API handler template for single kit (CRUD + pagination). |
| internal/kits/system/multi/templates/api/test.go.tmpl | API test template for multi kit. |
| internal/kits/system/multi/templates/api/queries.sql.tmpl | Adds paginated list + count queries template for multi kit. |
| internal/kits/system/multi/templates/api/handler.go.tmpl | API handler template for multi kit (CRUD + pagination). |
| internal/generator/api.go | New generator implementation for API handlers + SQL + main.go route injection. |
| integration_test.go | Adds generation + full-flow integration tests for API generation and compilation. |
| golden_test.go | Adds golden test to compare generated API handler vs snapshot. |
| commands/gen.go | Registers gen api subcommand in dispatcher + help text. |
| commands/api.go | New CLI entrypoint for lvt gen api. |
| "fmt" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "[[.ModuleName]]/database/models" |
There was a problem hiding this comment.
This generated test template has unused imports (fmt and the database/models import aren’t referenced anywhere). That will cause go test ./... to fail in generated apps. Either remove unused imports or add a compile-safe stub that actually uses them (or move the CRUD test behind a build tag).
| "fmt" | |
| "net/http" | |
| "net/http/httptest" | |
| "testing" | |
| "[[.ModuleName]]/database/models" | |
| "net/http" | |
| "net/http/httptest" | |
| "testing" |
| if len(cfg.AllowOrigins) == 1 && cfg.AllowOrigins[0] == "*" { | ||
| w.Header().Set("Access-Control-Allow-Origin", "*") | ||
| } else { | ||
| w.Header().Set("Access-Control-Allow-Origin", origin) | ||
| w.Header().Set("Vary", "Origin") | ||
| } | ||
|
|
||
| if cfg.AllowCredentials { | ||
| w.Header().Set("Access-Control-Allow-Credentials", "true") | ||
| } |
There was a problem hiding this comment.
When AllowCredentials is true and AllowOrigins is {"*"}, the middleware currently responds with Access-Control-Allow-Origin: * plus Access-Control-Allow-Credentials: true. That combination is invalid per the CORS spec and browsers will reject it. If credentials are enabled, echo the request Origin (and set Vary: Origin) instead of *, or reject configuration that combines credentials with wildcard origins.
| if len(cfg.AllowOrigins) == 1 && cfg.AllowOrigins[0] == "*" { | ||
| w.Header().Set("Access-Control-Allow-Origin", "*") | ||
| } else { | ||
| w.Header().Set("Access-Control-Allow-Origin", origin) | ||
| w.Header().Set("Vary", "Origin") | ||
| } |
There was a problem hiding this comment.
The middleware sets Vary: Origin by overwriting any existing Vary header value. This can break caching behavior when other middleware already added Vary (e.g. Accept-Encoding). Consider appending Origin to Vary (deduplicated) rather than replacing it.
| if kitName == "" { | ||
| kitName = "multi" | ||
| } | ||
|
|
||
| projectConfig, err := config.LoadProjectConfig(basePath) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to load project config: %w", err) | ||
| } | ||
| if kitName == "" { | ||
| kitName = projectConfig.GetKit() | ||
| } |
There was a problem hiding this comment.
In GenerateAPI, the kit selection logic is inconsistent: kitName is defaulted to "multi" before loading .lvtrc, so the later if kitName == "" { kitName = projectConfig.GetKit() } branch can never run. Either remove the projectConfig kit lookup, or move the defaulting until after reading .lvtrc so callers can omit kitName and still use the project’s configured kit.
| if kitName == "" { | |
| kitName = "multi" | |
| } | |
| projectConfig, err := config.LoadProjectConfig(basePath) | |
| if err != nil { | |
| return fmt.Errorf("failed to load project config: %w", err) | |
| } | |
| if kitName == "" { | |
| kitName = projectConfig.GetKit() | |
| } | |
| projectConfig, err := config.LoadProjectConfig(basePath) | |
| if err != nil { | |
| return fmt.Errorf("failed to load project config: %w", err) | |
| } | |
| if kitName == "" { | |
| kitName = projectConfig.GetKit() | |
| } | |
| if kitName == "" { | |
| kitName = "multi" | |
| } |
| "fmt" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "[[.ModuleName]]/database/models" |
There was a problem hiding this comment.
This generated test template has unused imports (fmt and the database/models import aren’t referenced anywhere). That will cause go test ./... to fail in generated apps. Either remove unused imports or add a compile-safe stub that actually uses them (or move the CRUD test behind a build tag).
| "fmt" | |
| "net/http" | |
| "net/http/httptest" | |
| "testing" | |
| "[[.ModuleName]]/database/models" | |
| "net/http" | |
| "net/http/httptest" | |
| "testing" |
| // Parse flags | ||
| skipValidation := false | ||
| var filteredArgs []string | ||
| for i := 0; i < len(args); i++ { | ||
| if args[i] == "--skip-validation" { | ||
| skipValidation = true | ||
| } else { | ||
| filteredArgs = append(filteredArgs, args[i]) | ||
| } | ||
| } | ||
| _ = skipValidation | ||
|
|
There was a problem hiding this comment.
--skip-validation is parsed but never used (it’s assigned to _), so the flag is currently a no-op despite being documented in help output. This should either run the same runPostGenValidation(...) used by other lvt gen * commands when the flag is not set, or the flag/help text should be removed.
| loader := kits.DefaultLoader() | ||
| kitInfo, err := loader.Load(kit) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to load kit: %w", err) | ||
| } | ||
| _ = kitInfo | ||
|
|
There was a problem hiding this comment.
kitInfo is loaded and then immediately discarded (_ = kitInfo). This adds I/O and code noise without affecting behavior. Either use kitInfo for something meaningful (e.g., validate kit supports API templates) or remove the load entirely.
| Path: "/api/v1/" + resourceNameLower + "/", | ||
| PackageName: "api", | ||
| HandlerCall: "api.Handler(queries)", | ||
| ImportPath: moduleName + "/app/api", | ||
| } | ||
| if err := InjectRoute(mainGoPath, route); err != nil { | ||
| fmt.Printf("⚠️ Could not auto-inject API route: %v\n", err) | ||
| fmt.Printf(" Add manually: http.Handle(\"/api/v1/%s/\", api.Handler(queries))\n", resourceNameLower) |
There was a problem hiding this comment.
The auto-injected route path includes a trailing slash (/api/v1/<resource>/), but the generated API mux registers routes without the trailing slash (e.g. GET /api/v1/<resource>). When mounted on /api/v1/<resource>/, requests to /api/v1/<resource>/ won’t match any handler (and /api/v1/<resource> may get redirected). Inject the exact path (/api/v1/<resource>) and/or generate handler routes that accept both variants.
| Path: "/api/v1/" + resourceNameLower + "/", | |
| PackageName: "api", | |
| HandlerCall: "api.Handler(queries)", | |
| ImportPath: moduleName + "/app/api", | |
| } | |
| if err := InjectRoute(mainGoPath, route); err != nil { | |
| fmt.Printf("⚠️ Could not auto-inject API route: %v\n", err) | |
| fmt.Printf(" Add manually: http.Handle(\"/api/v1/%s/\", api.Handler(queries))\n", resourceNameLower) | |
| Path: "/api/v1/" + resourceNameLower, | |
| PackageName: "api", | |
| HandlerCall: "api.Handler(queries)", | |
| ImportPath: moduleName + "/app/api", | |
| } | |
| if err := InjectRoute(mainGoPath, route); err != nil { | |
| fmt.Printf("⚠️ Could not auto-inject API route: %v\n", err) | |
| fmt.Printf(" Add manually: http.Handle(\"/api/v1/%s\", api.Handler(queries))\n", resourceNameLower) |
| return | ||
| } | ||
|
|
||
| updated, _ := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) |
There was a problem hiding this comment.
HandleUpdate ignores the error from the final Get...ByID call (updated, _ := ...). If the re-fetch fails (e.g., transient DB error), this will return a 200 with a zero-value payload. Handle the error and return an appropriate 5xx (or at least propagate the error) instead of discarding it.
| updated, _ := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) | |
| updated, err := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) | |
| if err != nil { | |
| writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load updated [[.ResourceNameLower]]") | |
| return | |
| } |
| return | ||
| } | ||
|
|
||
| updated, _ := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) |
There was a problem hiding this comment.
HandleUpdate ignores the error from the final Get...ByID call (updated, _ := ...). If the re-fetch fails (e.g., transient DB error), this will return a 200 with a zero-value payload. Handle the error and return an appropriate 5xx (or at least propagate the error) instead of discarding it.
| updated, _ := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) | |
| updated, err := h.Queries.Get[[.ResourceNameSingular]]ByID(r.Context(), id) | |
| if err != nil { | |
| writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated [[.ResourceNameLower]]") | |
| return | |
| } |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
lvt gen api <resource> <fields...>command generating pure HTTP REST JSON endpointspkg/cors/package with CORS middleware (functional options, preflight handling)lvt gen resourceContextAPI = "api"token constant added for future bearer authTest plan
🤖 Generated with Claude Code