@nakedev/go-scaffold 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -18,3 +18,18 @@ UnprocessableEntityError:
|
|
|
18
18
|
content:
|
|
19
19
|
application/json:
|
|
20
20
|
schema: { $ref: './schemas.yaml#/Error' }
|
|
21
|
+
UnauthorizedError:
|
|
22
|
+
description: missing, invalid, or expired credentials
|
|
23
|
+
content:
|
|
24
|
+
application/json:
|
|
25
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
26
|
+
ForbiddenError:
|
|
27
|
+
description: authenticated but lacking the required permission
|
|
28
|
+
content:
|
|
29
|
+
application/json:
|
|
30
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
31
|
+
TooManyRequestsError:
|
|
32
|
+
description: rate limit exceeded, retry later
|
|
33
|
+
content:
|
|
34
|
+
application/json:
|
|
35
|
+
schema: { $ref: './schemas.yaml#/Error' }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: Prometheus metrics
|
|
3
|
+
description: Text-format metrics for Prometheus to scrape — not part of the JSON API surface.
|
|
4
|
+
operationId: getMetrics
|
|
5
|
+
tags: [observability]
|
|
6
|
+
security: []
|
|
7
|
+
responses:
|
|
8
|
+
"200":
|
|
9
|
+
description: ok
|
|
10
|
+
content:
|
|
11
|
+
text/plain:
|
|
12
|
+
schema: { type: string }
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# ponytail: hand-written, not generated from annotations — cheap while the
|
|
2
|
-
# endpoint count is low.
|
|
3
|
-
#
|
|
2
|
+
# endpoint count is low. Generated module/method stubs keep the index wired,
|
|
3
|
+
# but request/response schemas still need to be completed with the code.
|
|
4
4
|
# Switch to swaggo (comment-generated) if drift becomes a recurring problem.
|
|
5
5
|
# multi-file: split by domain module (health/<domain>) + common/ for shared
|
|
6
|
-
# pieces — this file is just the index. `generate module
|
|
7
|
-
#
|
|
8
|
-
# (endpoint-specific docs stay hand-written).
|
|
6
|
+
# pieces — this file is just the index. `generate module` and `generate method`
|
|
7
|
+
# add path entries automatically.
|
|
9
8
|
openapi: 3.0.3
|
|
10
9
|
info:
|
|
11
10
|
title: {{projectName}} API
|
|
@@ -19,6 +18,10 @@ paths:
|
|
|
19
18
|
$ref: './health/health-livez.yaml'
|
|
20
19
|
/readyz:
|
|
21
20
|
$ref: './health/health-readyz.yaml'
|
|
21
|
+
{{#if observability}}
|
|
22
|
+
/metrics:
|
|
23
|
+
$ref: './observability/metrics.yaml'
|
|
24
|
+
{{/if}}
|
|
22
25
|
# go-scaffold:paths
|
|
23
26
|
|
|
24
27
|
components:
|
|
@@ -31,3 +34,12 @@ components:
|
|
|
31
34
|
NotFoundError: { $ref: './common/responses.yaml#/NotFoundError' }
|
|
32
35
|
ConflictError: { $ref: './common/responses.yaml#/ConflictError' }
|
|
33
36
|
UnprocessableEntityError: { $ref: './common/responses.yaml#/UnprocessableEntityError' }
|
|
37
|
+
UnauthorizedError: { $ref: './common/responses.yaml#/UnauthorizedError' }
|
|
38
|
+
ForbiddenError: { $ref: './common/responses.yaml#/ForbiddenError' }
|
|
39
|
+
TooManyRequestsError: { $ref: './common/responses.yaml#/TooManyRequestsError' }
|
|
40
|
+
securitySchemes:
|
|
41
|
+
bearerAuth:
|
|
42
|
+
type: http
|
|
43
|
+
scheme: bearer
|
|
44
|
+
bearerFormat: JWT
|
|
45
|
+
description: "access token from POST /auth/login or /auth/register, sent as `Authorization: Bearer <token>`"
|
|
@@ -17,8 +17,9 @@ internal/app/<domain>/
|
|
|
17
17
|
├── repository.go # the only place that touches the DB for this domain, every method takes ctx
|
|
18
18
|
├── service.go # business logic; declares the repository interface it needs (mockable in tests)
|
|
19
19
|
├── handler.go # HTTP: routing, bind, delegate, respond
|
|
20
|
-
├── service_test.go # unit test,
|
|
21
|
-
|
|
20
|
+
├── service_test.go # unit test, function-backed repository stub, no DB
|
|
21
|
+
├── handler_test.go # HTTP unit test, service stub, no DB
|
|
22
|
+
└── repository_test.go # Postgres integration test against migrated schema
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
### Model
|
|
@@ -137,10 +138,12 @@ placeholder fields.
|
|
|
137
138
|
- Unit and integration tests live in the same directory as the code under
|
|
138
139
|
test (Go convention) — never a separate `test/` folder. `test/` is only
|
|
139
140
|
for e2e black-box suites or fixtures.
|
|
140
|
-
- `service_test.go` uses a
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
- `service_test.go` uses a function-backed repository stub so each dependency
|
|
142
|
+
method has independent behavior and argument assertions.
|
|
143
|
+
- `handler_test.go` uses a service stub — HTTP tests never need Postgres.
|
|
144
|
+
- `repository_test.go` runs against a migrated Postgres database inside a
|
|
145
|
+
transaction that's rolled back after each test. Local runs may skip when
|
|
146
|
+
`TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
|
|
144
147
|
|
|
145
148
|
## Docs Maintenance
|
|
146
149
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- Graceful shutdown, structured logging, `/livez` + `/readyz`: always enabled
|
|
24
24
|
- Docker Compose (local Postgres): `{{#if docker}}enabled{{else}}disabled{{/if}}`
|
|
25
25
|
- OpenAPI docs (`docs/openapi.yaml`, whole `docs/` tree served at `/docs`): `{{#if openapiDocs}}enabled{{else}}disabled{{/if}}`
|
|
26
|
+
- Metrics + tracing (Prometheus `/metrics`, OpenTelemetry for Gin + GORM): `{{#if observability}}enabled{{else}}disabled{{/if}}`
|
|
26
27
|
- API route prefix: `{{#if apiPrefix}}/{{apiPrefix}}{{else}}(none){{/if}}`
|
|
27
28
|
- CI (`.github/workflows/ci.yml` — build, vet, gofmt check, golangci-lint, `go test` with a real Postgres service): always enabled
|
|
28
29
|
|
|
@@ -34,5 +35,7 @@
|
|
|
34
35
|
`false` in prod and run `migrate up` as a deploy step instead
|
|
35
36
|
{{#if openapiDocs}}- `docs/openapi.yaml` is hand-written, not generated — update it whenever an
|
|
36
37
|
endpoint or DTO changes
|
|
38
|
+
{{/if}}{{#if observability}}- `OTEL_EXPORTER_OTLP_ENDPOINT` unset (dev default) means tracing exports
|
|
39
|
+
nowhere — set it to a real OTLP/HTTP collector address to see traces
|
|
37
40
|
{{/if}}- Versions above are what `go-scaffold create` pinned in `go.mod` — bump them
|
|
38
41
|
by hand (`go get -u` + `go mod tidy`) as the stack evolves
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strconv"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/gin-gonic/gin"
|
|
8
|
+
"github.com/prometheus/client_golang/prometheus"
|
|
9
|
+
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var (
|
|
13
|
+
httpRequestsTotal = promauto.NewCounterVec(
|
|
14
|
+
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
|
|
15
|
+
[]string{"method", "path", "status"},
|
|
16
|
+
)
|
|
17
|
+
httpRequestDuration = promauto.NewHistogramVec(
|
|
18
|
+
prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration in seconds"},
|
|
19
|
+
[]string{"method", "path", "status"},
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Metrics records request count and latency per route for GET /metrics to
|
|
24
|
+
// expose to Prometheus. Uses c.FullPath() (the route template, e.g.
|
|
25
|
+
// "/v1/invoices/:id") rather than the raw request path — a UUID or other
|
|
26
|
+
// path param would otherwise create one label series per distinct value,
|
|
27
|
+
// growing unbounded with traffic instead of staying one series per route.
|
|
28
|
+
func Metrics() gin.HandlerFunc {
|
|
29
|
+
return func(c *gin.Context) {
|
|
30
|
+
start := time.Now()
|
|
31
|
+
c.Next()
|
|
32
|
+
|
|
33
|
+
path := c.FullPath()
|
|
34
|
+
if path == "" {
|
|
35
|
+
path = "unmatched" // no route matched (404) — one shared label, not the raw unbounded path
|
|
36
|
+
}
|
|
37
|
+
status := strconv.Itoa(c.Writer.Status())
|
|
38
|
+
httpRequestsTotal.WithLabelValues(c.Request.Method, path, status).Inc()
|
|
39
|
+
httpRequestDuration.WithLabelValues(c.Request.Method, path, status).Observe(time.Since(start).Seconds())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/gin-gonic/gin"
|
|
5
|
+
"go.opentelemetry.io/otel"
|
|
6
|
+
"go.opentelemetry.io/otel/attribute"
|
|
7
|
+
"go.opentelemetry.io/otel/codes"
|
|
8
|
+
"go.opentelemetry.io/otel/propagation"
|
|
9
|
+
"go.opentelemetry.io/otel/trace"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Tracing starts one span per request, propagating an incoming W3C
|
|
13
|
+
// traceparent header (if any) so a request already being traced upstream
|
|
14
|
+
// stays part of the same trace, not a new root.
|
|
15
|
+
//
|
|
16
|
+
// Hand-rolled instead of the official otelgin contrib package: pulling it in
|
|
17
|
+
// forces this project's gin dependency to a newer minimum version than the
|
|
18
|
+
// one this scaffold pins, which itself drags in HTTP/3 (quic-go) — weight
|
|
19
|
+
// unrelated to tracing. Gin's middleware surface (Next/Writer.Status/Errors)
|
|
20
|
+
// is small enough that duplicating just the part actually used isn't worth
|
|
21
|
+
// that.
|
|
22
|
+
func Tracing(serviceName string) gin.HandlerFunc {
|
|
23
|
+
tracer := otel.Tracer(serviceName)
|
|
24
|
+
return func(c *gin.Context) {
|
|
25
|
+
ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
|
|
26
|
+
|
|
27
|
+
spanName := c.FullPath()
|
|
28
|
+
if spanName == "" {
|
|
29
|
+
spanName = c.Request.URL.Path // no route matched (404) — still traced, just without a route template
|
|
30
|
+
}
|
|
31
|
+
ctx, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindServer))
|
|
32
|
+
defer span.End()
|
|
33
|
+
c.Request = c.Request.WithContext(ctx)
|
|
34
|
+
|
|
35
|
+
c.Next()
|
|
36
|
+
|
|
37
|
+
span.SetAttributes(
|
|
38
|
+
attribute.String("http.method", c.Request.Method),
|
|
39
|
+
attribute.Int("http.status_code", c.Writer.Status()),
|
|
40
|
+
)
|
|
41
|
+
if len(c.Errors) > 0 {
|
|
42
|
+
span.RecordError(c.Errors.Last())
|
|
43
|
+
span.SetStatus(codes.Error, c.Errors.String())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Package telemetry wires OpenTelemetry tracing — it talks to a real
|
|
2
|
+
// external system (the OTLP collector), so it lives in platform/, not
|
|
3
|
+
// shared/, same split rule as internal/platform/database.
|
|
4
|
+
package telemetry
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"go.opentelemetry.io/otel"
|
|
11
|
+
"go.opentelemetry.io/otel/attribute"
|
|
12
|
+
"go.opentelemetry.io/otel/codes"
|
|
13
|
+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
|
14
|
+
"go.opentelemetry.io/otel/propagation"
|
|
15
|
+
"go.opentelemetry.io/otel/sdk/resource"
|
|
16
|
+
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
17
|
+
"go.opentelemetry.io/otel/trace"
|
|
18
|
+
"gorm.io/gorm"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Init wires a global TracerProvider that exports spans via OTLP/HTTP to
|
|
22
|
+
// endpoint. An empty endpoint (the default — see OTEL_EXPORTER_OTLP_ENDPOINT
|
|
23
|
+
// in .env.example) skips exporter setup entirely and returns a no-op
|
|
24
|
+
// shutdown: tracing quietly does nothing instead of trying to dial a
|
|
25
|
+
// collector that isn't there, same "off unless configured" shape as the
|
|
26
|
+
// rest of this app's optional integrations.
|
|
27
|
+
func Init(ctx context.Context, serviceName, endpoint string) (shutdown func(context.Context) error, err error) {
|
|
28
|
+
noop := func(context.Context) error { return nil }
|
|
29
|
+
if endpoint == "" {
|
|
30
|
+
return noop, nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
exporter, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
|
|
34
|
+
if err != nil {
|
|
35
|
+
return noop, err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", serviceName)))
|
|
39
|
+
if err != nil {
|
|
40
|
+
return noop, err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
|
|
44
|
+
otel.SetTracerProvider(tp)
|
|
45
|
+
otel.SetTextMapPropagator(propagation.TraceContext{})
|
|
46
|
+
|
|
47
|
+
return func(shutdownCtx context.Context) error {
|
|
48
|
+
ctx, cancel := context.WithTimeout(shutdownCtx, 5*time.Second)
|
|
49
|
+
defer cancel()
|
|
50
|
+
return tp.Shutdown(ctx)
|
|
51
|
+
}, nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tracerName = "gorm"
|
|
55
|
+
|
|
56
|
+
// gormPlugin wraps every GORM operation (query, create, update, delete, row,
|
|
57
|
+
// raw) in a span — a hand-rolled GORM callback plugin instead of
|
|
58
|
+
// gorm.io/plugin/opentelemetry: that module's go.mod pulls in every driver
|
|
59
|
+
// it supports tracing for (MySQL, ClickHouse, MongoDB) plus their own
|
|
60
|
+
// transitive trees (gRPC, QUIC, ...) as indirect requires, even though this
|
|
61
|
+
// project only ever uses Postgres. GORM's callback API is small enough that
|
|
62
|
+
// replicating just "start a span before, end it after, record the error if
|
|
63
|
+
// any" isn't worth that weight.
|
|
64
|
+
type gormPlugin struct{}
|
|
65
|
+
|
|
66
|
+
// NewGormPlugin returns a gorm.Plugin — wire it in via db.Use(...).
|
|
67
|
+
func NewGormPlugin() gorm.Plugin { return gormPlugin{} }
|
|
68
|
+
|
|
69
|
+
func (gormPlugin) Name() string { return "otel-tracing" }
|
|
70
|
+
|
|
71
|
+
// Initialize registers a before/after pair on each of GORM's six operation
|
|
72
|
+
// callbacks (create/query/update/delete/row/raw) — spelled out individually,
|
|
73
|
+
// not looped, because db.Callback().Create() etc. return GORM's unexported
|
|
74
|
+
// *processor type: a package outside gorm can chain its methods but can't
|
|
75
|
+
// name the type, so it can't be held in a slice/struct field to loop over.
|
|
76
|
+
func (gormPlugin) Initialize(db *gorm.DB) error {
|
|
77
|
+
before := func(op string) func(*gorm.DB) {
|
|
78
|
+
return func(tx *gorm.DB) {
|
|
79
|
+
ctx, span := otel.Tracer(tracerName).Start(tx.Statement.Context, "gorm."+op)
|
|
80
|
+
span.SetAttributes(attribute.String("db.sql.table", tx.Statement.Table))
|
|
81
|
+
tx.Statement.Context = ctx
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
after := func(tx *gorm.DB) {
|
|
85
|
+
span := trace.SpanFromContext(tx.Statement.Context)
|
|
86
|
+
if tx.Error != nil {
|
|
87
|
+
span.RecordError(tx.Error)
|
|
88
|
+
span.SetStatus(codes.Error, tx.Error.Error())
|
|
89
|
+
}
|
|
90
|
+
span.End()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if err := db.Callback().Create().Before("gorm:create").Register("otel:before_create", before("create")); err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
if err := db.Callback().Create().After("gorm:create").Register("otel:after_create", after); err != nil {
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
if err := db.Callback().Query().Before("gorm:query").Register("otel:before_query", before("query")); err != nil {
|
|
100
|
+
return err
|
|
101
|
+
}
|
|
102
|
+
if err := db.Callback().Query().After("gorm:query").Register("otel:after_query", after); err != nil {
|
|
103
|
+
return err
|
|
104
|
+
}
|
|
105
|
+
if err := db.Callback().Update().Before("gorm:update").Register("otel:before_update", before("update")); err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
if err := db.Callback().Update().After("gorm:update").Register("otel:after_update", after); err != nil {
|
|
109
|
+
return err
|
|
110
|
+
}
|
|
111
|
+
if err := db.Callback().Delete().Before("gorm:delete").Register("otel:before_delete", before("delete")); err != nil {
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
if err := db.Callback().Delete().After("gorm:delete").Register("otel:after_delete", after); err != nil {
|
|
115
|
+
return err
|
|
116
|
+
}
|
|
117
|
+
if err := db.Callback().Row().Before("gorm:row").Register("otel:before_row", before("row")); err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
120
|
+
if err := db.Callback().Row().After("gorm:row").Register("otel:after_row", after); err != nil {
|
|
121
|
+
return err
|
|
122
|
+
}
|
|
123
|
+
if err := db.Callback().Raw().Before("gorm:raw").Register("otel:before_raw", before("raw")); err != nil {
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
if err := db.Callback().Raw().After("gorm:raw").Register("otel:after_raw", after); err != nil {
|
|
127
|
+
return err
|
|
128
|
+
}
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
@@ -29,9 +29,9 @@ put:
|
|
|
29
29
|
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
30
30
|
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
31
31
|
delete:
|
|
32
|
-
summary: Delete {{
|
|
32
|
+
summary: Delete {{name}}
|
|
33
|
+
description: Idempotent — deleting an already-missing resource also returns 204.
|
|
33
34
|
operationId: delete{{pascalName}}
|
|
34
35
|
tags: [{{plural}}]
|
|
35
36
|
responses:
|
|
36
|
-
"204": { description: deleted }
|
|
37
|
-
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
37
|
+
"204": { description: deleted or already absent }
|
|
@@ -1,26 +1,57 @@
|
|
|
1
1
|
package {{pkg}}
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"net/http"
|
|
5
6
|
|
|
7
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
6
8
|
"{{goModule}}/internal/shared/httpx"
|
|
9
|
+
{{#if auth}}
|
|
10
|
+
"{{goModule}}/internal/shared/middleware"
|
|
11
|
+
{{/if}}
|
|
7
12
|
"{{goModule}}/internal/shared/pagination"
|
|
8
13
|
|
|
9
14
|
"github.com/gin-gonic/gin"
|
|
15
|
+
"github.com/google/uuid"
|
|
10
16
|
)
|
|
11
17
|
|
|
18
|
+
// service is the narrow application API required by this HTTP adapter. Keeping
|
|
19
|
+
// the dependency as an interface makes handler tests fast and database-free.
|
|
20
|
+
type service interface {
|
|
21
|
+
Create(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
22
|
+
List(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
23
|
+
Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
24
|
+
Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
25
|
+
Delete(context.Context, uuid.UUID) error
|
|
26
|
+
// go-scaffold:service-interface
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
// Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
|
|
13
30
|
type Handler struct {
|
|
14
|
-
svc
|
|
31
|
+
svc service
|
|
32
|
+
{{#if auth}}
|
|
33
|
+
jwtSecret string
|
|
34
|
+
{{/if}}
|
|
35
|
+
{{#if permission}}
|
|
36
|
+
authz *middleware.Authz
|
|
37
|
+
{{/if}}
|
|
15
38
|
}
|
|
16
39
|
|
|
17
|
-
func NewHandler(svc *
|
|
18
|
-
return &Handler{
|
|
40
|
+
func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
|
|
41
|
+
return &Handler{
|
|
42
|
+
svc: svc,
|
|
43
|
+
{{#if auth}}
|
|
44
|
+
jwtSecret: jwtSecret,
|
|
45
|
+
{{/if}}
|
|
46
|
+
{{#if permission}}
|
|
47
|
+
authz: authz,
|
|
48
|
+
{{/if}}
|
|
49
|
+
}
|
|
19
50
|
}
|
|
20
51
|
|
|
21
52
|
// Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
|
|
22
53
|
func (h *Handler) Register(rg gin.IRouter) {
|
|
23
|
-
g := rg.Group("/{{plural}}")
|
|
54
|
+
g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
|
|
24
55
|
g.POST("", h.create)
|
|
25
56
|
g.GET("", h.list)
|
|
26
57
|
g.GET("/:id", h.get)
|
|
@@ -35,7 +66,7 @@ func (h *Handler) create(c *gin.Context) {
|
|
|
35
66
|
c.Error(httpx.BindErr(err))
|
|
36
67
|
return
|
|
37
68
|
}
|
|
38
|
-
m, err := h.svc.Create(c.Request.Context())
|
|
69
|
+
m, err := h.svc.Create(c.Request.Context(), in)
|
|
39
70
|
if err != nil {
|
|
40
71
|
c.Error(err)
|
|
41
72
|
return
|
|
@@ -80,7 +111,7 @@ func (h *Handler) update(c *gin.Context) {
|
|
|
80
111
|
c.Error(httpx.BindErr(err))
|
|
81
112
|
return
|
|
82
113
|
}
|
|
83
|
-
m, err := h.svc.Update(c.Request.Context(), id)
|
|
114
|
+
m, err := h.svc.Update(c.Request.Context(), id, in)
|
|
84
115
|
if err != nil {
|
|
85
116
|
c.Error(err)
|
|
86
117
|
return
|
|
@@ -2,103 +2,165 @@ package {{pkg}}
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"bytes"
|
|
5
|
-
"
|
|
5
|
+
"context"
|
|
6
6
|
"net/http"
|
|
7
7
|
"net/http/httptest"
|
|
8
|
-
"os"
|
|
9
|
-
"sync"
|
|
10
8
|
"testing"
|
|
9
|
+
{{#if auth}}
|
|
10
|
+
"time"
|
|
11
|
+
{{/if}}
|
|
11
12
|
|
|
12
13
|
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
13
14
|
"{{goModule}}/internal/shared/middleware"
|
|
14
15
|
|
|
15
16
|
"github.com/gin-gonic/gin"
|
|
17
|
+
{{#if auth}}
|
|
18
|
+
"github.com/golang-jwt/jwt/v5"
|
|
19
|
+
{{/if}}
|
|
16
20
|
"github.com/google/uuid"
|
|
17
|
-
"gorm.io/driver/postgres"
|
|
18
|
-
"gorm.io/gorm"
|
|
19
21
|
)
|
|
20
22
|
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
// serviceStub keeps handler tests at the HTTP boundary. It exercises binding,
|
|
24
|
+
// routing, middleware, status codes, and serialization without a database.
|
|
25
|
+
type serviceStub struct {
|
|
26
|
+
// Embedding keeps this stub source-compatible when `generate method` adds a
|
|
27
|
+
// new operation to the handler's service interface. Base CRUD methods below
|
|
28
|
+
// still override the promoted concrete methods for focused unit tests.
|
|
29
|
+
*Service
|
|
30
|
+
createFn func(context.Context, createInput) (*model.{{pascalName}}, error)
|
|
31
|
+
listFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
32
|
+
getFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
33
|
+
updateFn func(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
|
|
34
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
35
|
+
}
|
|
28
36
|
|
|
29
|
-
func
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
func (s *serviceStub) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
38
|
+
if s.createFn == nil {
|
|
39
|
+
panic("unexpected service.Create call")
|
|
40
|
+
}
|
|
41
|
+
return s.createFn(ctx, in)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (s *serviceStub) List(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
45
|
+
if s.listFn == nil {
|
|
46
|
+
panic("unexpected service.List call")
|
|
47
|
+
}
|
|
48
|
+
return s.listFn(ctx, limit, offset)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (s *serviceStub) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
52
|
+
if s.getFn == nil {
|
|
53
|
+
panic("unexpected service.Get call")
|
|
54
|
+
}
|
|
55
|
+
return s.getFn(ctx, id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func (s *serviceStub) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
59
|
+
if s.updateFn == nil {
|
|
60
|
+
panic("unexpected service.Update call")
|
|
44
61
|
}
|
|
45
|
-
return
|
|
62
|
+
return s.updateFn(ctx, id, in)
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
func (s *serviceStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
66
|
+
if s.deleteFn == nil {
|
|
67
|
+
panic("unexpected service.Delete call")
|
|
68
|
+
}
|
|
69
|
+
return s.deleteFn(ctx, id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// go-scaffold:service-stub-methods
|
|
73
|
+
|
|
74
|
+
func setupHandlerTest(t *testing.T, svc service) *gin.Engine {
|
|
50
75
|
t.Helper()
|
|
51
76
|
gin.SetMode(gin.TestMode)
|
|
52
|
-
tx := dbForTest(t).Begin()
|
|
53
|
-
t.Cleanup(func() { tx.Rollback() })
|
|
54
77
|
r := gin.New()
|
|
55
|
-
r.Use(middleware.RequestID(), middleware.Error())
|
|
56
|
-
|
|
78
|
+
r.Use(middleware.RequestID(), middleware.Error(true))
|
|
79
|
+
{{#if permission}}
|
|
80
|
+
// The handler unit test verifies route composition, not the role repository.
|
|
81
|
+
authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
|
|
82
|
+
return map[string]struct{}{"{{permission}}": {}}, nil
|
|
83
|
+
}, time.Minute)
|
|
84
|
+
{{/if}}
|
|
85
|
+
NewHandler(svc{{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
|
|
57
86
|
return r
|
|
58
87
|
}
|
|
59
88
|
|
|
60
|
-
|
|
61
|
-
|
|
89
|
+
{{#if auth}}
|
|
90
|
+
const testJWTSecret = "test-secret"
|
|
91
|
+
|
|
92
|
+
func authHeader() string {
|
|
93
|
+
claims := jwt.MapClaims{
|
|
94
|
+
"typ": "access",
|
|
95
|
+
"sub": uuid.NewString(),
|
|
96
|
+
"role": "staff",
|
|
97
|
+
"exp": time.Now().Add(time.Hour).Unix(),
|
|
98
|
+
"iat": time.Now().Unix(),
|
|
99
|
+
}
|
|
100
|
+
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testJWTSecret))
|
|
101
|
+
return "Bearer " + tok
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
{{/if}}
|
|
105
|
+
func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest.ResponseRecorder {
|
|
106
|
+
req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
|
|
62
107
|
req.Header.Set("Content-Type", "application/json")
|
|
108
|
+
{{#if auth}}
|
|
109
|
+
req.Header.Set("Authorization", authHeader())
|
|
110
|
+
{{/if}}
|
|
63
111
|
w := httptest.NewRecorder()
|
|
64
112
|
r.ServeHTTP(w, req)
|
|
65
113
|
return w
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
func TestHandler_Create_OK(t *testing.T) {
|
|
69
|
-
|
|
70
|
-
|
|
117
|
+
svc := &serviceStub{
|
|
118
|
+
createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
|
|
119
|
+
return &model.{{pascalName}}{ID: uuid.New()}, nil
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
r := setupHandlerTest(t, svc)
|
|
123
|
+
|
|
124
|
+
w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", `{}`)
|
|
125
|
+
|
|
71
126
|
if w.Code != http.StatusCreated {
|
|
72
127
|
t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
|
|
73
128
|
}
|
|
74
129
|
}
|
|
75
130
|
|
|
76
131
|
func TestHandler_Get_NotFound(t *testing.T) {
|
|
77
|
-
|
|
78
|
-
|
|
132
|
+
svc := &serviceStub{
|
|
133
|
+
getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
134
|
+
return nil, errNotFound()
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
r := setupHandlerTest(t, svc)
|
|
138
|
+
|
|
139
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
|
|
140
|
+
|
|
79
141
|
if w.Code != http.StatusNotFound {
|
|
80
142
|
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
|
|
81
143
|
}
|
|
82
144
|
}
|
|
83
145
|
|
|
84
|
-
func
|
|
85
|
-
r :=
|
|
86
|
-
|
|
146
|
+
func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
|
|
147
|
+
r := setupHandlerTest(t, &serviceStub{})
|
|
148
|
+
|
|
149
|
+
w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
|
|
150
|
+
|
|
87
151
|
if w.Code != http.StatusBadRequest {
|
|
88
152
|
t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
|
|
89
153
|
}
|
|
90
154
|
}
|
|
91
155
|
|
|
92
156
|
func TestHandler_Delete_OK(t *testing.T) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
var body struct {
|
|
96
|
-
ID string `json:"id"`
|
|
157
|
+
svc := &serviceStub{
|
|
158
|
+
deleteFn: func(context.Context, uuid.UUID) error { return nil },
|
|
97
159
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
160
|
+
r := setupHandlerTest(t, svc)
|
|
161
|
+
|
|
162
|
+
w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
|
|
163
|
+
|
|
102
164
|
if w.Code != http.StatusNoContent {
|
|
103
165
|
t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
|
|
104
166
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS {{
|
|
1
|
+
DROP TABLE IF EXISTS {{tableName}};
|