create-svc 0.1.8 → 0.1.10
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 +142 -13
- package/package.json +9 -4
- package/src/cli.test.ts +29 -8
- package/src/cli.ts +103 -70
- package/src/naming.test.ts +4 -2
- package/src/naming.ts +9 -1
- package/src/neon.ts +10 -8
- package/src/post-scaffold.ts +7 -28
- package/src/profiles.ts +28 -0
- package/src/scaffold.test.ts +126 -15
- package/src/scaffold.ts +94 -23
- package/src/vault.test.ts +62 -5
- package/src/vault.ts +24 -4
- package/templates/shared/README.md +143 -26
- package/templates/shared/docker-compose.yml +19 -0
- package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
- package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
- package/templates/shared/scripts/cloudrun/config.ts +14 -19
- package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
- package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
- package/templates/shared/scripts/cloudrun/lib.ts +88 -112
- package/templates/shared/scripts/cloudrun/neon.ts +100 -14
- package/templates/shared/service.yaml +44 -1
- package/templates/variants/bun-connectrpc/Dockerfile +1 -0
- package/templates/variants/bun-connectrpc/Makefile +4 -1
- package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
- package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-connectrpc/package.json +17 -0
- package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
- package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
- package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
- package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
- package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
- package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
- package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
- package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
- package/templates/variants/bun-connectrpc/src/index.ts +294 -22
- package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
- package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
- package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
- package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
- package/templates/variants/bun-hono/Makefile +4 -1
- package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
- package/templates/variants/bun-hono/package.json +13 -0
- package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
- package/templates/variants/bun-hono/src/chat/service.ts +384 -0
- package/templates/variants/bun-hono/src/chat/types.ts +142 -0
- package/templates/variants/bun-hono/src/db/client.ts +15 -0
- package/templates/variants/bun-hono/src/db/repository.ts +479 -0
- package/templates/variants/bun-hono/src/db/schema.ts +75 -0
- package/templates/variants/bun-hono/src/index.ts +254 -8
- package/templates/variants/bun-hono/src/storage.ts +72 -0
- package/templates/variants/bun-hono/src/webhooks.ts +35 -0
- package/templates/variants/bun-hono/test/app.test.ts +60 -6
- package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
- package/templates/variants/bun-hono/tsconfig.json +1 -0
- package/templates/variants/go-chi/Makefile +6 -2
- package/templates/variants/go-chi/buf.gen.yaml +2 -0
- package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
- package/templates/variants/go-chi/cmd/server/main.go +16 -15
- package/templates/variants/go-chi/go.mod +3 -0
- package/templates/variants/go-chi/internal/app/service.go +763 -71
- package/templates/variants/go-chi/internal/config/config.go +22 -7
- package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
- package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
- package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
- package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
- package/templates/variants/go-chi/test/go.test.ts +4 -1
- package/templates/variants/go-connectrpc/Makefile +6 -2
- package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
- package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
- package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
- package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
- package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
- package/templates/variants/go-connectrpc/go.mod +4 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
- package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
- package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
- package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
- package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
- package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
- package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
- package/templates/shared/.env.example +0 -10
- 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
|
@@ -1,17 +1,32 @@
|
|
|
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
|
+
AttachmentBucket string
|
|
13
|
+
AttachmentPublicBaseURL string
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
func Load() (Config, error) {
|
|
11
|
-
|
|
12
|
-
Port:
|
|
13
|
-
DatabaseURL:
|
|
14
|
-
|
|
17
|
+
cfg := Config{
|
|
18
|
+
Port: envOr("PORT", "8080"),
|
|
19
|
+
DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
|
20
|
+
AttachmentBucket: strings.TrimSpace(os.Getenv("ATTACHMENT_BUCKET")),
|
|
21
|
+
AttachmentPublicBaseURL: strings.TrimSpace(os.Getenv("ATTACHMENT_PUBLIC_BASE_URL")),
|
|
22
|
+
}
|
|
23
|
+
if cfg.DatabaseURL == "" {
|
|
24
|
+
return Config{}, errors.New("DATABASE_URL is required")
|
|
25
|
+
}
|
|
26
|
+
if cfg.AttachmentBucket == "" {
|
|
27
|
+
return Config{}, errors.New("ATTACHMENT_BUCKET is required")
|
|
28
|
+
}
|
|
29
|
+
return cfg, nil
|
|
15
30
|
}
|
|
16
31
|
|
|
17
32
|
func envOr(key string, fallback string) string {
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
package httpapi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"net/url"
|
|
11
|
+
"os"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/go-chi/chi/v5"
|
|
17
|
+
_ "github.com/jackc/pgx/v5/stdlib"
|
|
18
|
+
"github.com/jmoiron/sqlx"
|
|
19
|
+
|
|
20
|
+
"{{MODULE_PATH}}/internal/app"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
func TestListMessagesPaginationIncludesAttachmentMetadata(t *testing.T) {
|
|
24
|
+
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
|
25
|
+
if databaseURL == "" {
|
|
26
|
+
t.Skip("DATABASE_URL is required for integration test")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
t.Setenv("ATTACHMENT_PUBLIC_BASE_URL", "https://storage.test")
|
|
30
|
+
|
|
31
|
+
db, err := app.OpenDatabase(context.Background(), databaseURL)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatalf("open database: %v", err)
|
|
34
|
+
}
|
|
35
|
+
t.Cleanup(func() { _ = db.Close() })
|
|
36
|
+
|
|
37
|
+
if _, err := db.ExecContext(context.Background(), `
|
|
38
|
+
truncate table
|
|
39
|
+
webhook_events,
|
|
40
|
+
attachments,
|
|
41
|
+
messages,
|
|
42
|
+
conversation_participants,
|
|
43
|
+
conversations,
|
|
44
|
+
users
|
|
45
|
+
restart identity cascade`); err != nil {
|
|
46
|
+
t.Fatalf("truncate tables: %v", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
storage := newFakeStorage()
|
|
50
|
+
service := app.NewChatService(db, storage, app.GenericWebhookAdapter{})
|
|
51
|
+
router := chi.NewRouter()
|
|
52
|
+
RegisterRoutes(router, service)
|
|
53
|
+
server := httptest.NewServer(router)
|
|
54
|
+
t.Cleanup(server.Close)
|
|
55
|
+
|
|
56
|
+
userID := createUser(t, server.URL)
|
|
57
|
+
conversationID := createConversation(t, server.URL, userID)
|
|
58
|
+
|
|
59
|
+
messageIDs := make([]string, 0, 55)
|
|
60
|
+
for index := 1; index <= 55; index++ {
|
|
61
|
+
messageIDs = append(messageIDs, createMessage(t, server.URL, conversationID, userID, fmt.Sprintf("message-%d", index)))
|
|
62
|
+
}
|
|
63
|
+
rewriteMessageTimestamps(t, db, messageIDs)
|
|
64
|
+
|
|
65
|
+
upload := createAttachmentUpload(t, server.URL, conversationID, userID)
|
|
66
|
+
storage.set(upload.Result.Attachment.PublicURL, "image/png", 1234)
|
|
67
|
+
finalizeAttachment(t, server.URL, upload.Result.Attachment.ID, messageIDs[54])
|
|
68
|
+
|
|
69
|
+
firstPage := listMessagesPage(t, server.URL, conversationID, "")
|
|
70
|
+
if len(firstPage.Messages) != 50 {
|
|
71
|
+
t.Fatalf("expected 50 messages, got %d", len(firstPage.Messages))
|
|
72
|
+
}
|
|
73
|
+
if firstPage.Messages[0].Body != "message-55" {
|
|
74
|
+
t.Fatalf("expected newest message first, got %s", firstPage.Messages[0].Body)
|
|
75
|
+
}
|
|
76
|
+
if firstPage.Messages[49].Body != "message-6" {
|
|
77
|
+
t.Fatalf("expected oldest message on first page to be message-6, got %s", firstPage.Messages[49].Body)
|
|
78
|
+
}
|
|
79
|
+
if firstPage.NextCursor == "" {
|
|
80
|
+
t.Fatal("expected next_cursor on first page")
|
|
81
|
+
}
|
|
82
|
+
if len(firstPage.Messages[0].Attachments) != 1 {
|
|
83
|
+
t.Fatalf("expected attachment metadata on newest message, got %#v", firstPage.Messages[0].Attachments)
|
|
84
|
+
}
|
|
85
|
+
if firstPage.Messages[0].Attachments[0].PublicURL != upload.Result.Attachment.PublicURL {
|
|
86
|
+
t.Fatalf("expected public_url %s, got %s", upload.Result.Attachment.PublicURL, firstPage.Messages[0].Attachments[0].PublicURL)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
secondPage := listMessagesPage(t, server.URL, conversationID, firstPage.NextCursor)
|
|
90
|
+
expectedBodies := []string{"message-5", "message-4", "message-3", "message-2", "message-1"}
|
|
91
|
+
if len(secondPage.Messages) != len(expectedBodies) {
|
|
92
|
+
t.Fatalf("expected %d messages on second page, got %d", len(expectedBodies), len(secondPage.Messages))
|
|
93
|
+
}
|
|
94
|
+
for index, body := range expectedBodies {
|
|
95
|
+
if secondPage.Messages[index].Body != body {
|
|
96
|
+
t.Fatalf("expected %s at index %d, got %s", body, index, secondPage.Messages[index].Body)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if secondPage.NextCursor != "" {
|
|
100
|
+
t.Fatalf("expected no next cursor on final page, got %s", secondPage.NextCursor)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type pageResponse struct {
|
|
105
|
+
Messages []struct {
|
|
106
|
+
ID string `json:"id"`
|
|
107
|
+
Body string `json:"body"`
|
|
108
|
+
Attachments []struct {
|
|
109
|
+
ID string `json:"id"`
|
|
110
|
+
Filename string `json:"filename"`
|
|
111
|
+
ContentType string `json:"content_type"`
|
|
112
|
+
ByteSize int64 `json:"byte_size"`
|
|
113
|
+
Status string `json:"status"`
|
|
114
|
+
PublicURL string `json:"public_url"`
|
|
115
|
+
} `json:"attachments"`
|
|
116
|
+
} `json:"messages"`
|
|
117
|
+
NextCursor string `json:"next_cursor"`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type attachmentUploadResponse struct {
|
|
121
|
+
Result struct {
|
|
122
|
+
Attachment struct {
|
|
123
|
+
ID string `json:"id"`
|
|
124
|
+
PublicURL string `json:"public_url"`
|
|
125
|
+
} `json:"attachment"`
|
|
126
|
+
} `json:"result"`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type fakeStorage struct {
|
|
130
|
+
metadata map[string]struct {
|
|
131
|
+
contentType string
|
|
132
|
+
byteSize int64
|
|
133
|
+
publicURL string
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func newFakeStorage() *fakeStorage {
|
|
138
|
+
return &fakeStorage{metadata: map[string]struct {
|
|
139
|
+
contentType string
|
|
140
|
+
byteSize int64
|
|
141
|
+
publicURL string
|
|
142
|
+
}{}}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func (f *fakeStorage) CreateSignedUpload(_ context.Context, attachmentID string, conversationID string, filename string, contentType string) (string, string, app.UploadTarget, string, error) {
|
|
146
|
+
key := fmt.Sprintf("attachments/%s/%s/%s", conversationID, attachmentID, filename)
|
|
147
|
+
return "test-bucket", key, app.UploadTarget{
|
|
148
|
+
Method: http.MethodPut,
|
|
149
|
+
URL: "https://uploads.test/" + key,
|
|
150
|
+
Headers: map[string]string{
|
|
151
|
+
"Content-Type": contentType,
|
|
152
|
+
},
|
|
153
|
+
}, "https://storage.test/" + key, nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (f *fakeStorage) GetObjectMetadata(_ context.Context, bucket string, key string) (string, int64, string, error) {
|
|
157
|
+
entry, ok := f.metadata[bucket+"/"+key]
|
|
158
|
+
if !ok {
|
|
159
|
+
return "", 0, "", fmt.Errorf("missing metadata for %s/%s", bucket, key)
|
|
160
|
+
}
|
|
161
|
+
return entry.contentType, entry.byteSize, entry.publicURL, nil
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func (f *fakeStorage) set(publicURL string, contentType string, byteSize int64) {
|
|
165
|
+
key := strings.TrimPrefix(publicURL, "https://storage.test/")
|
|
166
|
+
f.metadata["test-bucket/"+key] = struct {
|
|
167
|
+
contentType string
|
|
168
|
+
byteSize int64
|
|
169
|
+
publicURL string
|
|
170
|
+
}{
|
|
171
|
+
contentType: contentType,
|
|
172
|
+
byteSize: byteSize,
|
|
173
|
+
publicURL: publicURL,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func createUser(t *testing.T, baseURL string) string {
|
|
178
|
+
var response struct {
|
|
179
|
+
User struct {
|
|
180
|
+
ID string `json:"id"`
|
|
181
|
+
} `json:"user"`
|
|
182
|
+
}
|
|
183
|
+
requestJSON(t, http.MethodPost, baseURL+"/v1/users", map[string]any{
|
|
184
|
+
"username": "alice",
|
|
185
|
+
"display_name": "Alice",
|
|
186
|
+
}, &response)
|
|
187
|
+
return response.User.ID
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func createConversation(t *testing.T, baseURL string, userID string) string {
|
|
191
|
+
var response struct {
|
|
192
|
+
Conversation struct {
|
|
193
|
+
ID string `json:"id"`
|
|
194
|
+
} `json:"conversation"`
|
|
195
|
+
}
|
|
196
|
+
requestJSON(t, http.MethodPost, baseURL+"/v1/conversations", map[string]any{
|
|
197
|
+
"created_by_user_id": userID,
|
|
198
|
+
"title": "General",
|
|
199
|
+
"participant_user_ids": []string{userID},
|
|
200
|
+
}, &response)
|
|
201
|
+
return response.Conversation.ID
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func createMessage(t *testing.T, baseURL string, conversationID string, userID string, body string) string {
|
|
205
|
+
var response struct {
|
|
206
|
+
Message struct {
|
|
207
|
+
ID string `json:"id"`
|
|
208
|
+
} `json:"message"`
|
|
209
|
+
}
|
|
210
|
+
requestJSON(t, http.MethodPost, baseURL+"/v1/conversations/"+conversationID+"/messages", map[string]any{
|
|
211
|
+
"user_id": userID,
|
|
212
|
+
"body": body,
|
|
213
|
+
}, &response)
|
|
214
|
+
return response.Message.ID
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
func createAttachmentUpload(t *testing.T, baseURL string, conversationID string, userID string) attachmentUploadResponse {
|
|
218
|
+
var response attachmentUploadResponse
|
|
219
|
+
requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/uploads", map[string]any{
|
|
220
|
+
"conversation_id": conversationID,
|
|
221
|
+
"user_id": userID,
|
|
222
|
+
"filename": "photo.png",
|
|
223
|
+
"content_type": "image/png",
|
|
224
|
+
"byte_size": 1234,
|
|
225
|
+
}, &response)
|
|
226
|
+
return response
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func finalizeAttachment(t *testing.T, baseURL string, attachmentID string, messageID string) {
|
|
230
|
+
requestJSON(t, http.MethodPost, baseURL+"/v1/attachments/"+attachmentID+"/finalize", map[string]any{
|
|
231
|
+
"message_id": messageID,
|
|
232
|
+
}, &struct{}{})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func listMessagesPage(t *testing.T, baseURL string, conversationID string, cursor string) pageResponse {
|
|
236
|
+
requestURL := baseURL + "/v1/conversations/" + conversationID + "/messages"
|
|
237
|
+
if cursor != "" {
|
|
238
|
+
requestURL += "?cursor=" + url.QueryEscape(cursor)
|
|
239
|
+
}
|
|
240
|
+
var response pageResponse
|
|
241
|
+
requestJSON(t, http.MethodGet, requestURL, nil, &response)
|
|
242
|
+
return response
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func requestJSON(t *testing.T, method string, url string, payload any, out any) {
|
|
246
|
+
t.Helper()
|
|
247
|
+
|
|
248
|
+
var bodyReader *bytes.Reader
|
|
249
|
+
if payload == nil {
|
|
250
|
+
bodyReader = bytes.NewReader(nil)
|
|
251
|
+
} else {
|
|
252
|
+
body, err := json.Marshal(payload)
|
|
253
|
+
if err != nil {
|
|
254
|
+
t.Fatalf("marshal request: %v", err)
|
|
255
|
+
}
|
|
256
|
+
bodyReader = bytes.NewReader(body)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
request, err := http.NewRequest(method, url, bodyReader)
|
|
260
|
+
if err != nil {
|
|
261
|
+
t.Fatalf("new request: %v", err)
|
|
262
|
+
}
|
|
263
|
+
if payload != nil {
|
|
264
|
+
request.Header.Set("Content-Type", "application/json")
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
response, err := http.DefaultClient.Do(request)
|
|
268
|
+
if err != nil {
|
|
269
|
+
t.Fatalf("do request: %v", err)
|
|
270
|
+
}
|
|
271
|
+
defer response.Body.Close()
|
|
272
|
+
|
|
273
|
+
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
274
|
+
var problem map[string]any
|
|
275
|
+
_ = json.NewDecoder(response.Body).Decode(&problem)
|
|
276
|
+
t.Fatalf("unexpected status %d: %#v", response.StatusCode, problem)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if out != nil {
|
|
280
|
+
if err := json.NewDecoder(response.Body).Decode(out); err != nil {
|
|
281
|
+
t.Fatalf("decode response: %v", err)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func rewriteMessageTimestamps(t *testing.T, db *sqlx.DB, messageIDs []string) {
|
|
287
|
+
t.Helper()
|
|
288
|
+
baseTime := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
|
289
|
+
for index, messageID := range messageIDs {
|
|
290
|
+
createdAt := baseTime.Add(time.Duration(index+1) * time.Second)
|
|
291
|
+
if _, err := db.ExecContext(context.Background(), `
|
|
292
|
+
update messages
|
|
293
|
+
set created_at = $2, updated_at = $2
|
|
294
|
+
where id = $1`, messageID, createdAt); err != nil {
|
|
295
|
+
t.Fatalf("rewrite message timestamp: %v", err)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -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,259 @@ 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.ChatService) {
|
|
16
17
|
router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
17
18
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
18
19
|
})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
writeJSON(w, http.StatusOK, map[string]any{"records": records})
|
|
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": "chat",
|
|
27
|
+
"apiOrigin": "https://api.{{SERVICE_NAME}}.anmho.com",
|
|
28
28
|
})
|
|
29
|
+
})
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
router.Post("/v1/users", func(w http.ResponseWriter, request *http.Request) {
|
|
32
|
+
var input app.CreateUserInput
|
|
33
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
34
|
+
writeError(w, err)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
user, err := service.CreateUser(request.Context(), input)
|
|
38
|
+
if err != nil {
|
|
39
|
+
writeError(w, err)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
writeJSON(w, http.StatusCreated, map[string]any{"user": user})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
router.Get("/v1/users/{userID}", func(w http.ResponseWriter, request *http.Request) {
|
|
46
|
+
user, err := service.GetUser(request.Context(), chi.URLParam(request, "userID"))
|
|
47
|
+
if err != nil {
|
|
48
|
+
writeError(w, err)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
router.Get("/v1/users", func(w http.ResponseWriter, request *http.Request) {
|
|
55
|
+
user, err := service.GetUserByUsername(request.Context(), request.URL.Query().Get("username"))
|
|
56
|
+
if err != nil {
|
|
57
|
+
writeError(w, err)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
router.Post("/v1/conversations", func(w http.ResponseWriter, request *http.Request) {
|
|
64
|
+
var input app.CreateConversationInput
|
|
65
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
66
|
+
writeError(w, err)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
conversation, err := service.CreateConversation(request.Context(), input)
|
|
70
|
+
if err != nil {
|
|
71
|
+
writeError(w, err)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
router.Get("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
78
|
+
conversation, err := service.GetConversation(request.Context(), chi.URLParam(request, "conversationID"))
|
|
79
|
+
if err != nil {
|
|
80
|
+
writeError(w, err)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
router.Patch("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
87
|
+
var input app.UpdateConversationInput
|
|
88
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
89
|
+
writeError(w, err)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
conversation, err := service.UpdateConversation(request.Context(), chi.URLParam(request, "conversationID"), input)
|
|
93
|
+
if err != nil {
|
|
94
|
+
writeError(w, err)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
router.Delete("/v1/conversations/{conversationID}", func(w http.ResponseWriter, request *http.Request) {
|
|
101
|
+
if err := service.DeleteConversation(request.Context(), chi.URLParam(request, "conversationID")); err != nil {
|
|
102
|
+
writeError(w, err)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
w.WriteHeader(http.StatusNoContent)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
router.Post("/v1/conversations/{conversationID}/participants", func(w http.ResponseWriter, request *http.Request) {
|
|
109
|
+
var input struct {
|
|
110
|
+
UserID string `json:"user_id"`
|
|
111
|
+
}
|
|
112
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
113
|
+
writeError(w, err)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
conversation, err := service.AddParticipant(request.Context(), chi.URLParam(request, "conversationID"), input.UserID)
|
|
117
|
+
if err != nil {
|
|
118
|
+
writeError(w, err)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
|
|
122
|
+
})
|
|
36
123
|
|
|
37
|
-
|
|
124
|
+
router.Delete("/v1/conversations/{conversationID}/participants/{userID}", func(w http.ResponseWriter, request *http.Request) {
|
|
125
|
+
if err := service.RemoveParticipant(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "userID")); err != nil {
|
|
126
|
+
writeError(w, err)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
w.WriteHeader(http.StatusNoContent)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
router.Get("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
|
|
133
|
+
limit := 0
|
|
134
|
+
if rawLimit := strings.TrimSpace(request.URL.Query().Get("limit")); rawLimit != "" {
|
|
135
|
+
parsedLimit, err := strconv.Atoi(rawLimit)
|
|
38
136
|
if err != nil {
|
|
39
|
-
writeError(w,
|
|
137
|
+
writeError(w, &app.AppError{Status: http.StatusBadRequest, Code: "invalid_limit", Err: errors.New("limit must be a positive integer")})
|
|
40
138
|
return
|
|
41
139
|
}
|
|
42
|
-
|
|
140
|
+
limit = parsedLimit
|
|
141
|
+
}
|
|
142
|
+
result, err := service.ListMessages(request.Context(), chi.URLParam(request, "conversationID"), app.ListMessagesInput{
|
|
143
|
+
Cursor: strings.TrimSpace(request.URL.Query().Get("cursor")),
|
|
144
|
+
Limit: limit,
|
|
43
145
|
})
|
|
146
|
+
if err != nil {
|
|
147
|
+
writeError(w, err)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
writeJSON(w, http.StatusOK, result)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
router.Post("/v1/conversations/{conversationID}/messages", func(w http.ResponseWriter, request *http.Request) {
|
|
154
|
+
var input app.CreateMessageInput
|
|
155
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
156
|
+
writeError(w, err)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
message, err := service.CreateMessage(request.Context(), chi.URLParam(request, "conversationID"), input)
|
|
160
|
+
if err != nil {
|
|
161
|
+
writeError(w, err)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
writeJSON(w, http.StatusCreated, map[string]any{"message": message})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
router.Patch("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
|
|
168
|
+
var input app.UpdateMessageInput
|
|
169
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
170
|
+
writeError(w, err)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
message, err := service.UpdateMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID"), input)
|
|
174
|
+
if err != nil {
|
|
175
|
+
writeError(w, err)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
writeJSON(w, http.StatusOK, map[string]any{"message": message})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
router.Delete("/v1/conversations/{conversationID}/messages/{messageID}", func(w http.ResponseWriter, request *http.Request) {
|
|
182
|
+
if err := service.DeleteMessage(request.Context(), chi.URLParam(request, "conversationID"), chi.URLParam(request, "messageID")); err != nil {
|
|
183
|
+
writeError(w, err)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
w.WriteHeader(http.StatusNoContent)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
router.Post("/v1/attachments/uploads", func(w http.ResponseWriter, request *http.Request) {
|
|
190
|
+
var input app.CreateAttachmentUploadInput
|
|
191
|
+
if err := decodeJSON(request, &input); err != nil {
|
|
192
|
+
writeError(w, err)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
result, err := service.CreateAttachmentUpload(request.Context(), input)
|
|
196
|
+
if err != nil {
|
|
197
|
+
writeError(w, err)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
writeJSON(w, http.StatusCreated, map[string]any{"result": result})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
router.Post("/v1/attachments/{attachmentID}/finalize", func(w http.ResponseWriter, request *http.Request) {
|
|
204
|
+
var input app.FinalizeAttachmentInput
|
|
205
|
+
if err := decodeOptionalJSON(request, &input); err != nil {
|
|
206
|
+
writeError(w, err)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
attachment, err := service.FinalizeAttachment(request.Context(), chi.URLParam(request, "attachmentID"), input)
|
|
210
|
+
if err != nil {
|
|
211
|
+
writeError(w, err)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
router.Get("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
|
|
218
|
+
attachment, err := service.GetAttachment(request.Context(), chi.URLParam(request, "attachmentID"))
|
|
219
|
+
if err != nil {
|
|
220
|
+
writeError(w, err)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
writeJSON(w, http.StatusOK, map[string]any{"attachment": attachment})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
router.Delete("/v1/attachments/{attachmentID}", func(w http.ResponseWriter, request *http.Request) {
|
|
227
|
+
if err := service.DeleteAttachment(request.Context(), chi.URLParam(request, "attachmentID")); err != nil {
|
|
228
|
+
writeError(w, err)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
w.WriteHeader(http.StatusNoContent)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
router.Post("/webhooks/{provider}", func(w http.ResponseWriter, request *http.Request) {
|
|
235
|
+
rawBody, err := io.ReadAll(request.Body)
|
|
236
|
+
if err != nil {
|
|
237
|
+
writeError(w, err)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
event, duplicate, err := service.ProcessWebhook(request.Context(), chi.URLParam(request, "provider"), request.Header, rawBody)
|
|
241
|
+
if err != nil {
|
|
242
|
+
writeError(w, err)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
status := http.StatusAccepted
|
|
246
|
+
if duplicate {
|
|
247
|
+
status = http.StatusOK
|
|
248
|
+
}
|
|
249
|
+
writeJSON(w, status, map[string]any{"event": event, "duplicate": duplicate})
|
|
250
|
+
})
|
|
44
251
|
|
|
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
|
-
})
|
|
252
|
+
router.Get("/webhooks/{provider}/health", func(w http.ResponseWriter, request *http.Request) {
|
|
253
|
+
writeJSON(w, http.StatusOK, map[string]string{
|
|
254
|
+
"status": "ok",
|
|
255
|
+
"provider": chi.URLParam(request, "provider"),
|
|
68
256
|
})
|
|
69
257
|
})
|
|
70
258
|
}
|
|
71
259
|
|
|
72
260
|
func decodeJSON(request *http.Request, out any) error {
|
|
73
261
|
defer request.Body.Close()
|
|
262
|
+
return json.NewDecoder(request.Body).Decode(out)
|
|
263
|
+
}
|
|
74
264
|
|
|
75
|
-
|
|
265
|
+
func decodeOptionalJSON(request *http.Request, out any) error {
|
|
266
|
+
defer request.Body.Close()
|
|
267
|
+
decoder := json.NewDecoder(request.Body)
|
|
268
|
+
if err := decoder.Decode(out); err != nil && !errors.Is(err, io.EOF) {
|
|
76
269
|
return err
|
|
77
270
|
}
|
|
78
271
|
return nil
|
|
@@ -85,6 +278,15 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
|
85
278
|
}
|
|
86
279
|
|
|
87
280
|
func writeError(w http.ResponseWriter, err error) {
|
|
281
|
+
var appErr *app.AppError
|
|
282
|
+
if errors.As(err, &appErr) {
|
|
283
|
+
writeJSON(w, appErr.Status, map[string]string{
|
|
284
|
+
"error": appErr.Error(),
|
|
285
|
+
"code": appErr.Code,
|
|
286
|
+
})
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
88
290
|
status := http.StatusInternalServerError
|
|
89
291
|
if errors.Is(err, strconv.ErrSyntax) || strings.Contains(strings.ToLower(err.Error()), "json") {
|
|
90
292
|
status = http.StatusBadRequest
|