create-svc 0.1.9 → 0.1.11
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 +138 -16
- package/bin/create-service.mjs +2 -0
- package/package.json +19 -11
- package/src/cli.test.ts +46 -7
- package/src/cli.ts +282 -84
- package/src/git-bootstrap.test.ts +40 -0
- package/src/git-bootstrap.ts +110 -0
- package/src/naming.test.ts +5 -2
- package/src/naming.ts +32 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.test.ts +19 -0
- package/src/post-scaffold.ts +18 -26
- package/src/profiles.ts +25 -0
- package/src/scaffold.test.ts +320 -18
- package/src/scaffold.ts +154 -28
- package/src/vault.test.ts +94 -10
- package/src/vault.ts +81 -18
- package/templates/shared/.github/workflows/ci.yml +2 -1
- package/templates/shared/.github/workflows/deploy.yml +2 -0
- package/templates/shared/README.md +217 -29
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/grafana/alerts.yaml +54 -0
- package/templates/shared/grafana/waitlist-dashboard.json +63 -0
- package/templates/shared/scripts/authctl.ts +231 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +24 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +81 -35
- package/templates/shared/scripts/cloudrun/cli.ts +324 -7
- package/templates/shared/scripts/cloudrun/config.ts +21 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +16 -11
- package/templates/shared/scripts/cloudrun/lib.ts +232 -123
- package/templates/shared/scripts/cloudrun/neon.ts +127 -13
- package/templates/shared/scripts/dev.ts +22 -0
- package/templates/shared/scripts/ensure-local-db.ts +3 -0
- package/templates/shared/scripts/local-docker.ts +63 -0
- package/templates/shared/scripts/local-env.ts +27 -0
- package/templates/shared/scripts/seed.ts +73 -0
- package/templates/shared/scripts/wait-for-db.ts +32 -0
- package/templates/shared/service.config.ts +59 -0
- package/templates/shared/service.yaml +24 -1
- package/templates/targets/workers/.github/workflows/ci.yml +19 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
- package/templates/targets/workers/Makefile +33 -0
- package/templates/targets/workers/README.md +75 -0
- package/templates/targets/workers/package.json +35 -0
- package/templates/targets/workers/scripts/workers/cli.ts +397 -0
- package/templates/targets/workers/src/auth.ts +178 -0
- package/templates/targets/workers/src/index.ts +198 -0
- package/templates/targets/workers/src/storage.ts +370 -0
- package/templates/targets/workers/test/app.test.ts +108 -0
- package/templates/targets/workers/tsconfig.json +11 -0
- package/templates/targets/workers/wrangler.toml +24 -0
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +17 -8
- package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-connectrpc/package.json +25 -1
- package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +49 -0
- package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +126 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +26 -0
- package/templates/variants/bun-connectrpc/src/index.ts +194 -22
- package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +17 -8
- package/templates/variants/bun-hono/migrations/0000_init.sql +20 -0
- package/templates/variants/bun-hono/package.json +21 -1
- package/templates/variants/bun-hono/scripts/migrate.ts +49 -0
- package/templates/variants/bun-hono/src/auth.ts +181 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +126 -0
- package/templates/variants/bun-hono/src/db/schema.ts +26 -0
- package/templates/variants/bun-hono/src/index.ts +141 -10
- package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
- package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
- package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
- package/templates/variants/bun-hono/test/app.test.ts +90 -5
- package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +30 -10
- package/templates/variants/go-chi/atlas.hcl +8 -0
- package/templates/variants/go-chi/cmd/server/main.go +25 -13
- package/templates/variants/go-chi/go.mod +3 -2
- package/templates/variants/go-chi/internal/app/service.go +279 -70
- package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
- package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-chi/internal/config/config.go +38 -7
- package/templates/variants/go-chi/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
- package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
- package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
- package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-chi/migrations/0000_init.sql +20 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -0
- package/templates/variants/go-chi/package.json +7 -1
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +29 -8
- package/templates/variants/go-connectrpc/atlas.hcl +8 -0
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +44 -9
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
- package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +279 -70
- package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
- package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
- package/templates/variants/go-connectrpc/internal/config/config.go +38 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +129 -40
- package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +170 -47
- package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
- package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
- package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +20 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
- package/templates/variants/go-connectrpc/package.json +7 -1
- package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
- package/templates/root/.github/workflows/buf-publish.yml +0 -19
- package/templates/root/.github/workflows/ci.yml +0 -26
- package/templates/root/.github/workflows/deploy.yml +0 -22
- package/templates/root/Dockerfile +0 -23
- package/templates/root/README.md +0 -69
- package/templates/root/buf.gen.yaml +0 -10
- package/templates/root/buf.yaml +0 -9
- package/templates/root/cmd/server/main.go +0 -44
- package/templates/root/gen/dns/v1/dns.pb.go +0 -623
- package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/root/go.mod +0 -10
- package/templates/root/internal/app/service.go +0 -152
- package/templates/root/internal/app/token_source.go +0 -50
- package/templates/root/internal/cloudflare/client.go +0 -160
- package/templates/root/internal/config/config.go +0 -55
- package/templates/root/internal/connectapi/handler.go +0 -79
- package/templates/root/internal/httpapi/routes.go +0 -93
- package/templates/root/internal/vault/client.go +0 -148
- package/templates/root/package.json +0 -12
- package/templates/root/protos/dns/v1/dns.proto +0 -58
- package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
- package/templates/root/scripts/cloudrun/config.ts +0 -50
- package/templates/root/scripts/cloudrun/deploy.ts +0 -41
- package/templates/root/scripts/cloudrun/lib.ts +0 -244
- package/templates/root/service.yaml +0 -50
- package/templates/root/test/go.test.ts +0 -19
- package/templates/shared/.env.example +0 -10
- package/templates/variants/go-chi/buf.gen.yaml +0 -10
- package/templates/variants/go-chi/buf.yaml +0 -9
- package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
- package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
- package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
- package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
- package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestMiddlewareRejectsProtectedPathWithoutBearerToken(t *testing.T) {
|
|
10
|
+
handler := Middleware(Config{
|
|
11
|
+
Enabled: true,
|
|
12
|
+
Issuer: "https://auth.anmho.com",
|
|
13
|
+
Audience: "api://{{SERVICE_ID}}",
|
|
14
|
+
JWKSURL: "https://auth.anmho.com/api/auth/jwks",
|
|
15
|
+
})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
16
|
+
w.WriteHeader(http.StatusNoContent)
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
response := httptest.NewRecorder()
|
|
20
|
+
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/waitlist.v1.WaitlistService/JoinWaitlist", nil))
|
|
21
|
+
|
|
22
|
+
if response.Code != http.StatusUnauthorized {
|
|
23
|
+
t.Fatalf("expected 401, got %d", response.Code)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func TestMiddlewareLeavesHealthPublic(t *testing.T) {
|
|
28
|
+
handler := Middleware(Config{Enabled: true})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
29
|
+
w.WriteHeader(http.StatusNoContent)
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
response := httptest.NewRecorder()
|
|
33
|
+
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
34
|
+
|
|
35
|
+
if response.Code != http.StatusNoContent {
|
|
36
|
+
t.Fatalf("expected health to pass through, got %d", response.Code)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
package config
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
4
8
|
|
|
5
9
|
type Config struct {
|
|
6
|
-
Port
|
|
7
|
-
DatabaseURL
|
|
10
|
+
Port string
|
|
11
|
+
DatabaseURL string
|
|
12
|
+
TemporalEnabled bool
|
|
13
|
+
TemporalAddress string
|
|
14
|
+
TemporalNamespace string
|
|
15
|
+
TemporalTaskQueue string
|
|
16
|
+
TemporalAPIKey string
|
|
17
|
+
AuthEnabled bool
|
|
18
|
+
AuthIssuer string
|
|
19
|
+
AuthAudience string
|
|
20
|
+
AuthJWKSURL string
|
|
8
21
|
}
|
|
9
22
|
|
|
10
23
|
func Load() (Config, error) {
|
|
11
|
-
|
|
12
|
-
Port:
|
|
13
|
-
DatabaseURL:
|
|
14
|
-
|
|
24
|
+
cfg := Config{
|
|
25
|
+
Port: envOr("PORT", "8080"),
|
|
26
|
+
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
|
27
|
+
TemporalEnabled: envBool("TEMPORAL_ENABLED"),
|
|
28
|
+
TemporalAddress: envOr("TEMPORAL_ADDRESS", "localhost:7233"),
|
|
29
|
+
TemporalNamespace: envOr("TEMPORAL_NAMESPACE", "default"),
|
|
30
|
+
TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
|
|
31
|
+
TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
|
|
32
|
+
AuthEnabled: envBool("AUTH_ENABLED"),
|
|
33
|
+
AuthIssuer: envOr("AUTH_ISSUER", "{{AUTH_ISSUER}}"),
|
|
34
|
+
AuthAudience: envOr("AUTH_AUDIENCE", "{{AUTH_AUDIENCE}}"),
|
|
35
|
+
AuthJWKSURL: envOr("AUTH_JWKS_URL", "{{AUTH_JWKS_URL}}"),
|
|
36
|
+
}
|
|
37
|
+
if cfg.DatabaseURL == "" {
|
|
38
|
+
return Config{}, errors.New("DATABASE_URL is required")
|
|
39
|
+
}
|
|
40
|
+
return cfg, nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func envBool(key string) bool {
|
|
44
|
+
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
|
45
|
+
return value == "1" || value == "true" || value == "yes" || value == "on"
|
|
15
46
|
}
|
|
16
47
|
|
|
17
48
|
func envOr(key string, fallback string) string {
|
|
@@ -2,78 +2,167 @@ package connectapi
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
5
7
|
"net/http"
|
|
6
8
|
|
|
7
9
|
"connectrpc.com/connect"
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
waitlistv1 "{{MODULE_PATH}}/gen/waitlist/v1"
|
|
12
|
+
waitlistv1connect "{{MODULE_PATH}}/gen/waitlist/v1/waitlistv1connect"
|
|
11
13
|
"{{MODULE_PATH}}/internal/app"
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
type Handler struct {
|
|
15
|
-
service *app.
|
|
17
|
+
service *app.WaitlistService
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
func NewHandler(service *app.
|
|
19
|
-
return
|
|
20
|
+
func NewHandler(service *app.WaitlistService) (string, http.Handler) {
|
|
21
|
+
return waitlistv1connect.NewWaitlistServiceHandler(&Handler{service: service})
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
func (h *Handler)
|
|
23
|
-
|
|
24
|
+
func (h *Handler) JoinWaitlist(ctx context.Context, request *connect.Request[waitlistv1.JoinWaitlistRequest]) (*connect.Response[waitlistv1.JoinWaitlistResponse], error) {
|
|
25
|
+
result, err := h.service.JoinWaitlist(ctx, app.JoinWaitlistInput{
|
|
26
|
+
Email: request.Msg.GetEmail(),
|
|
27
|
+
Name: request.Msg.GetName(),
|
|
28
|
+
Company: request.Msg.GetCompany(),
|
|
29
|
+
Source: request.Msg.GetSource(),
|
|
30
|
+
})
|
|
31
|
+
if err != nil {
|
|
32
|
+
return nil, toConnectError(err)
|
|
33
|
+
}
|
|
34
|
+
return connect.NewResponse(&waitlistv1.JoinWaitlistResponse{
|
|
35
|
+
Entry: toProtoEntry(result.Entry),
|
|
36
|
+
Created: result.Created,
|
|
37
|
+
}), nil
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func (h *Handler) GetWaitlistEntry(ctx context.Context, request *connect.Request[waitlistv1.GetWaitlistEntryRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
|
|
41
|
+
entry, err := h.service.GetEntry(ctx, request.Msg.GetEntryId())
|
|
42
|
+
if err != nil {
|
|
43
|
+
return nil, toConnectError(err)
|
|
44
|
+
}
|
|
45
|
+
return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (h *Handler) GetWaitlistEntryByEmail(ctx context.Context, request *connect.Request[waitlistv1.GetWaitlistEntryByEmailRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
|
|
49
|
+
entry, err := h.service.GetEntryByEmail(ctx, request.Msg.GetEmail())
|
|
24
50
|
if err != nil {
|
|
25
|
-
return nil,
|
|
51
|
+
return nil, toConnectError(err)
|
|
26
52
|
}
|
|
53
|
+
return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
|
|
54
|
+
}
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
func (h *Handler) ListWaitlistEntries(ctx context.Context, request *connect.Request[waitlistv1.ListWaitlistEntriesRequest]) (*connect.Response[waitlistv1.ListWaitlistEntriesResponse], error) {
|
|
57
|
+
entries, err := h.service.ListEntries(ctx, app.ListWaitlistEntriesInput{
|
|
58
|
+
Status: request.Msg.GetStatus(),
|
|
59
|
+
Limit: int(request.Msg.GetLimit()),
|
|
60
|
+
})
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, toConnectError(err)
|
|
63
|
+
}
|
|
64
|
+
response := &waitlistv1.ListWaitlistEntriesResponse{Entries: make([]*waitlistv1.WaitlistEntry, 0, len(entries))}
|
|
65
|
+
for _, entry := range entries {
|
|
66
|
+
response.Entries = append(response.Entries, toProtoEntry(entry))
|
|
31
67
|
}
|
|
32
68
|
return connect.NewResponse(response), nil
|
|
33
69
|
}
|
|
34
70
|
|
|
35
|
-
func (h *Handler)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
71
|
+
func (h *Handler) UpdateWaitlistEntry(ctx context.Context, request *connect.Request[waitlistv1.UpdateWaitlistEntryRequest]) (*connect.Response[waitlistv1.GetWaitlistEntryResponse], error) {
|
|
72
|
+
entry, err := h.service.UpdateEntry(ctx, app.UpdateWaitlistEntryInput{
|
|
73
|
+
EntryID: request.Msg.GetEntryId(),
|
|
74
|
+
Status: request.Msg.GetStatus(),
|
|
75
|
+
})
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, toConnectError(err)
|
|
78
|
+
}
|
|
79
|
+
return connect.NewResponse(&waitlistv1.GetWaitlistEntryResponse{Entry: toProtoEntry(entry)}), nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (h *Handler) ExportWaitlistEntries(ctx context.Context, request *connect.Request[waitlistv1.ExportWaitlistEntriesRequest]) (*connect.Response[waitlistv1.ExportWaitlistEntriesResponse], error) {
|
|
83
|
+
csv, err := h.service.ExportEntries(ctx, app.ListWaitlistEntriesInput{
|
|
84
|
+
Status: request.Msg.GetStatus(),
|
|
85
|
+
Limit: int(request.Msg.GetLimit()),
|
|
42
86
|
})
|
|
43
87
|
if err != nil {
|
|
44
|
-
return nil,
|
|
88
|
+
return nil, toConnectError(err)
|
|
45
89
|
}
|
|
46
|
-
return connect.NewResponse(&
|
|
90
|
+
return connect.NewResponse(&waitlistv1.ExportWaitlistEntriesResponse{Csv: csv}), nil
|
|
47
91
|
}
|
|
48
92
|
|
|
49
|
-
func (h *Handler)
|
|
50
|
-
|
|
93
|
+
func (h *Handler) RecordTrigger(ctx context.Context, request *connect.Request[waitlistv1.RecordTriggerRequest]) (*connect.Response[waitlistv1.RecordTriggerResponse], error) {
|
|
94
|
+
trigger, err := h.service.RecordTrigger(ctx, app.RecordTriggerInput{
|
|
51
95
|
Type: request.Msg.GetType(),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
TTL: int(request.Msg.GetTtl()),
|
|
55
|
-
Proxied: request.Msg.GetProxied(),
|
|
96
|
+
EntryID: request.Msg.GetEntryId(),
|
|
97
|
+
Payload: jsonPayload(request.Msg.GetPayloadJson()),
|
|
56
98
|
})
|
|
57
99
|
if err != nil {
|
|
58
|
-
return nil,
|
|
100
|
+
return nil, toConnectError(err)
|
|
101
|
+
}
|
|
102
|
+
return connect.NewResponse(&waitlistv1.RecordTriggerResponse{Trigger: toProtoTrigger(trigger)}), nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func toConnectError(err error) error {
|
|
106
|
+
var appErr *app.AppError
|
|
107
|
+
if errors.As(err, &appErr) {
|
|
108
|
+
return connect.NewError(statusCodeToConnectCode(appErr.Status), err)
|
|
109
|
+
}
|
|
110
|
+
return connect.NewError(connect.CodeInternal, err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func statusCodeToConnectCode(status int) connect.Code {
|
|
114
|
+
switch status {
|
|
115
|
+
case 400:
|
|
116
|
+
return connect.CodeInvalidArgument
|
|
117
|
+
case 404:
|
|
118
|
+
return connect.CodeNotFound
|
|
119
|
+
case 409:
|
|
120
|
+
return connect.CodeAlreadyExists
|
|
121
|
+
default:
|
|
122
|
+
return connect.CodeInternal
|
|
59
123
|
}
|
|
60
|
-
return connect.NewResponse(&dnsv1.UpdateRecordResponse{Record: toProtoRecord(record)}), nil
|
|
61
124
|
}
|
|
62
125
|
|
|
63
|
-
func (
|
|
64
|
-
|
|
65
|
-
|
|
126
|
+
func toProtoEntry(entry app.WaitlistEntry) *waitlistv1.WaitlistEntry {
|
|
127
|
+
return &waitlistv1.WaitlistEntry{
|
|
128
|
+
Id: entry.ID,
|
|
129
|
+
Email: entry.Email,
|
|
130
|
+
Name: entry.Name,
|
|
131
|
+
Company: entry.Company,
|
|
132
|
+
Source: entry.Source,
|
|
133
|
+
Status: entry.Status,
|
|
134
|
+
CreatedAt: entry.CreatedAt,
|
|
135
|
+
UpdatedAt: entry.UpdatedAt,
|
|
66
136
|
}
|
|
67
|
-
return connect.NewResponse(&dnsv1.DeleteRecordResponse{}), nil
|
|
68
137
|
}
|
|
69
138
|
|
|
70
|
-
func
|
|
71
|
-
return &
|
|
72
|
-
Id:
|
|
73
|
-
Type:
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
139
|
+
func toProtoTrigger(trigger app.WaitlistTrigger) *waitlistv1.WaitlistTrigger {
|
|
140
|
+
return &waitlistv1.WaitlistTrigger{
|
|
141
|
+
Id: trigger.ID,
|
|
142
|
+
Type: trigger.Type,
|
|
143
|
+
EntryId: trigger.EntryID,
|
|
144
|
+
Status: trigger.Status,
|
|
145
|
+
PayloadJson: payloadToJSON(trigger.Payload),
|
|
146
|
+
CreatedAt: trigger.CreatedAt,
|
|
147
|
+
ProcessedAt: trigger.ProcessedAt,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func jsonPayload(value string) any {
|
|
152
|
+
if value == "" {
|
|
153
|
+
return map[string]any{}
|
|
154
|
+
}
|
|
155
|
+
var payload any
|
|
156
|
+
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
|
157
|
+
return map[string]any{}
|
|
158
|
+
}
|
|
159
|
+
return payload
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func payloadToJSON(value any) string {
|
|
163
|
+
encoded, err := json.Marshal(value)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return "{}"
|
|
78
166
|
}
|
|
167
|
+
return string(encoded)
|
|
79
168
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
package connectapi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"connectrpc.com/connect"
|
|
12
|
+
_ "github.com/jackc/pgx/v5/stdlib"
|
|
13
|
+
|
|
14
|
+
waitlistv1 "{{MODULE_PATH}}/gen/waitlist/v1"
|
|
15
|
+
waitlistv1connect "{{MODULE_PATH}}/gen/waitlist/v1/waitlistv1connect"
|
|
16
|
+
"{{MODULE_PATH}}/internal/app"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
func TestWaitlistRPCJoinIsIdempotentAndRecordsTriggers(t *testing.T) {
|
|
20
|
+
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
|
21
|
+
if databaseURL == "" {
|
|
22
|
+
t.Skip("DATABASE_URL is required for integration test")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
db, err := app.OpenDatabase(context.Background(), databaseURL)
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatalf("open database: %v", err)
|
|
28
|
+
}
|
|
29
|
+
t.Cleanup(func() { _ = db.Close() })
|
|
30
|
+
|
|
31
|
+
if _, err := db.ExecContext(context.Background(), `
|
|
32
|
+
truncate table
|
|
33
|
+
waitlist_triggers,
|
|
34
|
+
waitlist_entries
|
|
35
|
+
restart identity cascade`); err != nil {
|
|
36
|
+
t.Fatalf("truncate tables: %v", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
service := app.NewWaitlistService(db)
|
|
40
|
+
path, handler := NewHandler(service)
|
|
41
|
+
mux := http.NewServeMux()
|
|
42
|
+
mux.Handle(path, handler)
|
|
43
|
+
server := httptest.NewServer(mux)
|
|
44
|
+
t.Cleanup(server.Close)
|
|
45
|
+
|
|
46
|
+
client := waitlistv1connect.NewWaitlistServiceClient(http.DefaultClient, server.URL)
|
|
47
|
+
|
|
48
|
+
first, err := client.JoinWaitlist(context.Background(), connect.NewRequest(&waitlistv1.JoinWaitlistRequest{
|
|
49
|
+
Email: "Founder@Example.com",
|
|
50
|
+
Name: "Founder",
|
|
51
|
+
Company: "Example Co",
|
|
52
|
+
Source: "homepage",
|
|
53
|
+
}))
|
|
54
|
+
if err != nil {
|
|
55
|
+
t.Fatalf("join waitlist: %v", err)
|
|
56
|
+
}
|
|
57
|
+
if !first.Msg.GetCreated() {
|
|
58
|
+
t.Fatal("expected first join to create entry")
|
|
59
|
+
}
|
|
60
|
+
if first.Msg.GetEntry().GetEmail() != "founder@example.com" {
|
|
61
|
+
t.Fatalf("expected normalized email, got %s", first.Msg.GetEntry().GetEmail())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
second, err := client.JoinWaitlist(context.Background(), connect.NewRequest(&waitlistv1.JoinWaitlistRequest{
|
|
65
|
+
Email: "founder@example.com",
|
|
66
|
+
}))
|
|
67
|
+
if err != nil {
|
|
68
|
+
t.Fatalf("join waitlist again: %v", err)
|
|
69
|
+
}
|
|
70
|
+
if second.Msg.GetCreated() {
|
|
71
|
+
t.Fatal("expected second join to be idempotent")
|
|
72
|
+
}
|
|
73
|
+
if second.Msg.GetEntry().GetId() != first.Msg.GetEntry().GetId() {
|
|
74
|
+
t.Fatalf("expected same entry id, got %s and %s", first.Msg.GetEntry().GetId(), second.Msg.GetEntry().GetId())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
trigger, err := client.RecordTrigger(context.Background(), connect.NewRequest(&waitlistv1.RecordTriggerRequest{
|
|
78
|
+
Type: "cron.digest",
|
|
79
|
+
EntryId: first.Msg.GetEntry().GetId(),
|
|
80
|
+
PayloadJson: "{}",
|
|
81
|
+
}))
|
|
82
|
+
if err != nil {
|
|
83
|
+
t.Fatalf("record trigger: %v", err)
|
|
84
|
+
}
|
|
85
|
+
if trigger.Msg.GetTrigger().GetType() != "cron.digest" {
|
|
86
|
+
t.Fatalf("expected cron.digest trigger, got %s", trigger.Msg.GetTrigger().GetType())
|
|
87
|
+
}
|
|
88
|
+
if trigger.Msg.GetTrigger().GetEntryId() != first.Msg.GetEntry().GetId() {
|
|
89
|
+
t.Fatalf("expected entry id %s, got %s", first.Msg.GetEntry().GetId(), trigger.Msg.GetTrigger().GetEntryId())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updated, err := client.UpdateWaitlistEntry(context.Background(), connect.NewRequest(&waitlistv1.UpdateWaitlistEntryRequest{
|
|
93
|
+
EntryId: first.Msg.GetEntry().GetId(),
|
|
94
|
+
Status: "invited",
|
|
95
|
+
}))
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatalf("update waitlist entry: %v", err)
|
|
98
|
+
}
|
|
99
|
+
if updated.Msg.GetEntry().GetStatus() != "invited" {
|
|
100
|
+
t.Fatalf("expected invited status, got %s", updated.Msg.GetEntry().GetStatus())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
list, err := client.ListWaitlistEntries(context.Background(), connect.NewRequest(&waitlistv1.ListWaitlistEntriesRequest{
|
|
104
|
+
Status: "invited",
|
|
105
|
+
}))
|
|
106
|
+
if err != nil {
|
|
107
|
+
t.Fatalf("list waitlist entries: %v", err)
|
|
108
|
+
}
|
|
109
|
+
if len(list.Msg.GetEntries()) != 1 || list.Msg.GetEntries()[0].GetId() != first.Msg.GetEntry().GetId() {
|
|
110
|
+
t.Fatalf("expected one invited entry %s, got %+v", first.Msg.GetEntry().GetId(), list.Msg.GetEntries())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
exported, err := client.ExportWaitlistEntries(context.Background(), connect.NewRequest(&waitlistv1.ExportWaitlistEntriesRequest{
|
|
114
|
+
Status: "invited",
|
|
115
|
+
}))
|
|
116
|
+
if err != nil {
|
|
117
|
+
t.Fatalf("export waitlist entries: %v", err)
|
|
118
|
+
}
|
|
119
|
+
if !strings.Contains(exported.Msg.GetCsv(), "founder@example.com") {
|
|
120
|
+
t.Fatalf("expected exported csv to contain email, got %s", exported.Msg.GetCsv())
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -3,6 +3,7 @@ package httpapi
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"errors"
|
|
6
|
+
"io"
|
|
6
7
|
"net/http"
|
|
7
8
|
"strconv"
|
|
8
9
|
"strings"
|
|
@@ -12,67 +13,154 @@ import (
|
|
|
12
13
|
"{{MODULE_PATH}}/internal/app"
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
func RegisterRoutes(router chi.Router, service *app.
|
|
16
|
+
func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
|
|
16
17
|
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
17
18
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
18
19
|
})
|
|
20
|
+
router.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
|
21
|
+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
22
|
+
})
|
|
23
|
+
router.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
|
24
|
+
writeJSON(w, http.StatusOK, map[string]string{
|
|
25
|
+
"service": "{{SERVICE_NAME}}",
|
|
26
|
+
"domain": "waitlist",
|
|
27
|
+
"apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
router.Post("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
32
|
+
var input app.JoinWaitlistInput
|
|
33
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
34
|
+
writeError(w, err)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
result, err := service.JoinWaitlist(request.Context(), input)
|
|
38
|
+
if err != nil {
|
|
39
|
+
writeError(w, err)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
status := http.StatusOK
|
|
43
|
+
if result.Created {
|
|
44
|
+
status = http.StatusCreated
|
|
45
|
+
}
|
|
46
|
+
writeJSON(w, status, result)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
router.Get("/v1/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
50
|
+
entry, err := service.GetEntryByEmail(request.Context(), request.URL.Query().Get("email"))
|
|
51
|
+
if err != nil {
|
|
52
|
+
writeError(w, err)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.Get("/v1/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
59
|
+
entry, err := service.GetEntry(request.Context(), chi.URLParam(request, "entryID"))
|
|
60
|
+
if err != nil {
|
|
61
|
+
writeError(w, err)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
router.Get("/v1/admin/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
68
|
+
entries, err := service.ListEntries(request.Context(), app.ListWaitlistEntriesInput{
|
|
69
|
+
Status: request.URL.Query().Get("status"),
|
|
70
|
+
Limit: optionalInt(request.URL.Query().Get("limit")),
|
|
71
|
+
})
|
|
72
|
+
if err != nil {
|
|
73
|
+
writeError(w, err)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
router.Get("/v1/admin/waitlist/export", func(w http.ResponseWriter, request *http.Request) {
|
|
80
|
+
csv, err := service.ExportEntries(request.Context(), app.ListWaitlistEntriesInput{
|
|
81
|
+
Status: request.URL.Query().Get("status"),
|
|
82
|
+
Limit: optionalInt(request.URL.Query().Get("limit")),
|
|
83
|
+
})
|
|
84
|
+
if err != nil {
|
|
85
|
+
writeError(w, err)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
89
|
+
w.Header().Set("Content-Disposition", `attachment; filename="waitlist.csv"`)
|
|
90
|
+
w.WriteHeader(http.StatusOK)
|
|
91
|
+
_, _ = w.Write([]byte(csv))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
router.Patch("/v1/admin/waitlist/{entryID}", func(w http.ResponseWriter, request *http.Request) {
|
|
95
|
+
var input app.UpdateWaitlistEntryInput
|
|
96
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
97
|
+
writeError(w, err)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
input.EntryID = chi.URLParam(request, "entryID")
|
|
101
|
+
entry, err := service.UpdateEntry(request.Context(), input)
|
|
102
|
+
if err != nil {
|
|
103
|
+
writeError(w, err)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
writeJSON(w, http.StatusOK, map[string]any{"entry": entry})
|
|
107
|
+
})
|
|
19
108
|
|
|
20
|
-
router.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
109
|
+
router.Post("/v1/triggers/waitlist", func(w http.ResponseWriter, request *http.Request) {
|
|
110
|
+
var payload map[string]any
|
|
111
|
+
if err := decodeOptionalJSON(request, &payload); err != nil {
|
|
112
|
+
writeError(w, err)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
116
|
+
Type: stringValue(payload, "type", "manual"),
|
|
117
|
+
EntryID: stringValue(payload, "entry_id", ""),
|
|
118
|
+
Payload: payload,
|
|
28
119
|
})
|
|
120
|
+
if err != nil {
|
|
121
|
+
writeError(w, err)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
125
|
+
})
|
|
29
126
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
writeJSON(w, http.StatusCreated, map[string]any{"record": record})
|
|
127
|
+
router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
|
|
128
|
+
rawBody, err := io.ReadAll(request.Body)
|
|
129
|
+
if err != nil {
|
|
130
|
+
writeError(w, err)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
trigger, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
134
|
+
Type: "webhook." + chi.URLParam(request, "provider"),
|
|
135
|
+
Payload: map[string]any{
|
|
136
|
+
"headers": request.Header,
|
|
137
|
+
"rawBody": string(rawBody),
|
|
138
|
+
},
|
|
43
139
|
})
|
|
140
|
+
if err != nil {
|
|
141
|
+
writeError(w, err)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
writeJSON(w, http.StatusAccepted, map[string]any{"trigger": trigger})
|
|
145
|
+
})
|
|
44
146
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
writeError(w, err)
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
record, err := service.UpdateRecord(request.Context(), chi.URLParam(request, "recordID"), input)
|
|
54
|
-
if err != nil {
|
|
55
|
-
writeError(w, err)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
writeJSON(w, http.StatusOK, map[string]any{"record": record})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
r.Delete("/", func(w http.ResponseWriter, request *http.Request) {
|
|
62
|
-
if err := service.DeleteRecord(request.Context(), chi.URLParam(request, "recordID")); err != nil {
|
|
63
|
-
writeError(w, err)
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
w.WriteHeader(http.StatusNoContent)
|
|
67
|
-
})
|
|
147
|
+
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
148
|
+
writeJSON(w, http.StatusOK, map[string]string{
|
|
149
|
+
"status": "ok",
|
|
150
|
+
"provider": chi.URLParam(request, "provider"),
|
|
68
151
|
})
|
|
69
152
|
})
|
|
70
153
|
}
|
|
71
154
|
|
|
72
155
|
func decodeJSON(request *http.Request, out any) error {
|
|
73
156
|
defer request.Body.Close()
|
|
157
|
+
return json.NewDecoder(request.Body).Decode(out)
|
|
158
|
+
}
|
|
74
159
|
|
|
75
|
-
|
|
160
|
+
func decodeOptionalJSON(request *http.Request, out any) error {
|
|
161
|
+
defer request.Body.Close()
|
|
162
|
+
decoder := json.NewDecoder(request.Body)
|
|
163
|
+
if err := decoder.Decode(out); err != nil && !errors.Is(err, io.EOF) {
|
|
76
164
|
return err
|
|
77
165
|
}
|
|
78
166
|
return nil
|
|
@@ -85,9 +173,44 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
|
85
173
|
}
|
|
86
174
|
|
|
87
175
|
func writeError(w http.ResponseWriter, err error) {
|
|
176
|
+
var appErr *app.AppError
|
|
177
|
+
if errors.As(err, &appErr) {
|
|
178
|
+
writeJSON(w, appErr.Status, map[string]string{
|
|
179
|
+
"error": appErr.Error(),
|
|
180
|
+
"code": appErr.Code,
|
|
181
|
+
})
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
88
185
|
status := http.StatusInternalServerError
|
|
89
|
-
if
|
|
186
|
+
if strings.Contains(strings.ToLower(err.Error()), "json") {
|
|
90
187
|
status = http.StatusBadRequest
|
|
91
188
|
}
|
|
92
189
|
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
93
190
|
}
|
|
191
|
+
|
|
192
|
+
func stringValue(values map[string]any, key string, fallback string) string {
|
|
193
|
+
if values == nil {
|
|
194
|
+
return fallback
|
|
195
|
+
}
|
|
196
|
+
value, ok := values[key]
|
|
197
|
+
if !ok && key == "entry_id" {
|
|
198
|
+
value, ok = values["entryId"]
|
|
199
|
+
}
|
|
200
|
+
if !ok {
|
|
201
|
+
return fallback
|
|
202
|
+
}
|
|
203
|
+
text, ok := value.(string)
|
|
204
|
+
if !ok {
|
|
205
|
+
return fallback
|
|
206
|
+
}
|
|
207
|
+
return strings.TrimSpace(text)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func optionalInt(value string) int {
|
|
211
|
+
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
212
|
+
if err != nil {
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
215
|
+
return parsed
|
|
216
|
+
}
|