@topogram/cli 0.3.57 → 0.3.59
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/package.json +1 -1
- package/src/generator/runtime/environment.js +17 -6
- package/src/generator/surfaces/native/swiftui-app.js +3 -3
- package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +4 -4
- package/src/generator/surfaces/native/swiftui-templates/README.generated.md +4 -4
- package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +110 -145
- package/src/generator/surfaces/native/swiftui-templates/runtime/{TodoAPIClient.swift → TopogramAPIClient.swift} +32 -11
- package/src/generator/surfaces/native/swiftui-templates/runtime/{TodoSwiftUIApp.swift → TopogramSwiftUIApp.swift} +8 -8
- package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +3 -2
- package/src/project-config.js +61 -61
package/package.json
CHANGED
|
@@ -370,16 +370,26 @@ function componentScriptOptions() {
|
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
function runtimePortExpression(plan, runtimes, component, sharedEnvName) {
|
|
374
|
+
const runtimeEnvName = `${component.id.toUpperCase()}_PORT`;
|
|
375
|
+
const primaryRuntime = runtimes[0];
|
|
376
|
+
const fallback = primaryRuntime?.id === component.id
|
|
377
|
+
? `\${${sharedEnvName}:-${component.port}}`
|
|
378
|
+
: `${component.port}`;
|
|
379
|
+
return `\${${runtimeEnvName}:-${fallback}}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
373
382
|
function renderEnvironmentServerDevScript(plan, component = plan.runtimes.apis[0], options = {}) {
|
|
374
383
|
if (!component) {
|
|
375
384
|
return renderEnvAwareShellScript(['echo "No API runtimes are configured."']);
|
|
376
385
|
}
|
|
377
386
|
const guardPortsScript = options.componentScript ? '"$ROOT_DIR/scripts/guard-ports.mjs"' : '"$SCRIPT_DIR/guard-ports.mjs"';
|
|
387
|
+
const serverPortExpression = runtimePortExpression(plan, plan.runtimes.apis, component, "SERVER_PORT");
|
|
378
388
|
return renderEnvAwareShellScript([
|
|
379
389
|
`node ${guardPortsScript} api`,
|
|
380
390
|
"",
|
|
381
391
|
...apiDatabaseExportLines(component),
|
|
382
|
-
`export PORT="
|
|
392
|
+
`export PORT="${serverPortExpression}"`,
|
|
383
393
|
`export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost:\${WEB_PORT:-${plan.ports.web}},http://127.0.0.1:\${WEB_PORT:-${plan.ports.web}}}"`,
|
|
384
394
|
"",
|
|
385
395
|
`cd "$ROOT_DIR/${component.dir}"`,
|
|
@@ -395,15 +405,16 @@ function renderEnvironmentWebDevScript(plan, component = plan.runtimes.webs[0],
|
|
|
395
405
|
}
|
|
396
406
|
const apiRuntime = plan.runtimes.apis.find((entry) => entry.id === component.uses_api) || plan.runtimes.apis[0];
|
|
397
407
|
const guardPortsScript = options.componentScript ? '"$ROOT_DIR/scripts/guard-ports.mjs"' : '"$SCRIPT_DIR/guard-ports.mjs"';
|
|
408
|
+
const webPortExpression = runtimePortExpression(plan, plan.runtimes.webs, component, "WEB_PORT");
|
|
398
409
|
return renderEnvAwareShellScript([
|
|
399
410
|
`node ${guardPortsScript} web`,
|
|
400
411
|
"",
|
|
401
412
|
...(apiRuntime ? [`export PUBLIC_TOPOGRAM_API_BASE_URL="\${PUBLIC_TOPOGRAM_API_BASE_URL:-http://localhost:\${${apiRuntime.id.toUpperCase()}_PORT:-\${SERVER_PORT:-${apiRuntime.port}}}}"`] : []),
|
|
402
|
-
`export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost
|
|
413
|
+
`export TOPOGRAM_CORS_ORIGINS="\${TOPOGRAM_CORS_ORIGINS:-http://localhost:${webPortExpression},http://127.0.0.1:${webPortExpression}}"`,
|
|
403
414
|
"",
|
|
404
415
|
`cd "$ROOT_DIR/${component.dir}"`,
|
|
405
416
|
"npm install",
|
|
406
|
-
`npm run dev -- --host "\${WEB_HOST:-127.0.0.1}" --port "
|
|
417
|
+
`npm run dev -- --host "\${WEB_HOST:-127.0.0.1}" --port "${webPortExpression}"`,
|
|
407
418
|
], options.componentScript ? componentScriptOptions() : {});
|
|
408
419
|
}
|
|
409
420
|
|
|
@@ -450,8 +461,8 @@ ${startLines.length ? "wait" : ""}
|
|
|
450
461
|
|
|
451
462
|
function renderEnvironmentGuardPortsScript(plan) {
|
|
452
463
|
const ports = [
|
|
453
|
-
...plan.runtimes.apis.map((component) => ({ id: component.id, type: "api", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: "SERVER_PORT", port: component.port })),
|
|
454
|
-
...plan.runtimes.webs.map((component) => ({ id: component.id, type: "web", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: "WEB_PORT", port: component.port }))
|
|
464
|
+
...plan.runtimes.apis.map((component, index) => ({ id: component.id, type: "api", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: index === 0 ? "SERVER_PORT" : null, port: component.port })),
|
|
465
|
+
...plan.runtimes.webs.map((component, index) => ({ id: component.id, type: "web", env: `${component.id.toUpperCase()}_PORT`, fallbackEnv: index === 0 ? "WEB_PORT" : null, port: component.port }))
|
|
455
466
|
];
|
|
456
467
|
return `#!/usr/bin/env node
|
|
457
468
|
import net from "node:net";
|
|
@@ -461,7 +472,7 @@ const ports = ${JSON.stringify(ports, null, 2)};
|
|
|
461
472
|
const expectedService = ${JSON.stringify(plan.runtimeReference.serviceName || "")};
|
|
462
473
|
|
|
463
474
|
function effectivePort(entry) {
|
|
464
|
-
return Number(process.env[entry.env] || process.env[entry.fallbackEnv] || entry.port);
|
|
475
|
+
return Number(process.env[entry.env] || (entry.fallbackEnv ? process.env[entry.fallbackEnv] : "") || entry.port);
|
|
465
476
|
}
|
|
466
477
|
|
|
467
478
|
function portInUse(port) {
|
|
@@ -26,13 +26,13 @@ export function generateSwiftUiApp(graph, options = {}) {
|
|
|
26
26
|
const files = {};
|
|
27
27
|
|
|
28
28
|
for (const name of swiftFiles) {
|
|
29
|
-
files[`Sources/
|
|
29
|
+
files[`Sources/TopogramSwiftUIApp/${name}`] = fs.readFileSync(path.join(runtimeDir, name), "utf8");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
files["Package.swift"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "Package.swift.txt"), "utf8");
|
|
33
33
|
files["README.md"] = fs.readFileSync(path.join(__dirname, "swiftui-templates", "README.generated.md"), "utf8");
|
|
34
|
-
files["Sources/
|
|
35
|
-
files["Sources/
|
|
34
|
+
files["Sources/TopogramSwiftUIApp/Resources/ui-surface-contract.json"] = contractJson;
|
|
35
|
+
files["Sources/TopogramSwiftUIApp/Resources/api-contracts.json"] = apiJson;
|
|
36
36
|
|
|
37
37
|
return files;
|
|
38
38
|
}
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import PackageDescription
|
|
3
3
|
|
|
4
4
|
let package = Package(
|
|
5
|
-
name: "
|
|
5
|
+
name: "TopogramSwiftUIApp",
|
|
6
6
|
platforms: [.iOS(.v17), .macOS(.v14)],
|
|
7
7
|
products: [
|
|
8
|
-
.executable(name: "
|
|
8
|
+
.executable(name: "TopogramSwiftUIApp", targets: ["TopogramSwiftUIApp"])
|
|
9
9
|
],
|
|
10
10
|
targets: [
|
|
11
11
|
.executableTarget(
|
|
12
|
-
name: "
|
|
13
|
-
path: "Sources/
|
|
12
|
+
name: "TopogramSwiftUIApp",
|
|
13
|
+
path: "Sources/TopogramSwiftUIApp",
|
|
14
14
|
resources: [
|
|
15
15
|
.copy("Resources/api-contracts.json"),
|
|
16
16
|
.copy("Resources/ui-surface-contract.json")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Topogram SwiftUI (generated)
|
|
2
2
|
|
|
3
3
|
Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI contract as the web stacks. Prefer the **`proj_ios_surface__swiftui`** projection when present; otherwise the generator falls back to a **`proj_web_surface__*`** projection (often **`proj_web_surface__sveltekit`**).
|
|
4
4
|
|
|
@@ -9,12 +9,12 @@ Apple SwiftUI client generated from the same **`buildWebRealization`** routed UI
|
|
|
9
9
|
|
|
10
10
|
## Run
|
|
11
11
|
|
|
12
|
-
Open **`Package.swift`** in Xcode 15+ and run the **`
|
|
12
|
+
Open **`Package.swift`** in Xcode 15+ and run the **`TopogramSwiftUIApp`** scheme on an iOS Simulator.
|
|
13
13
|
|
|
14
|
-
Configure the API base URL and
|
|
14
|
+
Configure the API base URL and optional auth token via scheme environment variables (mirror web):
|
|
15
15
|
|
|
16
16
|
- `PUBLIC_TOPOGRAM_API_BASE_URL` (default `http://localhost:3000`)
|
|
17
|
-
- `
|
|
17
|
+
- `PUBLIC_TOPOGRAM_AUTH_TOKEN`
|
|
18
18
|
- Optional JWT / permission env vars matching web `visibility.ts` (`PUBLIC_TOPOGRAM_AUTH_*`).
|
|
19
19
|
|
|
20
20
|
## Regenerate
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
3
|
/// Holds decoded ui-surface-contract.json for runtime-driven navigation (parity with web bundle).
|
|
4
|
-
public final class
|
|
4
|
+
public final class TopogramUiContract: ObservableObject {
|
|
5
5
|
public let raw: [String: Any]
|
|
6
6
|
|
|
7
7
|
public init(data: Data) throws {
|
|
8
8
|
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
9
|
-
throw NSError(domain: "
|
|
9
|
+
throw NSError(domain: "TopogramUiContract", code: 1)
|
|
10
10
|
}
|
|
11
11
|
self.raw = obj
|
|
12
12
|
}
|
|
@@ -14,7 +14,7 @@ public final class TodoUiContract: ObservableObject {
|
|
|
14
14
|
public static func loadBundled() throws -> Data {
|
|
15
15
|
guard let url = Bundle.module.url(forResource: "ui-surface-contract", withExtension: "json"),
|
|
16
16
|
let data = try? Data(contentsOf: url) else {
|
|
17
|
-
throw NSError(domain: "
|
|
17
|
+
throw NSError(domain: "TopogramUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-surface-contract.json"])
|
|
18
18
|
}
|
|
19
19
|
return data
|
|
20
20
|
}
|
|
@@ -27,6 +27,12 @@ public final class TodoUiContract: ObservableObject {
|
|
|
27
27
|
screens.first { ($0["id"] as? String) == id }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
public func firstScreen(kind: String, excluding excludedId: String? = nil) -> [String: Any]? {
|
|
31
|
+
screens.first {
|
|
32
|
+
($0["kind"] as? String) == kind && ($0["id"] as? String) != excludedId
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
public var tabItems: [NavigationItem] {
|
|
31
37
|
guard let nav = raw["navigation"] as? [String: Any],
|
|
32
38
|
let items = nav["items"] as? [[String: Any]] else {
|
|
@@ -63,10 +69,10 @@ public struct NavigationItem: Identifiable {
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
public struct RootTabView: View {
|
|
66
|
-
@ObservedObject var contract:
|
|
67
|
-
let client:
|
|
72
|
+
@ObservedObject var contract: TopogramUiContract
|
|
73
|
+
let client: TopogramAPIClient
|
|
68
74
|
|
|
69
|
-
public init(contract:
|
|
75
|
+
public init(contract: TopogramUiContract, client: TopogramAPIClient) {
|
|
70
76
|
self.contract = contract
|
|
71
77
|
self.client = client
|
|
72
78
|
}
|
|
@@ -85,11 +91,11 @@ public struct RootTabView: View {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
public struct DynamicScreenView: View {
|
|
88
|
-
@ObservedObject var contract:
|
|
89
|
-
let client:
|
|
94
|
+
@ObservedObject var contract: TopogramUiContract
|
|
95
|
+
let client: TopogramAPIClient
|
|
90
96
|
let rootScreenId: String
|
|
91
97
|
|
|
92
|
-
public init(contract:
|
|
98
|
+
public init(contract: TopogramUiContract, client: TopogramAPIClient, rootScreenId: String) {
|
|
93
99
|
self.contract = contract
|
|
94
100
|
self.client = client
|
|
95
101
|
self.rootScreenId = rootScreenId
|
|
@@ -105,8 +111,8 @@ public struct DynamicScreenView: View {
|
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
struct ScreenSwitcher: View {
|
|
108
|
-
@ObservedObject var contract:
|
|
109
|
-
let client:
|
|
114
|
+
@ObservedObject var contract: TopogramUiContract
|
|
115
|
+
let client: TopogramAPIClient
|
|
110
116
|
let screen: [String: Any]
|
|
111
117
|
let baseParams: [String: String]
|
|
112
118
|
|
|
@@ -136,8 +142,8 @@ struct ScreenSwitcher: View {
|
|
|
136
142
|
// MARK: - List
|
|
137
143
|
|
|
138
144
|
struct ListScreen: View {
|
|
139
|
-
@ObservedObject var contract:
|
|
140
|
-
let client:
|
|
145
|
+
@ObservedObject var contract: TopogramUiContract
|
|
146
|
+
let client: TopogramAPIClient
|
|
141
147
|
let screen: [String: Any]
|
|
142
148
|
let baseParams: [String: String]
|
|
143
149
|
@State private var rows: [[String: Any]] = []
|
|
@@ -148,10 +154,11 @@ struct ListScreen: View {
|
|
|
148
154
|
let title = screen["title"] as? String ?? "List"
|
|
149
155
|
let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String
|
|
150
156
|
let screenId = screen["id"] as? String ?? ""
|
|
157
|
+
let filterFields = loadCap.map { client.queryFieldNames(for: $0) } ?? []
|
|
151
158
|
|
|
152
159
|
VStack(alignment: .leading, spacing: 8) {
|
|
153
|
-
if
|
|
154
|
-
filterBar
|
|
160
|
+
if let loadCap, !filterFields.isEmpty {
|
|
161
|
+
filterBar(loadCap: loadCap, fields: filterFields)
|
|
155
162
|
}
|
|
156
163
|
if let errorText {
|
|
157
164
|
Text(errorText).foregroundStyle(.red)
|
|
@@ -161,7 +168,7 @@ struct ListScreen: View {
|
|
|
161
168
|
List(0 ..< rows.count, id: \.self) { i in
|
|
162
169
|
let row = rows[i]
|
|
163
170
|
NavigationLink {
|
|
164
|
-
linkedDetail(row: row
|
|
171
|
+
linkedDetail(row: row)
|
|
165
172
|
} label: {
|
|
166
173
|
VStack(alignment: .leading) {
|
|
167
174
|
Text(rowTitle(row))
|
|
@@ -181,15 +188,10 @@ struct ListScreen: View {
|
|
|
181
188
|
Visibility.canShowAction(pid, screen: screen, resource: nil) {
|
|
182
189
|
ToolbarItem(placement: .primaryAction) {
|
|
183
190
|
NavigationLink("Add") {
|
|
184
|
-
linkedCreate(
|
|
191
|
+
linkedCreate(currentScreenId: screenId)
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
}
|
|
188
|
-
if screenId == "task_list", Visibility.canShowAction("cap_export_tasks", screen: screen, resource: nil) {
|
|
189
|
-
ToolbarItem(placement: .automatic) {
|
|
190
|
-
ExportTasksButton(client: client, contract: contract)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
195
|
}
|
|
194
196
|
.task {
|
|
195
197
|
if let loadCap {
|
|
@@ -199,18 +201,18 @@ struct ListScreen: View {
|
|
|
199
201
|
}
|
|
200
202
|
|
|
201
203
|
@ViewBuilder
|
|
202
|
-
private
|
|
204
|
+
private func filterBar(loadCap: String, fields: [String]) -> some View {
|
|
203
205
|
VStack(alignment: .leading) {
|
|
204
206
|
Text("Filters").font(.caption).foregroundStyle(.secondary)
|
|
205
207
|
HStack {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
ForEach(fields, id: \.self) { field in
|
|
209
|
+
TextField(field, text: bindingFilter(field))
|
|
210
|
+
}
|
|
209
211
|
}
|
|
210
212
|
.textFieldStyle(.roundedBorder)
|
|
211
213
|
Button("Apply") {
|
|
212
214
|
Task {
|
|
213
|
-
await reload(loadCap:
|
|
215
|
+
await reload(loadCap: loadCap)
|
|
214
216
|
}
|
|
215
217
|
}
|
|
216
218
|
}
|
|
@@ -237,29 +239,19 @@ struct ListScreen: View {
|
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
@ViewBuilder
|
|
240
|
-
private func linkedDetail(row: [String: Any]
|
|
241
|
-
let
|
|
242
|
-
|
|
243
|
-
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "task_detail") ?? [:], params: ["task_id": rid])
|
|
244
|
-
} else if listScreenId.contains("project") {
|
|
245
|
-
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "project_detail") ?? [:], params: ["project_id": rid])
|
|
246
|
-
} else if listScreenId.contains("user") {
|
|
247
|
-
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "user_detail") ?? [:], params: ["user_id": rid])
|
|
242
|
+
private func linkedDetail(row: [String: Any]) -> some View {
|
|
243
|
+
if let detail = contract.firstScreen(kind: "detail") {
|
|
244
|
+
DetailScreen(contract: contract, client: client, screen: detail, params: navigationParams(for: detail, row: row, client: client))
|
|
248
245
|
} else {
|
|
249
|
-
|
|
246
|
+
RowDetailFallback(row: row)
|
|
250
247
|
}
|
|
251
248
|
}
|
|
252
249
|
|
|
253
250
|
@ViewBuilder
|
|
254
|
-
private func linkedCreate(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
case "user_list":
|
|
259
|
-
FormScreen(client: client, contract: contract, screen: contract.screen(id: "user_create") ?? [:], params: [:])
|
|
260
|
-
case "task_list", "task_board", "task_calendar":
|
|
261
|
-
FormScreen(client: client, contract: contract, screen: contract.screen(id: "task_create") ?? [:], params: [:])
|
|
262
|
-
default:
|
|
251
|
+
private func linkedCreate(currentScreenId: String) -> some View {
|
|
252
|
+
if let form = contract.firstScreen(kind: "form", excluding: currentScreenId) ?? contract.firstScreen(kind: "wizard", excluding: currentScreenId) {
|
|
253
|
+
FormScreen(client: client, contract: contract, screen: form, params: [:])
|
|
254
|
+
} else {
|
|
263
255
|
Text("Create")
|
|
264
256
|
}
|
|
265
257
|
}
|
|
@@ -279,8 +271,8 @@ private func rowSubtitle(_ row: [String: Any]) -> String {
|
|
|
279
271
|
// MARK: - Detail
|
|
280
272
|
|
|
281
273
|
struct DetailScreen: View {
|
|
282
|
-
@ObservedObject var contract:
|
|
283
|
-
let client:
|
|
274
|
+
@ObservedObject var contract: TopogramUiContract
|
|
275
|
+
let client: TopogramAPIClient
|
|
284
276
|
let screen: [String: Any]
|
|
285
277
|
let params: [String: String]
|
|
286
278
|
@State private var payload: [String: Any]?
|
|
@@ -310,7 +302,7 @@ struct DetailScreen: View {
|
|
|
310
302
|
}
|
|
311
303
|
.navigationTitle((screen["title"] as? String) ?? "Detail")
|
|
312
304
|
.toolbar {
|
|
313
|
-
ToolbarItemGroup(placement: .
|
|
305
|
+
ToolbarItemGroup(placement: .automatic) {
|
|
314
306
|
if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
|
|
315
307
|
let cid = primary["id"] as? String {
|
|
316
308
|
NavigationLink("Edit") {
|
|
@@ -353,19 +345,12 @@ struct DetailScreen: View {
|
|
|
353
345
|
|
|
354
346
|
@MainActor
|
|
355
347
|
private func runSecondary(_ capabilityId: String) async {
|
|
356
|
-
guard capabilityId == "cap_complete_task",
|
|
357
|
-
let taskId = params["task_id"] ?? params["id"],
|
|
358
|
-
let detail = payload else { return }
|
|
359
|
-
let etag = String(describing: detail["updated_at"] ?? "")
|
|
360
348
|
var headers: [String: String] = [:]
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
"completed": true,
|
|
365
|
-
"idempotency_key": UUID().uuidString
|
|
366
|
-
]
|
|
349
|
+
if let detail = payload, let updatedAt = detail["updated_at"] {
|
|
350
|
+
headers["If-Match"] = String(describing: updatedAt)
|
|
351
|
+
}
|
|
367
352
|
do {
|
|
368
|
-
_ = try await client.requestCapability(capabilityId, input:
|
|
353
|
+
_ = try await client.requestCapability(capabilityId, input: params, extraHeaders: headers)
|
|
369
354
|
await load()
|
|
370
355
|
} catch {
|
|
371
356
|
errorText = String(describing: error)
|
|
@@ -374,13 +359,8 @@ struct DetailScreen: View {
|
|
|
374
359
|
|
|
375
360
|
@MainActor
|
|
376
361
|
private func runDestructive(_ capabilityId: String) async {
|
|
377
|
-
guard let rid = params["task_id"] ?? params["project_id"] ?? params["user_id"] ?? params["id"] else { return }
|
|
378
|
-
var input: [String: Any] = [:]
|
|
379
|
-
if capabilityId.contains("task") { input["task_id"] = rid }
|
|
380
|
-
if capabilityId.contains("project") { input["project_id"] = rid }
|
|
381
|
-
if capabilityId.contains("user") { input["user_id"] = rid }
|
|
382
362
|
do {
|
|
383
|
-
_ = try await client.requestCapability(capabilityId, input:
|
|
363
|
+
_ = try await client.requestCapability(capabilityId, input: params)
|
|
384
364
|
} catch {
|
|
385
365
|
errorText = String(describing: error)
|
|
386
366
|
}
|
|
@@ -388,16 +368,22 @@ struct DetailScreen: View {
|
|
|
388
368
|
|
|
389
369
|
@ViewBuilder
|
|
390
370
|
private func editForm(cid: String) -> some View {
|
|
391
|
-
let
|
|
392
|
-
|
|
371
|
+
if let form = contract.screens.first(where: { screen in
|
|
372
|
+
guard let submit = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return false }
|
|
373
|
+
return submit == cid
|
|
374
|
+
}) ?? contract.firstScreen(kind: "form") {
|
|
375
|
+
FormScreen(client: client, contract: contract, screen: form, params: params)
|
|
376
|
+
} else {
|
|
377
|
+
Text("Edit")
|
|
378
|
+
}
|
|
393
379
|
}
|
|
394
380
|
}
|
|
395
381
|
|
|
396
382
|
// MARK: - Form
|
|
397
383
|
|
|
398
384
|
struct FormScreen: View {
|
|
399
|
-
let client:
|
|
400
|
-
@ObservedObject var contract:
|
|
385
|
+
let client: TopogramAPIClient
|
|
386
|
+
@ObservedObject var contract: TopogramUiContract
|
|
401
387
|
let screen: [String: Any]
|
|
402
388
|
let params: [String: String]
|
|
403
389
|
@State private var values: [String: String] = [:]
|
|
@@ -434,23 +420,16 @@ struct FormScreen: View {
|
|
|
434
420
|
}
|
|
435
421
|
|
|
436
422
|
private func preload() async {
|
|
437
|
-
guard let
|
|
438
|
-
|
|
439
|
-
let
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if sid.contains("user") { p["user_id"] = params["user_id"] ?? params["id"] ?? "" }
|
|
444
|
-
do {
|
|
445
|
-
let json = try await client.requestCapability(getCap, input: p)
|
|
446
|
-
if let dict = json as? [String: Any] {
|
|
447
|
-
for key in dict.keys {
|
|
448
|
-
values[key] = String(describing: dict[key] ?? "")
|
|
449
|
-
}
|
|
423
|
+
guard let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
|
|
424
|
+
do {
|
|
425
|
+
let json = try await client.requestCapability(loadCap, input: params)
|
|
426
|
+
if let dict = json as? [String: Any] {
|
|
427
|
+
for key in dict.keys {
|
|
428
|
+
values[key] = String(describing: dict[key] ?? "")
|
|
450
429
|
}
|
|
451
|
-
} catch {
|
|
452
|
-
message = String(describing: error)
|
|
453
430
|
}
|
|
431
|
+
} catch {
|
|
432
|
+
message = String(describing: error)
|
|
454
433
|
}
|
|
455
434
|
}
|
|
456
435
|
|
|
@@ -466,7 +445,7 @@ struct FormScreen: View {
|
|
|
466
445
|
input[k] = v
|
|
467
446
|
}
|
|
468
447
|
var headers: [String: String] = [:]
|
|
469
|
-
if
|
|
448
|
+
if let etag = values["updated_at"], !etag.isEmpty {
|
|
470
449
|
headers["If-Match"] = etag
|
|
471
450
|
}
|
|
472
451
|
do {
|
|
@@ -478,59 +457,11 @@ struct FormScreen: View {
|
|
|
478
457
|
}
|
|
479
458
|
}
|
|
480
459
|
|
|
481
|
-
extension TodoAPIClient {
|
|
482
|
-
func bodyFieldNames(for capabilityId: String) -> [String] {
|
|
483
|
-
guard let contract = contracts[capabilityId] as? [String: Any],
|
|
484
|
-
let rc = contract["requestContract"] as? [String: Any],
|
|
485
|
-
let transport = rc["transport"] as? [String: Any],
|
|
486
|
-
let body = transport["body"] as? [[String: Any]] else {
|
|
487
|
-
return []
|
|
488
|
-
}
|
|
489
|
-
return body.compactMap { $0["name"] as? String }
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
struct ExportTasksButton: View {
|
|
494
|
-
let client: TodoAPIClient
|
|
495
|
-
@ObservedObject var contract: TodoUiContract
|
|
496
|
-
@State private var jobId: String?
|
|
497
|
-
|
|
498
|
-
var body: some View {
|
|
499
|
-
Group {
|
|
500
|
-
if let jobId {
|
|
501
|
-
NavigationLink("Export job") {
|
|
502
|
-
JobStatusScreen(
|
|
503
|
-
client: client,
|
|
504
|
-
contract: contract,
|
|
505
|
-
screen: contract.screen(id: "task_exports") ?? [:],
|
|
506
|
-
params: ["job_id": jobId]
|
|
507
|
-
)
|
|
508
|
-
}
|
|
509
|
-
} else {
|
|
510
|
-
Button("Export") {
|
|
511
|
-
Task { await run() }
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
private func run() async {
|
|
518
|
-
do {
|
|
519
|
-
let json = try await client.requestCapability("cap_export_tasks", input: [:])
|
|
520
|
-
if let dict = json as? [String: Any], let id = dict["job_id"] as? String {
|
|
521
|
-
jobId = id
|
|
522
|
-
}
|
|
523
|
-
} catch {
|
|
524
|
-
jobId = nil
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
460
|
// MARK: - Board & Calendar
|
|
530
461
|
|
|
531
462
|
struct BoardScreen: View {
|
|
532
|
-
@ObservedObject var contract:
|
|
533
|
-
let client:
|
|
463
|
+
@ObservedObject var contract: TopogramUiContract
|
|
464
|
+
let client: TopogramAPIClient
|
|
534
465
|
let screen: [String: Any]
|
|
535
466
|
let baseParams: [String: String]
|
|
536
467
|
@State private var rows: [[String: Any]] = []
|
|
@@ -549,8 +480,8 @@ struct BoardScreen: View {
|
|
|
549
480
|
DetailScreen(
|
|
550
481
|
contract: contract,
|
|
551
482
|
client: client,
|
|
552
|
-
screen: contract.
|
|
553
|
-
params:
|
|
483
|
+
screen: contract.firstScreen(kind: "detail") ?? [:],
|
|
484
|
+
params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
|
|
554
485
|
)
|
|
555
486
|
} label: {
|
|
556
487
|
Text(rowTitle(row))
|
|
@@ -578,8 +509,8 @@ struct BoardScreen: View {
|
|
|
578
509
|
}
|
|
579
510
|
|
|
580
511
|
struct CalendarScreen: View {
|
|
581
|
-
@ObservedObject var contract:
|
|
582
|
-
let client:
|
|
512
|
+
@ObservedObject var contract: TopogramUiContract
|
|
513
|
+
let client: TopogramAPIClient
|
|
583
514
|
let screen: [String: Any]
|
|
584
515
|
let baseParams: [String: String]
|
|
585
516
|
@State private var rows: [[String: Any]] = []
|
|
@@ -602,8 +533,8 @@ struct CalendarScreen: View {
|
|
|
602
533
|
DetailScreen(
|
|
603
534
|
contract: contract,
|
|
604
535
|
client: client,
|
|
605
|
-
screen: contract.
|
|
606
|
-
params:
|
|
536
|
+
screen: contract.firstScreen(kind: "detail") ?? [:],
|
|
537
|
+
params: navigationParams(for: contract.firstScreen(kind: "detail") ?? [:], row: row, client: client)
|
|
607
538
|
)
|
|
608
539
|
} label: {
|
|
609
540
|
Text(rowTitle(row))
|
|
@@ -630,8 +561,8 @@ struct CalendarScreen: View {
|
|
|
630
561
|
}
|
|
631
562
|
|
|
632
563
|
struct JobStatusScreen: View {
|
|
633
|
-
let client:
|
|
634
|
-
@ObservedObject var contract:
|
|
564
|
+
let client: TopogramAPIClient
|
|
565
|
+
@ObservedObject var contract: TopogramUiContract
|
|
635
566
|
let screen: [String: Any]
|
|
636
567
|
let params: [String: String]
|
|
637
568
|
@State private var job: [String: Any]?
|
|
@@ -672,11 +603,45 @@ struct JobStatusScreen: View {
|
|
|
672
603
|
}
|
|
673
604
|
|
|
674
605
|
private func download(_ capabilityId: String) async {
|
|
675
|
-
let jobId = params["job_id"] ?? ""
|
|
676
606
|
do {
|
|
677
|
-
_ = try await client.requestCapability(capabilityId, input:
|
|
607
|
+
_ = try await client.requestCapability(capabilityId, input: params)
|
|
678
608
|
} catch {
|
|
679
609
|
// ignore download errors in stub
|
|
680
610
|
}
|
|
681
611
|
}
|
|
682
612
|
}
|
|
613
|
+
|
|
614
|
+
struct RowDetailFallback: View {
|
|
615
|
+
let row: [String: Any]
|
|
616
|
+
|
|
617
|
+
var body: some View {
|
|
618
|
+
List(Array(row.keys.sorted()), id: \.self) { key in
|
|
619
|
+
HStack {
|
|
620
|
+
Text(key).foregroundStyle(.secondary)
|
|
621
|
+
Spacer()
|
|
622
|
+
Text(String(describing: row[key] ?? ""))
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
.navigationTitle(rowTitle(row))
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private func navigationParams(for screen: [String: Any], row: [String: Any], client: TopogramAPIClient) -> [String: String] {
|
|
630
|
+
guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else {
|
|
631
|
+
return row.compactMapValues { $0 as? String }
|
|
632
|
+
}
|
|
633
|
+
let pathFields = client.pathFieldNames(for: cap)
|
|
634
|
+
if pathFields.isEmpty {
|
|
635
|
+
return row.compactMapValues { $0 as? String }
|
|
636
|
+
}
|
|
637
|
+
var params: [String: String] = [:]
|
|
638
|
+
let fallbackId = row["id"].map { String(describing: $0) } ?? ""
|
|
639
|
+
for field in pathFields {
|
|
640
|
+
if let value = row[field] {
|
|
641
|
+
params[field] = String(describing: value)
|
|
642
|
+
} else if !fallbackId.isEmpty {
|
|
643
|
+
params[field] = fallbackId
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return params
|
|
647
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
3
|
/// Dynamic capability client mirroring `src/lib/api/client.ts` (requestCapability over bundled api-contracts.json).
|
|
4
|
-
public final class
|
|
4
|
+
public final class TopogramAPIClient: @unchecked Sendable {
|
|
5
5
|
private let session: URLSession
|
|
6
6
|
let contracts: [String: Any]
|
|
7
7
|
public init(session: URLSession = .shared, contractsData: Data) throws {
|
|
8
8
|
self.session = session
|
|
9
9
|
guard let root = try JSONSerialization.jsonObject(with: contractsData) as? [String: Any] else {
|
|
10
|
-
throw
|
|
10
|
+
throw TopogramAPIError.invalidContracts
|
|
11
11
|
}
|
|
12
12
|
self.contracts = root
|
|
13
13
|
}
|
|
@@ -15,7 +15,7 @@ public final class TodoAPIClient: @unchecked Sendable {
|
|
|
15
15
|
public static func loadBundledContracts() throws -> Data {
|
|
16
16
|
guard let url = Bundle.module.url(forResource: "api-contracts", withExtension: "json"),
|
|
17
17
|
let data = try? Data(contentsOf: url) else {
|
|
18
|
-
throw
|
|
18
|
+
throw TopogramAPIError.missingResource("api-contracts.json")
|
|
19
19
|
}
|
|
20
20
|
return data
|
|
21
21
|
}
|
|
@@ -26,13 +26,34 @@ public final class TodoAPIClient: @unchecked Sendable {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
private func authToken() -> String {
|
|
29
|
-
ProcessInfo.processInfo.environment["
|
|
29
|
+
ProcessInfo.processInfo.environment["PUBLIC_TOPOGRAM_AUTH_TOKEN"] ?? ""
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private func transportFields(for capabilityId: String, section: String) -> [[String: Any]] {
|
|
33
|
+
guard let contract = contracts[capabilityId] as? [String: Any],
|
|
34
|
+
let requestContract = contract["requestContract"] as? [String: Any],
|
|
35
|
+
let transport = requestContract["transport"] as? [String: Any] else {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
return transport[section] as? [[String: Any]] ?? []
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public func pathFieldNames(for capabilityId: String) -> [String] {
|
|
42
|
+
transportFields(for: capabilityId, section: "path").compactMap { $0["name"] as? String }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func queryFieldNames(for capabilityId: String) -> [String] {
|
|
46
|
+
transportFields(for: capabilityId, section: "query").compactMap { $0["name"] as? String }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public func bodyFieldNames(for capabilityId: String) -> [String] {
|
|
50
|
+
transportFields(for: capabilityId, section: "body").compactMap { $0["name"] as? String }
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
private func buildPath(contract: [String: Any], input: [String: Any]) throws -> String {
|
|
33
54
|
guard let endpoint = contract["endpoint"] as? [String: Any],
|
|
34
55
|
let rawPath = endpoint["path"] as? String else {
|
|
35
|
-
throw
|
|
56
|
+
throw TopogramAPIError.invalidContracts
|
|
36
57
|
}
|
|
37
58
|
var path = rawPath
|
|
38
59
|
let requestContract = contract["requestContract"] as? [String: Any]
|
|
@@ -78,15 +99,15 @@ public final class TodoAPIClient: @unchecked Sendable {
|
|
|
78
99
|
extraHeaders: [String: String] = [:]
|
|
79
100
|
) async throws -> Any {
|
|
80
101
|
guard let contract = contracts[capabilityId] as? [String: Any] else {
|
|
81
|
-
throw
|
|
102
|
+
throw TopogramAPIError.unknownCapability(capabilityId)
|
|
82
103
|
}
|
|
83
104
|
guard let endpoint = contract["endpoint"] as? [String: Any],
|
|
84
105
|
let method = endpoint["method"] as? String else {
|
|
85
|
-
throw
|
|
106
|
+
throw TopogramAPIError.invalidContracts
|
|
86
107
|
}
|
|
87
108
|
let path = try buildPath(contract: contract, input: input)
|
|
88
109
|
guard let url = URL(string: path, relativeTo: URL(string: apiBase()))?.absoluteURL else {
|
|
89
|
-
throw
|
|
110
|
+
throw TopogramAPIError.badURL(path)
|
|
90
111
|
}
|
|
91
112
|
var request = URLRequest(url: url)
|
|
92
113
|
request.httpMethod = method
|
|
@@ -121,11 +142,11 @@ public final class TodoAPIClient: @unchecked Sendable {
|
|
|
121
142
|
let downloadable = endpoint["download"] as? [[String: Any]] ?? []
|
|
122
143
|
let (data, response) = try await session.data(for: request)
|
|
123
144
|
guard let http = response as? HTTPURLResponse else {
|
|
124
|
-
throw
|
|
145
|
+
throw TopogramAPIError.invalidResponse
|
|
125
146
|
}
|
|
126
147
|
guard (200 ..< 300).contains(http.statusCode) else {
|
|
127
148
|
let text = String(data: data, encoding: .utf8) ?? ""
|
|
128
|
-
throw
|
|
149
|
+
throw TopogramAPIError.http(http.statusCode, text)
|
|
129
150
|
}
|
|
130
151
|
if http.statusCode == 204 {
|
|
131
152
|
return NSNull()
|
|
@@ -146,7 +167,7 @@ public final class TodoAPIClient: @unchecked Sendable {
|
|
|
146
167
|
}
|
|
147
168
|
}
|
|
148
169
|
|
|
149
|
-
public enum
|
|
170
|
+
public enum TopogramAPIError: Error {
|
|
150
171
|
case invalidContracts
|
|
151
172
|
case missingResource(String)
|
|
152
173
|
case unknownCapability(String)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
3
|
@main
|
|
4
|
-
struct
|
|
4
|
+
struct TopogramSwiftUIApp: App {
|
|
5
5
|
@StateObject private var contractHolder = ContractHolder()
|
|
6
6
|
|
|
7
7
|
var body: some Scene {
|
|
@@ -13,7 +13,7 @@ struct TodoSwiftUIApp: App {
|
|
|
13
13
|
Text("Failed to load Topogram UI contract: \(err)")
|
|
14
14
|
.padding()
|
|
15
15
|
} else {
|
|
16
|
-
ProgressView("Loading Topogram
|
|
16
|
+
ProgressView("Loading Topogram UI…")
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
.task {
|
|
@@ -25,16 +25,16 @@ struct TodoSwiftUIApp: App {
|
|
|
25
25
|
|
|
26
26
|
@MainActor
|
|
27
27
|
final class ContractHolder: ObservableObject {
|
|
28
|
-
@Published var contract:
|
|
29
|
-
@Published var client:
|
|
28
|
+
@Published var contract: TopogramUiContract?
|
|
29
|
+
@Published var client: TopogramAPIClient?
|
|
30
30
|
@Published var error: String?
|
|
31
31
|
|
|
32
32
|
func bootstrap() async {
|
|
33
33
|
do {
|
|
34
|
-
let uiData = try
|
|
35
|
-
let apiData = try
|
|
36
|
-
let ui = try
|
|
37
|
-
let cli = try
|
|
34
|
+
let uiData = try TopogramUiContract.loadBundled()
|
|
35
|
+
let apiData = try TopogramAPIClient.loadBundledContracts()
|
|
36
|
+
let ui = try TopogramUiContract(data: uiData)
|
|
37
|
+
let cli = try TopogramAPIClient(contractsData: apiData)
|
|
38
38
|
contract = ui
|
|
39
39
|
client = cli
|
|
40
40
|
} catch {
|
|
@@ -108,11 +108,12 @@ public enum Visibility {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
private static func currentPrincipal(overrides: PrincipalOverride?) -> Principal? {
|
|
111
|
-
let token = env("
|
|
111
|
+
let token = env("PUBLIC_TOPOGRAM_AUTH_TOKEN")
|
|
112
112
|
let jwtPrincipal = token.isEmpty ? nil : principalFromJwt(token)
|
|
113
113
|
let envClaims = parseClaims(env("PUBLIC_TOPOGRAM_AUTH_CLAIMS"))
|
|
114
|
+
let envUserId = env("PUBLIC_TOPOGRAM_AUTH_USER_ID")
|
|
114
115
|
let userId = overrides?.userId
|
|
115
|
-
??
|
|
116
|
+
?? (!envUserId.isEmpty ? envUserId : nil)
|
|
116
117
|
?? jwtPrincipal?.userId
|
|
117
118
|
?? ""
|
|
118
119
|
|
package/src/project-config.js
CHANGED
|
@@ -305,128 +305,128 @@ function validateOutputConfig(errors, config) {
|
|
|
305
305
|
}
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
|
-
* @param {any}
|
|
308
|
+
* @param {any} runtime
|
|
309
309
|
* @returns {string}
|
|
310
310
|
*/
|
|
311
|
-
function
|
|
312
|
-
return
|
|
311
|
+
function runtimeLabel(runtime) {
|
|
312
|
+
return runtime?.id ? `Runtime '${runtime.id}'` : "Topology runtime";
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/**
|
|
316
316
|
* @param {ValidationError[]} errors
|
|
317
|
-
* @param {any}
|
|
317
|
+
* @param {any} runtime
|
|
318
318
|
* @param {Set<string>} seenIds
|
|
319
319
|
* @returns {boolean}
|
|
320
320
|
*/
|
|
321
|
-
function
|
|
322
|
-
if (!
|
|
321
|
+
function validateRuntimeShape(errors, runtime, seenIds) {
|
|
322
|
+
if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) {
|
|
323
323
|
pushError(errors, "Topology runtime must be an object");
|
|
324
324
|
return false;
|
|
325
325
|
}
|
|
326
|
-
if (typeof
|
|
327
|
-
pushError(errors, `${
|
|
328
|
-
} else if (seenIds.has(
|
|
329
|
-
pushError(errors, `Duplicate topology runtime id '${
|
|
326
|
+
if (typeof runtime.id !== "string" || !IDENTIFIER_PATTERN.test(runtime.id)) {
|
|
327
|
+
pushError(errors, `${runtimeLabel(runtime)} id must match ${IDENTIFIER_PATTERN}`);
|
|
328
|
+
} else if (seenIds.has(runtime.id)) {
|
|
329
|
+
pushError(errors, `Duplicate topology runtime id '${runtime.id}'`);
|
|
330
330
|
} else {
|
|
331
|
-
seenIds.add(
|
|
331
|
+
seenIds.add(runtime.id);
|
|
332
332
|
}
|
|
333
|
-
if (
|
|
334
|
-
pushError(errors, `${
|
|
333
|
+
if (runtime.type != null) {
|
|
334
|
+
pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'type'", "'kind'", `"kind": "api_service"`)}`);
|
|
335
335
|
}
|
|
336
|
-
if (
|
|
337
|
-
pushError(errors, `${
|
|
336
|
+
if (runtime.database != null) {
|
|
337
|
+
pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'database'", "'uses_database'", `"uses_database": "app_db"`)}`);
|
|
338
338
|
}
|
|
339
|
-
if (
|
|
340
|
-
pushError(errors, `${
|
|
339
|
+
if (runtime.api != null) {
|
|
340
|
+
pushError(errors, `${runtimeLabel(runtime)} ${renameDiagnostic("'api'", "'uses_api'", `"uses_api": "app_api"`)}`);
|
|
341
341
|
}
|
|
342
|
-
if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(
|
|
343
|
-
pushError(errors, `${
|
|
342
|
+
if (!["api_service", "web_surface", "ios_surface", "android_surface", "database"].includes(runtime.kind)) {
|
|
343
|
+
pushError(errors, `${runtimeLabel(runtime)} kind must be api_service, web_surface, ios_surface, android_surface, or database`);
|
|
344
344
|
}
|
|
345
|
-
if (typeof
|
|
346
|
-
pushError(errors, `${
|
|
345
|
+
if (typeof runtime.projection !== "string" || runtime.projection.length === 0) {
|
|
346
|
+
pushError(errors, `${runtimeLabel(runtime)} projection must be a non-empty string`);
|
|
347
347
|
}
|
|
348
|
-
if (!
|
|
349
|
-
pushError(errors, `${
|
|
348
|
+
if (!runtime.generator || typeof runtime.generator !== "object") {
|
|
349
|
+
pushError(errors, `${runtimeLabel(runtime)} generator must be an object`);
|
|
350
350
|
} else {
|
|
351
|
-
if (typeof
|
|
352
|
-
pushError(errors, `${
|
|
351
|
+
if (typeof runtime.generator.id !== "string" || runtime.generator.id.length === 0) {
|
|
352
|
+
pushError(errors, `${runtimeLabel(runtime)} generator.id must be a non-empty string`);
|
|
353
353
|
}
|
|
354
|
-
if (typeof
|
|
355
|
-
pushError(errors, `${
|
|
354
|
+
if (typeof runtime.generator.version !== "string" || runtime.generator.version.length === 0) {
|
|
355
|
+
pushError(errors, `${runtimeLabel(runtime)} generator.version must be a non-empty string`);
|
|
356
356
|
}
|
|
357
|
-
if (
|
|
358
|
-
pushError(errors, `${
|
|
357
|
+
if (runtime.generator.package != null && (typeof runtime.generator.package !== "string" || runtime.generator.package.length === 0)) {
|
|
358
|
+
pushError(errors, `${runtimeLabel(runtime)} generator.package must be a non-empty string when provided`);
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
|
-
if (
|
|
362
|
-
pushError(errors, `${
|
|
361
|
+
if (runtime.port != null && (!Number.isInteger(runtime.port) || runtime.port <= 0 || runtime.port > 65535)) {
|
|
362
|
+
pushError(errors, `${runtimeLabel(runtime)} port must be an integer from 1 to 65535`);
|
|
363
363
|
}
|
|
364
364
|
return true;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
368
|
* @param {ValidationError[]} errors
|
|
369
|
-
* @param {RuntimeTopologyRuntime}
|
|
369
|
+
* @param {RuntimeTopologyRuntime} runtime
|
|
370
370
|
* @param {Map<string, Record<string, any>>} projections
|
|
371
371
|
* @param {{ configDir?: string|null, rootDir?: string|null }} [options]
|
|
372
372
|
* @returns {void}
|
|
373
373
|
*/
|
|
374
|
-
function
|
|
375
|
-
const projection = projections.get(
|
|
374
|
+
function validateRuntimeCompatibility(errors, runtime, projections, options = {}) {
|
|
375
|
+
const projection = projections.get(runtime.projection);
|
|
376
376
|
if (!projection) {
|
|
377
|
-
pushError(errors, `${
|
|
377
|
+
pushError(errors, `${runtimeLabel(runtime)} references missing projection '${runtime.projection}'`);
|
|
378
378
|
return;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
const resolvedManifest = resolveGeneratorManifestForBinding(
|
|
381
|
+
const resolvedManifest = resolveGeneratorManifestForBinding(runtime.generator, options);
|
|
382
382
|
const manifest = resolvedManifest.manifest;
|
|
383
383
|
if (!manifest) {
|
|
384
384
|
const details = resolvedManifest.errors.length > 0 ? `: ${resolvedManifest.errors.join("; ")}` : "";
|
|
385
|
-
pushError(errors, `${
|
|
385
|
+
pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses unknown generator '${runtime.generator?.id}' version '${runtime.generator?.version || "unknown"}'${details}`);
|
|
386
386
|
return;
|
|
387
387
|
}
|
|
388
388
|
const manifestValidation = validateGeneratorManifest(manifest);
|
|
389
389
|
if (!manifestValidation.ok) {
|
|
390
390
|
for (const message of manifestValidation.errors) {
|
|
391
|
-
pushError(errors, `${
|
|
391
|
+
pushError(errors, `${runtimeLabel(runtime)} generator manifest invalid: ${message}`);
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
if (manifest.planned) {
|
|
395
|
-
pushError(errors, `${
|
|
395
|
+
pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' uses planned generator '${manifest.id}@${manifest.version}', which is not implemented yet`);
|
|
396
396
|
}
|
|
397
|
-
if (manifest.version !==
|
|
398
|
-
pushError(errors, `${
|
|
397
|
+
if (manifest.version !== runtime.generator.version) {
|
|
398
|
+
pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}' version '${runtime.generator.version}' is unsupported; expected '${manifest.version}'`);
|
|
399
399
|
}
|
|
400
|
-
if (!isGeneratorCompatible(manifest,
|
|
401
|
-
pushError(errors, `${
|
|
400
|
+
if (!isGeneratorCompatible(manifest, runtime.kind, projection)) {
|
|
401
|
+
pushError(errors, `${runtimeLabel(runtime)} for projection '${projection.id}' generator '${manifest.id}@${manifest.version}' is incompatible with runtime kind '${runtime.kind}' and projection type '${projection.type || "api_contract"}'`);
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
/**
|
|
406
406
|
* @param {ValidationError[]} errors
|
|
407
|
-
* @param {RuntimeTopologyRuntime[]}
|
|
407
|
+
* @param {RuntimeTopologyRuntime[]} runtimes
|
|
408
408
|
* @returns {void}
|
|
409
409
|
*/
|
|
410
|
-
function validateTopologyReferences(errors,
|
|
411
|
-
const byId = new Map(
|
|
410
|
+
function validateTopologyReferences(errors, runtimes) {
|
|
411
|
+
const byId = new Map(runtimes.map((runtime) => [runtime.id, runtime]));
|
|
412
412
|
const usedPorts = new Map();
|
|
413
|
-
for (const
|
|
414
|
-
if (
|
|
415
|
-
const existing = usedPorts.get(
|
|
413
|
+
for (const runtime of runtimes) {
|
|
414
|
+
if (runtime.port != null) {
|
|
415
|
+
const existing = usedPorts.get(runtime.port);
|
|
416
416
|
if (existing) {
|
|
417
|
-
pushError(errors, `Port ${
|
|
417
|
+
pushError(errors, `Port ${runtime.port} is used by both '${existing}' and '${runtime.id}'`);
|
|
418
418
|
} else {
|
|
419
|
-
usedPorts.set(
|
|
419
|
+
usedPorts.set(runtime.port, runtime.id);
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
-
if (
|
|
423
|
-
if (
|
|
424
|
-
pushError(errors, `${
|
|
422
|
+
if (runtime.kind === "api_service") {
|
|
423
|
+
if (runtime.uses_database && byId.get(runtime.uses_database)?.kind !== "database") {
|
|
424
|
+
pushError(errors, `${runtimeLabel(runtime)} references missing database runtime '${runtime.uses_database}'`);
|
|
425
425
|
}
|
|
426
426
|
}
|
|
427
|
-
if (["web_surface", "ios_surface", "android_surface"].includes(
|
|
428
|
-
if (
|
|
429
|
-
pushError(errors, `${
|
|
427
|
+
if (["web_surface", "ios_surface", "android_surface"].includes(runtime.kind)) {
|
|
428
|
+
if (runtime.uses_api && byId.get(runtime.uses_api)?.kind !== "api_service") {
|
|
429
|
+
pushError(errors, `${runtimeLabel(runtime)} references missing api runtime '${runtime.uses_api}'`);
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
}
|
|
@@ -455,8 +455,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
|
|
|
455
455
|
pushError(errors, "topogram.project.json topology.runtimes must be an array");
|
|
456
456
|
} else {
|
|
457
457
|
const seenIds = new Set();
|
|
458
|
-
for (const
|
|
459
|
-
|
|
458
|
+
for (const runtime of config.topology.runtimes) {
|
|
459
|
+
validateRuntimeShape(errors, runtime, seenIds);
|
|
460
460
|
}
|
|
461
461
|
const generatorPolicy = validateProjectGeneratorPolicy(config, options);
|
|
462
462
|
for (const error of generatorPolicy.errors) {
|
|
@@ -464,8 +464,8 @@ export function validateProjectConfig(config, graph = null, options = {}) {
|
|
|
464
464
|
}
|
|
465
465
|
if (graph) {
|
|
466
466
|
const projections = projectionById(graph);
|
|
467
|
-
for (const
|
|
468
|
-
|
|
467
|
+
for (const runtime of config.topology.runtimes) {
|
|
468
|
+
validateRuntimeCompatibility(errors, runtime, projections, options);
|
|
469
469
|
}
|
|
470
470
|
validateTopologyReferences(errors, config.topology.runtimes);
|
|
471
471
|
}
|