@donkeylabs/server 2.0.37 → 2.2.0
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/docs/cache.md +27 -34
- package/docs/code-organization.md +424 -0
- package/docs/project-structure.md +37 -26
- package/docs/rate-limiter.md +23 -28
- package/docs/swift-adapter.md +293 -0
- package/docs/versioning.md +351 -0
- package/package.json +6 -2
- package/src/client/base.ts +18 -5
- package/src/core/cache-adapter-redis.ts +113 -0
- package/src/core/events.ts +54 -7
- package/src/core/health.ts +165 -0
- package/src/core/index.ts +22 -0
- package/src/core/jobs.ts +11 -4
- package/src/core/rate-limit-adapter-redis.ts +109 -0
- package/src/core/subprocess-bootstrap.ts +3 -0
- package/src/core/workflow-executor.ts +1 -0
- package/src/core/workflow-state-machine.ts +6 -5
- package/src/core/workflows.ts +3 -2
- package/src/core.ts +9 -1
- package/src/generator/index.ts +4 -0
- package/src/harness.ts +17 -5
- package/src/index.ts +21 -0
- package/src/router.ts +22 -2
- package/src/server.ts +458 -117
- package/src/versioning.ts +154 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Swift Adapter
|
|
2
|
+
|
|
3
|
+
`@donkeylabs/adapter-swift` generates a typed Swift Package (SPM) from @donkeylabs/server routes. The generated package includes Codable models, async/await networking, and SSE support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Typed Models** - Zod schemas become Codable Swift structs and enums
|
|
8
|
+
- **Async/Await** - URLSession-based networking with Swift concurrency
|
|
9
|
+
- **All Handler Types** - typed, raw, stream, SSE, formData, and html
|
|
10
|
+
- **SSE Support** - Real-time streaming with typed events and auto-reconnect
|
|
11
|
+
- **API Versioning** - Optional `X-API-Version` header support
|
|
12
|
+
- **SPM Ready** - Generates a complete Swift Package with Package.swift
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add @donkeylabs/adapter-swift
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Generation
|
|
25
|
+
|
|
26
|
+
### CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
donkeylabs generate --adapter swift --output ./swift-client
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Configuration
|
|
33
|
+
|
|
34
|
+
In `donkeylabs.config.ts`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
export default {
|
|
38
|
+
plugins: ["./src/plugins/*"],
|
|
39
|
+
routes: "./src/routes",
|
|
40
|
+
swift: {
|
|
41
|
+
packageName: "MyApi", // SPM package name (default: "ApiClient")
|
|
42
|
+
platforms: { // Minimum platform versions
|
|
43
|
+
iOS: "15.0",
|
|
44
|
+
macOS: "12.0",
|
|
45
|
+
},
|
|
46
|
+
apiVersion: "2.0", // Default X-API-Version header
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Programmatic
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { generateClient } from "@donkeylabs/adapter-swift";
|
|
55
|
+
|
|
56
|
+
await generateClient(config, routes, "./output");
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Generated Structure
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
MyApi/
|
|
65
|
+
├── Package.swift
|
|
66
|
+
└── Sources/
|
|
67
|
+
└── MyApi/
|
|
68
|
+
├── ApiClient.swift # Main client class
|
|
69
|
+
├── ApiClient+Routes.swift # Route method extensions
|
|
70
|
+
├── Routes.swift # Route name constants
|
|
71
|
+
├── ApiClientBase.swift # URLSession networking runtime
|
|
72
|
+
├── ApiError.swift # Error types
|
|
73
|
+
├── SSEConnection.swift # SSE streaming runtime
|
|
74
|
+
├── AnyCodable.swift # Dynamic JSON wrapper
|
|
75
|
+
└── Models/
|
|
76
|
+
├── UsersModels.swift # Per-namespace model files
|
|
77
|
+
└── OrdersModels.swift
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Usage in Swift
|
|
83
|
+
|
|
84
|
+
### Setup
|
|
85
|
+
|
|
86
|
+
```swift
|
|
87
|
+
import MyApi
|
|
88
|
+
|
|
89
|
+
let client = ApiClient(
|
|
90
|
+
baseURL: URL(string: "https://api.example.com")!,
|
|
91
|
+
apiVersion: "2.0"
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Typed Routes
|
|
96
|
+
|
|
97
|
+
```swift
|
|
98
|
+
// Input/output are fully typed Codable structs
|
|
99
|
+
let result = try await client.create(CreateInput(name: "Alice", email: "alice@example.com"))
|
|
100
|
+
print(result.id) // String
|
|
101
|
+
|
|
102
|
+
let users = try await client.list()
|
|
103
|
+
for user in users {
|
|
104
|
+
print(user.name)
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Raw Routes
|
|
109
|
+
|
|
110
|
+
```swift
|
|
111
|
+
let (data, response) = try await client.upload(
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: fileData,
|
|
114
|
+
headers: ["Content-Type": "application/octet-stream"]
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### SSE Routes
|
|
119
|
+
|
|
120
|
+
```swift
|
|
121
|
+
let connection = client.stream()
|
|
122
|
+
|
|
123
|
+
// Typed event handling
|
|
124
|
+
connection.on("notification") { (event: NotificationEvent) in
|
|
125
|
+
print(event.message)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Start listening
|
|
129
|
+
Task {
|
|
130
|
+
await connection.connect()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Later: disconnect
|
|
134
|
+
connection.close()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Stream Routes
|
|
138
|
+
|
|
139
|
+
```swift
|
|
140
|
+
let (bytes, response) = try await client.download()
|
|
141
|
+
for try await chunk in bytes {
|
|
142
|
+
// Process streaming data
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### FormData Routes
|
|
147
|
+
|
|
148
|
+
```swift
|
|
149
|
+
let result = try await client.upload(
|
|
150
|
+
fields: UploadInput(title: "Photo"),
|
|
151
|
+
files: [
|
|
152
|
+
FormFile(name: "file", filename: "photo.jpg", data: imageData, mimeType: "image/jpeg")
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### HTML Routes
|
|
158
|
+
|
|
159
|
+
```swift
|
|
160
|
+
let html: String = try await client.home()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Type Mapping
|
|
166
|
+
|
|
167
|
+
Zod schemas are converted to Swift types:
|
|
168
|
+
|
|
169
|
+
| Zod | Swift |
|
|
170
|
+
|-----|-------|
|
|
171
|
+
| `z.string()` | `String` |
|
|
172
|
+
| `z.number()` | `Double` |
|
|
173
|
+
| `z.boolean()` | `Bool` |
|
|
174
|
+
| `z.date()` | `Date` |
|
|
175
|
+
| `z.bigint()` | `Int64` |
|
|
176
|
+
| `z.any()` / `z.unknown()` | `AnyCodable` |
|
|
177
|
+
| `z.object({...})` | `struct: Codable, Sendable` |
|
|
178
|
+
| `z.array(T)` | `[SwiftType]` |
|
|
179
|
+
| `z.optional()` / `z.nullable()` | `SwiftType?` |
|
|
180
|
+
| `z.enum(["a", "b"])` | `enum: String, Codable, Sendable` |
|
|
181
|
+
| `z.union([A, B])` | `enum` with associated values |
|
|
182
|
+
| `z.record(K, V)` | `[String: SwiftType]` |
|
|
183
|
+
| `z.tuple([A, B])` | `struct` with `_0`, `_1` fields |
|
|
184
|
+
| `z.literal("x")` | `String` / `Int` / `Bool` |
|
|
185
|
+
|
|
186
|
+
### Nested Types
|
|
187
|
+
|
|
188
|
+
Objects generate named structs. Nested objects use the parent type name as prefix:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
z.object({ address: z.object({ street: z.string() }) })
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Generates:
|
|
195
|
+
```swift
|
|
196
|
+
public struct CreateInput: Codable, Sendable {
|
|
197
|
+
public let address: CreateInputAddress
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public struct CreateInputAddress: Codable, Sendable {
|
|
201
|
+
public let street: String
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### CodingKeys
|
|
206
|
+
|
|
207
|
+
When JSON property names differ from valid Swift identifiers, CodingKeys are generated automatically:
|
|
208
|
+
|
|
209
|
+
```swift
|
|
210
|
+
public struct Item: Codable, Sendable {
|
|
211
|
+
public let createdAt: String
|
|
212
|
+
|
|
213
|
+
enum CodingKeys: String, CodingKey {
|
|
214
|
+
case createdAt = "created_at"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Error Handling
|
|
222
|
+
|
|
223
|
+
The generated client uses `ApiError` for all error cases:
|
|
224
|
+
|
|
225
|
+
```swift
|
|
226
|
+
do {
|
|
227
|
+
let user = try await client.get(GetInput(id: "123"))
|
|
228
|
+
} catch let error as ApiError {
|
|
229
|
+
switch error {
|
|
230
|
+
case .server(let status, let code, let message, let details):
|
|
231
|
+
print("Server error \(status): \(code) - \(message)")
|
|
232
|
+
case .validation(let issues):
|
|
233
|
+
for issue in issues {
|
|
234
|
+
print("Validation: \(issue)")
|
|
235
|
+
}
|
|
236
|
+
case .invalidResponse:
|
|
237
|
+
print("Could not decode response")
|
|
238
|
+
case .networkError(let underlying):
|
|
239
|
+
print("Network: \(underlying.localizedDescription)")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## API Versioning
|
|
247
|
+
|
|
248
|
+
Set a default API version in the client:
|
|
249
|
+
|
|
250
|
+
```swift
|
|
251
|
+
// All requests include X-API-Version: 2.0
|
|
252
|
+
let client = ApiClient(
|
|
253
|
+
baseURL: URL(string: "https://api.example.com")!,
|
|
254
|
+
apiVersion: "2.0"
|
|
255
|
+
)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The version is sent as the `X-API-Version` header on every request. See [Versioning](versioning.md) for server-side configuration.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Route Constants
|
|
263
|
+
|
|
264
|
+
All route names are available as static constants:
|
|
265
|
+
|
|
266
|
+
```swift
|
|
267
|
+
// Generated Routes namespace
|
|
268
|
+
Routes.Users.list // "users.list"
|
|
269
|
+
Routes.Users.create // "users.create"
|
|
270
|
+
Routes.Auth.login // "auth.login"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Adding to an Xcode Project
|
|
276
|
+
|
|
277
|
+
1. Generate the Swift package:
|
|
278
|
+
```bash
|
|
279
|
+
donkeylabs generate --adapter swift --output ./ios/ApiClient
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
2. In Xcode: File > Add Package Dependencies > Add Local > select the generated folder
|
|
283
|
+
|
|
284
|
+
3. Import and use:
|
|
285
|
+
```swift
|
|
286
|
+
import MyApi
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Minimum Requirements
|
|
290
|
+
|
|
291
|
+
- iOS 15.0+ / macOS 12.0+
|
|
292
|
+
- Swift 5.9+
|
|
293
|
+
- Xcode 15+
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# API Versioning
|
|
2
|
+
|
|
3
|
+
Router-level API versioning with semver support. Routes declare a version, the server resolves the best match via the `X-API-Version` header, and deprecated versions send standard sunset headers.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createRouter, AppServer } from "@donkeylabs/server";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// Version 1
|
|
12
|
+
const usersV1 = createRouter("users", { version: "1.0.0" });
|
|
13
|
+
usersV1.route("list").typed({
|
|
14
|
+
output: z.object({ users: z.array(z.object({ id: z.string(), name: z.string() })) }),
|
|
15
|
+
handle: async (_input, ctx) => {
|
|
16
|
+
const users = await ctx.db.selectFrom("users").select(["id", "name"]).execute();
|
|
17
|
+
return { users };
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Version 2 - adds email field
|
|
22
|
+
const usersV2 = createRouter("users", { version: "2.0.0" });
|
|
23
|
+
usersV2.route("list").typed({
|
|
24
|
+
output: z.object({ users: z.array(z.object({ id: z.string(), name: z.string(), email: z.string() })) }),
|
|
25
|
+
handle: async (_input, ctx) => {
|
|
26
|
+
const users = await ctx.db.selectFrom("users").select(["id", "name", "email"]).execute();
|
|
27
|
+
return { users };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const server = new AppServer({ db, versioning: { defaultBehavior: "latest" } });
|
|
32
|
+
server.use(usersV1);
|
|
33
|
+
server.use(usersV2);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Client requests:**
|
|
37
|
+
```sh
|
|
38
|
+
# Get latest version (v2)
|
|
39
|
+
curl -X POST http://localhost:3000/users.list
|
|
40
|
+
|
|
41
|
+
# Request specific version
|
|
42
|
+
curl -X POST http://localhost:3000/users.list -H "X-API-Version: 1"
|
|
43
|
+
|
|
44
|
+
# Request minor version
|
|
45
|
+
curl -X POST http://localhost:3000/users.list -H "X-API-Version: 2.0"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Server Configuration
|
|
51
|
+
|
|
52
|
+
Configure versioning behavior in `AppServer`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const server = new AppServer({
|
|
56
|
+
db,
|
|
57
|
+
versioning: {
|
|
58
|
+
// What to do when no X-API-Version header is sent
|
|
59
|
+
defaultBehavior: "latest", // "latest" | "unversioned" | "error"
|
|
60
|
+
|
|
61
|
+
// Echo the resolved version back in the response header
|
|
62
|
+
echoVersion: true,
|
|
63
|
+
|
|
64
|
+
// Custom header name (default: "X-API-Version")
|
|
65
|
+
headerName: "X-API-Version",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Default Behavior Options
|
|
71
|
+
|
|
72
|
+
| Value | Behavior |
|
|
73
|
+
|-------|----------|
|
|
74
|
+
| `"latest"` | No version header = use highest registered version (default) |
|
|
75
|
+
| `"unversioned"` | No version header = only match routes without a version |
|
|
76
|
+
| `"error"` | No version header = return `VERSION_REQUIRED` error |
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Router Options
|
|
81
|
+
|
|
82
|
+
### Setting a Version
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const router = createRouter("users", { version: "2.1.0" });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Version strings follow semver format: `major.minor.patch`. All three components are optional:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
createRouter("users", { version: "2" }); // Interpreted as 2.0.0
|
|
92
|
+
createRouter("users", { version: "2.1" }); // Interpreted as 2.1.0
|
|
93
|
+
createRouter("users", { version: "2.1.3" }); // Exact
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Deprecating a Version
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const usersV1 = createRouter("users", {
|
|
100
|
+
version: "1.0.0",
|
|
101
|
+
deprecated: {
|
|
102
|
+
sunsetDate: "2025-06-01",
|
|
103
|
+
message: "Use v2 for expanded user fields",
|
|
104
|
+
successor: "2.0.0",
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
When a deprecated version is resolved, the response includes standard headers:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Sunset: 2025-06-01
|
|
113
|
+
Deprecation: true
|
|
114
|
+
X-Deprecation-Notice: Use v2 for expanded user fields. Upgrade to 2.0.0.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Child Router Inheritance
|
|
118
|
+
|
|
119
|
+
Child routers inherit the parent's version unless they override it:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const api = createRouter("api", { version: "2.0.0" });
|
|
123
|
+
|
|
124
|
+
// Inherits version 2.0.0
|
|
125
|
+
const users = api.router("users");
|
|
126
|
+
|
|
127
|
+
// Overrides with its own version
|
|
128
|
+
const legacy = api.router("legacy");
|
|
129
|
+
// (set version on the legacy router directly if needed)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Version Resolution
|
|
135
|
+
|
|
136
|
+
The `X-API-Version` header supports flexible matching:
|
|
137
|
+
|
|
138
|
+
| Header Value | Matches |
|
|
139
|
+
|-------------|---------|
|
|
140
|
+
| `"2"` | Highest 2.x.x version |
|
|
141
|
+
| `"2.1"` | Highest 2.1.x version |
|
|
142
|
+
| `"2.1.3"` | Exact match only |
|
|
143
|
+
| `"2.x"` | Highest 2.x.x (wildcard) |
|
|
144
|
+
| `"2.1.x"` | Highest 2.1.x (wildcard) |
|
|
145
|
+
| (none) | Depends on `defaultBehavior` config |
|
|
146
|
+
|
|
147
|
+
### Resolution Algorithm
|
|
148
|
+
|
|
149
|
+
1. Parse the requested version string
|
|
150
|
+
2. Filter registered versions that satisfy the request
|
|
151
|
+
3. Sort by semver (highest first)
|
|
152
|
+
4. Return the highest match, or `null` if none
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// Registered: 1.0.0, 2.0.0, 2.1.0, 3.0.0
|
|
156
|
+
// Request "2" → resolves to 2.1.0 (highest 2.x)
|
|
157
|
+
// Request "2.0" → resolves to 2.0.0 (exact minor)
|
|
158
|
+
// Request "3" → resolves to 3.0.0
|
|
159
|
+
// Request "4" → null (no match)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Backward Compatibility
|
|
165
|
+
|
|
166
|
+
Unversioned routers continue to work exactly as before:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// No version = always matches when no X-API-Version header is sent
|
|
170
|
+
const router = createRouter("health");
|
|
171
|
+
router.route("ping").typed({
|
|
172
|
+
handle: async () => ({ ok: true }),
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Unversioned routes are stored in a separate fast-path map and are never affected by version resolution. Existing applications require **zero changes** to adopt versioning.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Client Usage
|
|
181
|
+
|
|
182
|
+
### TypeScript Client
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { createApiClient } from "./client";
|
|
186
|
+
|
|
187
|
+
// Pin client to a specific API version
|
|
188
|
+
const api = createApiClient({
|
|
189
|
+
baseUrl: "http://localhost:3000",
|
|
190
|
+
apiVersion: "2",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// All requests include X-API-Version: 2
|
|
194
|
+
const users = await api.users.list();
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Test Harness
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const { api } = await createTestHarness(server);
|
|
201
|
+
|
|
202
|
+
// Call with a specific version
|
|
203
|
+
const result = await api.call("users.list", {}, { version: "1" });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### `callRoute` (Server-Side)
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// Call a specific version from within the server
|
|
210
|
+
const result = await server.callRoute("users.list", input, ip, { version: "2" });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Semver Utilities
|
|
216
|
+
|
|
217
|
+
The versioning module exports utilities for working with semver strings:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { parseSemVer, compareSemVer, resolveVersion } from "@donkeylabs/server";
|
|
221
|
+
|
|
222
|
+
// Parse a version string
|
|
223
|
+
const v = parseSemVer("2.1.3");
|
|
224
|
+
// { major: 2, minor: 1, patch: 3, raw: "2.1.3" }
|
|
225
|
+
|
|
226
|
+
// Compare two versions
|
|
227
|
+
compareSemVer(parseSemVer("1.0.0")!, parseSemVer("2.0.0")!); // -1
|
|
228
|
+
|
|
229
|
+
// Resolve best match from a list
|
|
230
|
+
const versions = ["1.0.0", "2.0.0", "2.1.0"].map(v => parseSemVer(v)!);
|
|
231
|
+
resolveVersion(versions, "2"); // Returns 2.1.0
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Exported Types
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
interface SemVer {
|
|
238
|
+
major: number;
|
|
239
|
+
minor: number;
|
|
240
|
+
patch: number;
|
|
241
|
+
raw: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface VersioningConfig {
|
|
245
|
+
defaultBehavior?: "latest" | "unversioned" | "error";
|
|
246
|
+
echoVersion?: boolean;
|
|
247
|
+
headerName?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
interface DeprecationInfo {
|
|
251
|
+
sunsetDate?: string;
|
|
252
|
+
message?: string;
|
|
253
|
+
successor?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface RouterOptions {
|
|
257
|
+
version?: string;
|
|
258
|
+
deprecated?: DeprecationInfo;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Code Generation
|
|
265
|
+
|
|
266
|
+
When running `donkeylabs generate`, versioned routes include version metadata in `RouteInfo`:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
{
|
|
270
|
+
name: "users.list",
|
|
271
|
+
handler: "typed",
|
|
272
|
+
version: "2.0.0",
|
|
273
|
+
deprecated: false,
|
|
274
|
+
// ...
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
This metadata is available to all adapters (TypeScript, SvelteKit, Swift) for client generation.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Real-World Example
|
|
283
|
+
|
|
284
|
+
### Multi-Version API with Deprecation
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
import { createRouter, AppServer } from "@donkeylabs/server";
|
|
288
|
+
import { z } from "zod";
|
|
289
|
+
|
|
290
|
+
// V1 - deprecated, sunset June 2025
|
|
291
|
+
const v1 = createRouter("orders", {
|
|
292
|
+
version: "1.0.0",
|
|
293
|
+
deprecated: {
|
|
294
|
+
sunsetDate: "2025-06-01",
|
|
295
|
+
message: "V1 returns flat totals. Use V2 for itemized pricing.",
|
|
296
|
+
successor: "2.0.0",
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
v1.route("get").typed({
|
|
301
|
+
input: z.object({ id: z.string() }),
|
|
302
|
+
output: z.object({ id: z.string(), total: z.number() }),
|
|
303
|
+
handle: async (input, ctx) => {
|
|
304
|
+
const order = await ctx.plugins.orders.get(input.id);
|
|
305
|
+
return { id: order.id, total: order.total };
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// V2 - current
|
|
310
|
+
const v2 = createRouter("orders", { version: "2.0.0" });
|
|
311
|
+
|
|
312
|
+
v2.route("get").typed({
|
|
313
|
+
input: z.object({ id: z.string() }),
|
|
314
|
+
output: z.object({
|
|
315
|
+
id: z.string(),
|
|
316
|
+
items: z.array(z.object({ name: z.string(), price: z.number(), qty: z.number() })),
|
|
317
|
+
subtotal: z.number(),
|
|
318
|
+
tax: z.number(),
|
|
319
|
+
total: z.number(),
|
|
320
|
+
}),
|
|
321
|
+
handle: async (input, ctx) => {
|
|
322
|
+
return ctx.plugins.orders.getDetailed(input.id);
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Server
|
|
327
|
+
const server = new AppServer({
|
|
328
|
+
db,
|
|
329
|
+
versioning: { defaultBehavior: "latest", echoVersion: true },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
server.use(v1);
|
|
333
|
+
server.use(v2);
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```sh
|
|
337
|
+
# V1 response includes deprecation headers
|
|
338
|
+
curl -X POST http://localhost:3000/orders.get \
|
|
339
|
+
-H "X-API-Version: 1" \
|
|
340
|
+
-d '{"id": "order-123"}'
|
|
341
|
+
# Response headers:
|
|
342
|
+
# X-API-Version: 1.0.0
|
|
343
|
+
# Sunset: 2025-06-01
|
|
344
|
+
# Deprecation: true
|
|
345
|
+
# X-Deprecation-Notice: V1 returns flat totals. Use V2 for itemized pricing. Upgrade to 2.0.0.
|
|
346
|
+
|
|
347
|
+
# V2 response (default)
|
|
348
|
+
curl -X POST http://localhost:3000/orders.get \
|
|
349
|
+
-d '{"id": "order-123"}'
|
|
350
|
+
# Response header: X-API-Version: 2.0.0
|
|
351
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -75,7 +75,8 @@
|
|
|
75
75
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
76
76
|
"@playwright/test": "^1.40.0",
|
|
77
77
|
"pg": "^8.0.0",
|
|
78
|
-
"mysql2": "^3.0.0"
|
|
78
|
+
"mysql2": "^3.0.0",
|
|
79
|
+
"ioredis": "^5.0.0"
|
|
79
80
|
},
|
|
80
81
|
"peerDependenciesMeta": {
|
|
81
82
|
"@aws-sdk/client-s3": {
|
|
@@ -92,6 +93,9 @@
|
|
|
92
93
|
},
|
|
93
94
|
"mysql2": {
|
|
94
95
|
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"ioredis": {
|
|
98
|
+
"optional": true
|
|
95
99
|
}
|
|
96
100
|
},
|
|
97
101
|
"dependencies": {
|