create-svc 0.1.53 → 0.1.55
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 +6 -0
- package/package.json +1 -1
- package/src/git-bootstrap.test.ts +49 -1
- package/src/git-bootstrap.ts +13 -1
- package/src/scaffold.test.ts +38 -8
- package/src/scaffold.ts +28 -1
- package/src/service-runtime/cloudrun/cli.ts +10 -0
- package/src/service-runtime/cloudrun/config.ts +3 -0
- package/src/service-runtime/cloudrun/lib.ts +0 -3
- package/src/service-runtime/cloudrun/observability.ts +18 -0
- package/templates/shared/.env.example +40 -0
- package/templates/shared/.github/workflows/deploy.yml +2 -1
- package/templates/shared/.github/workflows/preview.yml +5 -3
- package/templates/shared/README.md +37 -0
- package/templates/shared/_gitignore +13 -0
- package/templates/shared/service.jsonc +8 -0
- package/templates/targets/workers/.github/workflows/deploy.yml +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-connectrpc/package.json +1 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +52 -3
- package/templates/variants/bun-connectrpc/src/db/schema.ts +13 -0
- package/templates/variants/bun-connectrpc/src/index.ts +47 -4
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-connectrpc/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +10 -0
- package/templates/variants/bun-hono/package.json +1 -0
- package/templates/variants/bun-hono/src/db/repository.ts +52 -3
- package/templates/variants/bun-hono/src/db/schema.ts +13 -0
- package/templates/variants/bun-hono/src/index.ts +34 -8
- package/templates/variants/bun-hono/src/waitlist/service.ts +22 -0
- package/templates/variants/bun-hono/src/waitlist/types.ts +16 -0
- package/templates/variants/bun-hono/test/app.test.ts +13 -0
- package/templates/variants/go-chi/Dockerfile +0 -1
- package/templates/variants/go-chi/Makefile +4 -1
- package/templates/variants/go-chi/internal/app/service.go +96 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-chi/migrations/0000_init.sql +10 -0
- package/templates/variants/go-chi/migrations/atlas.sum +2 -2
- package/templates/variants/go-connectrpc/Makefile +4 -1
- package/templates/variants/go-connectrpc/internal/app/service.go +96 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +56 -7
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +10 -0
- package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -2
|
@@ -3,10 +3,12 @@ package httpapi
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"errors"
|
|
6
|
+
"fmt"
|
|
6
7
|
"io"
|
|
7
8
|
"net/http"
|
|
8
9
|
"strconv"
|
|
9
10
|
"strings"
|
|
11
|
+
"time"
|
|
10
12
|
|
|
11
13
|
"github.com/go-chi/chi/v5"
|
|
12
14
|
|
|
@@ -130,18 +132,35 @@ func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
|
|
|
130
132
|
writeError(w, err)
|
|
131
133
|
return
|
|
132
134
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
provider := chi.URLParam(request, "provider")
|
|
136
|
+
payload := parseWebhookPayload(rawBody)
|
|
137
|
+
result, err := service.RecordWebhookEvent(request.Context(), app.RecordWebhookEventInput{
|
|
138
|
+
Provider: provider,
|
|
139
|
+
ExternalEventID: webhookEventID(payload, request.Header),
|
|
140
|
+
Payload: payload,
|
|
141
|
+
Headers: headersPayload(request.Header),
|
|
139
142
|
})
|
|
140
143
|
if err != nil {
|
|
141
144
|
writeError(w, err)
|
|
142
145
|
return
|
|
143
146
|
}
|
|
144
|
-
|
|
147
|
+
if !result.Duplicate {
|
|
148
|
+
if _, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
149
|
+
Type: "webhook." + provider,
|
|
150
|
+
Payload: map[string]any{
|
|
151
|
+
"headers": headersPayload(request.Header),
|
|
152
|
+
"rawBody": string(rawBody),
|
|
153
|
+
},
|
|
154
|
+
}); err != nil {
|
|
155
|
+
writeError(w, err)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if result.Duplicate {
|
|
160
|
+
writeJSON(w, http.StatusOK, result)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
writeJSON(w, http.StatusAccepted, result)
|
|
145
164
|
})
|
|
146
165
|
|
|
147
166
|
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
@@ -157,6 +176,36 @@ func decodeJSON(request *http.Request, out any) error {
|
|
|
157
176
|
return json.NewDecoder(request.Body).Decode(out)
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
func parseWebhookPayload(rawBody []byte) map[string]any {
|
|
180
|
+
var payload map[string]any
|
|
181
|
+
if len(rawBody) == 0 || json.Unmarshal(rawBody, &payload) != nil {
|
|
182
|
+
return map[string]any{"rawBody": string(rawBody)}
|
|
183
|
+
}
|
|
184
|
+
return payload
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func webhookEventID(payload map[string]any, headers http.Header) string {
|
|
188
|
+
if id, ok := payload["id"].(string); ok && id != "" {
|
|
189
|
+
return id
|
|
190
|
+
}
|
|
191
|
+
if id := headers.Get("X-Webhook-Event-Id"); id != "" {
|
|
192
|
+
return id
|
|
193
|
+
}
|
|
194
|
+
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func headersPayload(headers http.Header) map[string]any {
|
|
198
|
+
out := make(map[string]any, len(headers))
|
|
199
|
+
for key, values := range headers {
|
|
200
|
+
if len(values) == 1 {
|
|
201
|
+
out[key] = values[0]
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
out[key] = values
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
160
209
|
func decodeOptionalJSON(request *http.Request, out any) error {
|
|
161
210
|
defer request.Body.Close()
|
|
162
211
|
decoder := json.NewDecoder(request.Body)
|
|
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
|
|
|
18
18
|
created_at timestamptz not null default now(),
|
|
19
19
|
processed_at timestamptz
|
|
20
20
|
);
|
|
21
|
+
|
|
22
|
+
create table if not exists webhook_events (
|
|
23
|
+
id text primary key,
|
|
24
|
+
provider text not null,
|
|
25
|
+
external_event_id text not null,
|
|
26
|
+
payload_json text not null,
|
|
27
|
+
headers_json text not null,
|
|
28
|
+
received_at timestamptz not null default now(),
|
|
29
|
+
unique (provider, external_event_id)
|
|
30
|
+
);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
h1:
|
|
2
|
-
0000_init.sql h1:
|
|
1
|
+
h1:AhMVwNnmHR10XivPa/5MXyIZrG7LPmekS6SpgjTGJyA=
|
|
2
|
+
0000_init.sql h1:9Sm7GI+kmYAsnRTFdZkIZl0bsQN5EUlN/AopryCCRgs=
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate migrate-lint gen lint test create deploy dashboards auth destroy
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test create deploy dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
@@ -39,6 +39,9 @@ deploy:
|
|
|
39
39
|
dashboards:
|
|
40
40
|
$(SERVICE) dashboards
|
|
41
41
|
|
|
42
|
+
observability-bootstrap:
|
|
43
|
+
$(SERVICE) observability-bootstrap
|
|
44
|
+
|
|
42
45
|
auth:
|
|
43
46
|
$(SERVICE) auth $(ARGS)
|
|
44
47
|
|
|
@@ -36,6 +36,15 @@ type WaitlistTrigger struct {
|
|
|
36
36
|
ProcessedAt string `json:"processed_at,omitempty" db:"processed_at"`
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
type WebhookEvent struct {
|
|
40
|
+
ID string `json:"id" db:"id"`
|
|
41
|
+
Provider string `json:"provider" db:"provider"`
|
|
42
|
+
ExternalEventID string `json:"external_event_id" db:"external_event_id"`
|
|
43
|
+
Payload map[string]any `json:"payload"`
|
|
44
|
+
Headers map[string]any `json:"headers"`
|
|
45
|
+
ReceivedAt string `json:"received_at" db:"received_at"`
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
type JoinWaitlistInput struct {
|
|
40
49
|
Email string `json:"email"`
|
|
41
50
|
Name string `json:"name"`
|
|
@@ -54,6 +63,18 @@ type RecordTriggerInput struct {
|
|
|
54
63
|
Payload any `json:"payload"`
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
type RecordWebhookEventInput struct {
|
|
67
|
+
Provider string `json:"provider"`
|
|
68
|
+
ExternalEventID string `json:"external_event_id"`
|
|
69
|
+
Payload map[string]any `json:"payload"`
|
|
70
|
+
Headers map[string]any `json:"headers"`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type RecordWebhookEventResult struct {
|
|
74
|
+
Event WebhookEvent `json:"event"`
|
|
75
|
+
Duplicate bool `json:"duplicate"`
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
type ListWaitlistEntriesInput struct {
|
|
58
79
|
Status string `json:"status"`
|
|
59
80
|
Limit int `json:"limit"`
|
|
@@ -252,6 +273,53 @@ returning id, type, coalesce(entry_id, '') as entry_id, status, payload_json, cr
|
|
|
252
273
|
return row.toTrigger()
|
|
253
274
|
}
|
|
254
275
|
|
|
276
|
+
func (s *WaitlistService) RecordWebhookEvent(ctx context.Context, input RecordWebhookEventInput) (RecordWebhookEventResult, error) {
|
|
277
|
+
provider := strings.TrimSpace(input.Provider)
|
|
278
|
+
if provider == "" {
|
|
279
|
+
return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_provider", Err: errors.New("webhook provider is required")}
|
|
280
|
+
}
|
|
281
|
+
externalEventID := strings.TrimSpace(input.ExternalEventID)
|
|
282
|
+
if externalEventID == "" {
|
|
283
|
+
return RecordWebhookEventResult{}, &AppError{Status: 400, Code: "invalid_webhook_event_id", Err: errors.New("webhook event id is required")}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
payloadBytes, err := json.Marshal(input.Payload)
|
|
287
|
+
if err != nil {
|
|
288
|
+
return RecordWebhookEventResult{}, err
|
|
289
|
+
}
|
|
290
|
+
headersBytes, err := json.Marshal(input.Headers)
|
|
291
|
+
if err != nil {
|
|
292
|
+
return RecordWebhookEventResult{}, err
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
id := fmt.Sprintf("wh_%d", time.Now().UnixNano())
|
|
296
|
+
var row webhookEventRow
|
|
297
|
+
err = s.db.GetContext(ctx, &row, `
|
|
298
|
+
insert into webhook_events (id, provider, external_event_id, payload_json, headers_json)
|
|
299
|
+
values ($1, $2, $3, $4, $5)
|
|
300
|
+
on conflict (provider, external_event_id) do nothing
|
|
301
|
+
returning id, provider, external_event_id, payload_json, headers_json, received_at::text
|
|
302
|
+
`, id, provider, externalEventID, string(payloadBytes), string(headersBytes))
|
|
303
|
+
if err == nil {
|
|
304
|
+
event, err := row.toWebhookEvent()
|
|
305
|
+
return RecordWebhookEventResult{Event: event, Duplicate: false}, err
|
|
306
|
+
}
|
|
307
|
+
if !errors.Is(err, sql.ErrNoRows) {
|
|
308
|
+
return RecordWebhookEventResult{}, err
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
err = s.db.GetContext(ctx, &row, `
|
|
312
|
+
select id, provider, external_event_id, payload_json, headers_json, received_at::text
|
|
313
|
+
from webhook_events
|
|
314
|
+
where provider = $1 and external_event_id = $2
|
|
315
|
+
`, provider, externalEventID)
|
|
316
|
+
if err != nil {
|
|
317
|
+
return RecordWebhookEventResult{}, err
|
|
318
|
+
}
|
|
319
|
+
event, err := row.toWebhookEvent()
|
|
320
|
+
return RecordWebhookEventResult{Event: event, Duplicate: true}, err
|
|
321
|
+
}
|
|
322
|
+
|
|
255
323
|
type waitlistTriggerRow struct {
|
|
256
324
|
ID string `db:"id"`
|
|
257
325
|
Type string `db:"type"`
|
|
@@ -262,6 +330,34 @@ type waitlistTriggerRow struct {
|
|
|
262
330
|
ProcessedAt string `db:"processed_at"`
|
|
263
331
|
}
|
|
264
332
|
|
|
333
|
+
type webhookEventRow struct {
|
|
334
|
+
ID string `db:"id"`
|
|
335
|
+
Provider string `db:"provider"`
|
|
336
|
+
ExternalEventID string `db:"external_event_id"`
|
|
337
|
+
PayloadJSON string `db:"payload_json"`
|
|
338
|
+
HeadersJSON string `db:"headers_json"`
|
|
339
|
+
ReceivedAt string `db:"received_at"`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
func (r webhookEventRow) toWebhookEvent() (WebhookEvent, error) {
|
|
343
|
+
var payload map[string]any
|
|
344
|
+
if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
|
|
345
|
+
return WebhookEvent{}, err
|
|
346
|
+
}
|
|
347
|
+
var headers map[string]any
|
|
348
|
+
if err := json.Unmarshal([]byte(r.HeadersJSON), &headers); err != nil {
|
|
349
|
+
return WebhookEvent{}, err
|
|
350
|
+
}
|
|
351
|
+
return WebhookEvent{
|
|
352
|
+
ID: r.ID,
|
|
353
|
+
Provider: r.Provider,
|
|
354
|
+
ExternalEventID: r.ExternalEventID,
|
|
355
|
+
Payload: payload,
|
|
356
|
+
Headers: headers,
|
|
357
|
+
ReceivedAt: r.ReceivedAt,
|
|
358
|
+
}, nil
|
|
359
|
+
}
|
|
360
|
+
|
|
265
361
|
func (r waitlistTriggerRow) toTrigger() (WaitlistTrigger, error) {
|
|
266
362
|
var payload any
|
|
267
363
|
if err := json.Unmarshal([]byte(r.PayloadJSON), &payload); err != nil {
|
|
@@ -3,10 +3,12 @@ package httpapi
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"errors"
|
|
6
|
+
"fmt"
|
|
6
7
|
"io"
|
|
7
8
|
"net/http"
|
|
8
9
|
"strconv"
|
|
9
10
|
"strings"
|
|
11
|
+
"time"
|
|
10
12
|
|
|
11
13
|
"github.com/go-chi/chi/v5"
|
|
12
14
|
|
|
@@ -130,18 +132,35 @@ func RegisterRoutes(router chi.Router, service *app.WaitlistService) {
|
|
|
130
132
|
writeError(w, err)
|
|
131
133
|
return
|
|
132
134
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
provider := chi.URLParam(request, "provider")
|
|
136
|
+
payload := parseWebhookPayload(rawBody)
|
|
137
|
+
result, err := service.RecordWebhookEvent(request.Context(), app.RecordWebhookEventInput{
|
|
138
|
+
Provider: provider,
|
|
139
|
+
ExternalEventID: webhookEventID(payload, request.Header),
|
|
140
|
+
Payload: payload,
|
|
141
|
+
Headers: headersPayload(request.Header),
|
|
139
142
|
})
|
|
140
143
|
if err != nil {
|
|
141
144
|
writeError(w, err)
|
|
142
145
|
return
|
|
143
146
|
}
|
|
144
|
-
|
|
147
|
+
if !result.Duplicate {
|
|
148
|
+
if _, err := service.RecordTrigger(request.Context(), app.RecordTriggerInput{
|
|
149
|
+
Type: "webhook." + provider,
|
|
150
|
+
Payload: map[string]any{
|
|
151
|
+
"headers": headersPayload(request.Header),
|
|
152
|
+
"rawBody": string(rawBody),
|
|
153
|
+
},
|
|
154
|
+
}); err != nil {
|
|
155
|
+
writeError(w, err)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if result.Duplicate {
|
|
160
|
+
writeJSON(w, http.StatusOK, result)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
writeJSON(w, http.StatusAccepted, result)
|
|
145
164
|
})
|
|
146
165
|
|
|
147
166
|
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
@@ -157,6 +176,36 @@ func decodeJSON(request *http.Request, out any) error {
|
|
|
157
176
|
return json.NewDecoder(request.Body).Decode(out)
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
func parseWebhookPayload(rawBody []byte) map[string]any {
|
|
180
|
+
var payload map[string]any
|
|
181
|
+
if len(rawBody) == 0 || json.Unmarshal(rawBody, &payload) != nil {
|
|
182
|
+
return map[string]any{"rawBody": string(rawBody)}
|
|
183
|
+
}
|
|
184
|
+
return payload
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
func webhookEventID(payload map[string]any, headers http.Header) string {
|
|
188
|
+
if id, ok := payload["id"].(string); ok && id != "" {
|
|
189
|
+
return id
|
|
190
|
+
}
|
|
191
|
+
if id := headers.Get("X-Webhook-Event-Id"); id != "" {
|
|
192
|
+
return id
|
|
193
|
+
}
|
|
194
|
+
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func headersPayload(headers http.Header) map[string]any {
|
|
198
|
+
out := make(map[string]any, len(headers))
|
|
199
|
+
for key, values := range headers {
|
|
200
|
+
if len(values) == 1 {
|
|
201
|
+
out[key] = values[0]
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
out[key] = values
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
160
209
|
func decodeOptionalJSON(request *http.Request, out any) error {
|
|
161
210
|
defer request.Body.Close()
|
|
162
211
|
decoder := json.NewDecoder(request.Body)
|
|
@@ -18,3 +18,13 @@ create table if not exists waitlist_triggers (
|
|
|
18
18
|
created_at timestamptz not null default now(),
|
|
19
19
|
processed_at timestamptz
|
|
20
20
|
);
|
|
21
|
+
|
|
22
|
+
create table if not exists webhook_events (
|
|
23
|
+
id text primary key,
|
|
24
|
+
provider text not null,
|
|
25
|
+
external_event_id text not null,
|
|
26
|
+
payload_json text not null,
|
|
27
|
+
headers_json text not null,
|
|
28
|
+
received_at timestamptz not null default now(),
|
|
29
|
+
unique (provider, external_event_id)
|
|
30
|
+
);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
h1:
|
|
2
|
-
0000_init.sql h1:
|
|
1
|
+
h1:AhMVwNnmHR10XivPa/5MXyIZrG7LPmekS6SpgjTGJyA=
|
|
2
|
+
0000_init.sql h1:9Sm7GI+kmYAsnRTFdZkIZl0bsQN5EUlN/AopryCCRgs=
|