typespec-rust-emitter 0.1.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/README.md +220 -0
- package/dist/src/emitter.d.ts +7 -0
- package/dist/src/emitter.js +490 -0
- package/dist/src/emitter.js.map +1 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib.d.ts +12 -0
- package/dist/src/lib.js +7 -0
- package/dist/src/lib.js.map +1 -0
- package/dist/src/testing/index.d.ts +2 -0
- package/dist/src/testing/index.js +8 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/test/hello.test.d.ts +1 -0
- package/dist/test/hello.test.js +140 -0
- package/dist/test/hello.test.js.map +1 -0
- package/dist/test/test-host.d.ts +4 -0
- package/dist/test/test-host.js +16 -0
- package/dist/test/test-host.js.map +1 -0
- package/eslint.config.js +20 -0
- package/example/lib/learning/models.tsp +189 -0
- package/example/lib/learning/operations.tsp +319 -0
- package/example/main.tsp +8 -0
- package/example/output-rust/Cargo.lock +1731 -0
- package/example/output-rust/Cargo.toml +12 -0
- package/example/output-rust/src/generated/mod.rs +1 -0
- package/example/output-rust/src/generated/types.rs +315 -0
- package/example/output-rust/src/main.rs +5 -0
- package/example/output-rust/src/mod.rs +1 -0
- package/example/package-lock.json +1495 -0
- package/example/package.json +15 -0
- package/example/tspconfig.yaml +10 -0
- package/justfile +15 -0
- package/package.json +64 -0
- package/prettierrc.yaml +8 -0
- package/src/emitter.ts +685 -0
- package/src/index.ts +3 -0
- package/src/lib.ts +8 -0
- package/src/lib.tsp +6 -0
- package/src/testing/index.ts +8 -0
- package/test/hello.test.ts +168 -0
- package/test/test-host.ts +20 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { strictEqual } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { emit } from "./test-host.js";
|
|
4
|
+
describe("Rust emitter", () => {
|
|
5
|
+
it("emits basic model", async () => {
|
|
6
|
+
const results = await emit(`
|
|
7
|
+
model User {
|
|
8
|
+
name: string;
|
|
9
|
+
age: int32;
|
|
10
|
+
}
|
|
11
|
+
`);
|
|
12
|
+
const output = results["types.rs"];
|
|
13
|
+
strictEqual(output.includes("pub struct User"), true);
|
|
14
|
+
strictEqual(output.includes("pub name: String,"), true);
|
|
15
|
+
strictEqual(output.includes("pub age: i32,"), true);
|
|
16
|
+
});
|
|
17
|
+
it("emits optional properties", async () => {
|
|
18
|
+
const results = await emit(`
|
|
19
|
+
model Person {
|
|
20
|
+
firstName: string;
|
|
21
|
+
middleName?: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
const output = results["types.rs"];
|
|
26
|
+
strictEqual(output.includes('#[serde(rename = "firstName")]'), true);
|
|
27
|
+
strictEqual(output.includes("pub first_name: String,"), true);
|
|
28
|
+
strictEqual(output.includes('#[serde(rename = "middleName")]'), true);
|
|
29
|
+
strictEqual(output.includes("pub middle_name: Option<String>,"), true);
|
|
30
|
+
strictEqual(output.includes("#[serde(skip_serializing_if"), true);
|
|
31
|
+
strictEqual(output.includes('#[serde(rename = "lastName")]'), true);
|
|
32
|
+
strictEqual(output.includes("pub last_name: String,"), true);
|
|
33
|
+
});
|
|
34
|
+
it("emits string enum", async () => {
|
|
35
|
+
const results = await emit(`
|
|
36
|
+
enum Status {
|
|
37
|
+
active,
|
|
38
|
+
inactive,
|
|
39
|
+
pending,
|
|
40
|
+
}
|
|
41
|
+
`);
|
|
42
|
+
const output = results["types.rs"];
|
|
43
|
+
strictEqual(output.includes("pub enum Status"), true);
|
|
44
|
+
strictEqual(output.includes('#[serde(rename = "active")]'), true);
|
|
45
|
+
strictEqual(output.includes("Active,"), true);
|
|
46
|
+
strictEqual(output.includes("Inactive,"), true);
|
|
47
|
+
strictEqual(output.includes("Pending,"), true);
|
|
48
|
+
});
|
|
49
|
+
it("emits integer enum", async () => {
|
|
50
|
+
const results = await emit(`
|
|
51
|
+
enum Priority {
|
|
52
|
+
low: 1,
|
|
53
|
+
medium: 2,
|
|
54
|
+
high: 3,
|
|
55
|
+
}
|
|
56
|
+
`);
|
|
57
|
+
const output = results["types.rs"];
|
|
58
|
+
strictEqual(output.includes("pub enum Priority"), true);
|
|
59
|
+
strictEqual(output.includes("Low = 1,"), true);
|
|
60
|
+
strictEqual(output.includes("Medium = 2,"), true);
|
|
61
|
+
strictEqual(output.includes("High = 3,"), true);
|
|
62
|
+
});
|
|
63
|
+
it("emits nested models", async () => {
|
|
64
|
+
const results = await emit(`
|
|
65
|
+
model Address {
|
|
66
|
+
street: string;
|
|
67
|
+
city: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
model User {
|
|
71
|
+
name: string;
|
|
72
|
+
address: Address;
|
|
73
|
+
}
|
|
74
|
+
`);
|
|
75
|
+
const output = results["types.rs"];
|
|
76
|
+
strictEqual(output.includes("pub struct Address"), true);
|
|
77
|
+
strictEqual(output.includes("pub struct User"), true);
|
|
78
|
+
strictEqual(output.includes("pub address: Address,"), true);
|
|
79
|
+
});
|
|
80
|
+
it("includes serde derive macros", async () => {
|
|
81
|
+
const results = await emit(`
|
|
82
|
+
model Test {
|
|
83
|
+
value: string;
|
|
84
|
+
}
|
|
85
|
+
`);
|
|
86
|
+
const output = results["types.rs"];
|
|
87
|
+
strictEqual(output.includes("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]"), true);
|
|
88
|
+
});
|
|
89
|
+
it("emits arrays as Vec", async () => {
|
|
90
|
+
const results = await emit(`
|
|
91
|
+
model Test {
|
|
92
|
+
items: string[];
|
|
93
|
+
}
|
|
94
|
+
`);
|
|
95
|
+
const output = results["types.rs"];
|
|
96
|
+
strictEqual(output.includes("pub items: Vec<String>,"), true);
|
|
97
|
+
});
|
|
98
|
+
it("emits maps as HashMap", async () => {
|
|
99
|
+
const results = await emit(`
|
|
100
|
+
model Test {
|
|
101
|
+
metadata: Record<string>;
|
|
102
|
+
}
|
|
103
|
+
`);
|
|
104
|
+
const output = results["types.rs"];
|
|
105
|
+
strictEqual(output.includes("std::collections::HashMap<String, String>"), true);
|
|
106
|
+
});
|
|
107
|
+
it("emits nullable types as Option", async () => {
|
|
108
|
+
const results = await emit(`
|
|
109
|
+
model Test {
|
|
110
|
+
deletedAt: string | null;
|
|
111
|
+
}
|
|
112
|
+
`);
|
|
113
|
+
const output = results["types.rs"];
|
|
114
|
+
strictEqual(output.includes('#[serde(rename = "deletedAt")]'), true);
|
|
115
|
+
strictEqual(output.includes("pub deleted_at: Option<String>,"), true);
|
|
116
|
+
});
|
|
117
|
+
it("emits custom rustDerive macros", async () => {
|
|
118
|
+
const results = await emit(`
|
|
119
|
+
import "typespec-rust-emitter";
|
|
120
|
+
|
|
121
|
+
@rustDerive("sqlx::FromRow")
|
|
122
|
+
model User {
|
|
123
|
+
name: string;
|
|
124
|
+
age: int32;
|
|
125
|
+
}
|
|
126
|
+
`);
|
|
127
|
+
const output = results["types.rs"];
|
|
128
|
+
strictEqual(output.includes("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]"), true);
|
|
129
|
+
});
|
|
130
|
+
it("emits multiple rustDerive macros via $rustDerives", async () => {
|
|
131
|
+
const results = await emit(`
|
|
132
|
+
model Statistics {
|
|
133
|
+
count: int32;
|
|
134
|
+
}
|
|
135
|
+
`);
|
|
136
|
+
const output = results["types.rs"];
|
|
137
|
+
strictEqual(output.includes("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]"), true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
//# sourceMappingURL=hello.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hello.test.js","sourceRoot":"","sources":["../../test/hello.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Diagnostic } from "@typespec/compiler";
|
|
2
|
+
export declare const Tester: import("@typespec/compiler/testing").EmitterTester<import("@typespec/compiler/testing").TestEmitterCompileResult>;
|
|
3
|
+
export declare function emitWithDiagnostics(code: string): Promise<[Record<string, string>, readonly Diagnostic[]]>;
|
|
4
|
+
export declare function emit(code: string): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolvePath } from "@typespec/compiler";
|
|
2
|
+
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
|
|
3
|
+
import { createTester } from "@typespec/compiler/testing";
|
|
4
|
+
export const Tester = createTester(resolvePath(import.meta.dirname, "../.."), {
|
|
5
|
+
libraries: ["typespec-rust-emitter"],
|
|
6
|
+
}).emit("typespec-rust-emitter");
|
|
7
|
+
export async function emitWithDiagnostics(code) {
|
|
8
|
+
const [{ outputs }, diagnostics] = await Tester.compileAndDiagnose(code);
|
|
9
|
+
return [outputs, diagnostics];
|
|
10
|
+
}
|
|
11
|
+
export async function emit(code) {
|
|
12
|
+
const [result, diagnostics] = await emitWithDiagnostics(code);
|
|
13
|
+
expectDiagnosticEmpty(diagnostics);
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=test-host.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-host.js","sourceRoot":"","sources":["../../test/test-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE1D,MAAM,CAAC,MAAM,MAAM,GAAG,YAAY,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE;IAC5E,SAAS,EAAE,CAAC,uBAAuB,CAAC;CACrC,CAAC,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC;AAEjC,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,IAAY;IAEZ,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,WAAW,CAAC,GAAG,MAAM,MAAM,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACzE,OAAO,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAY;IACrC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,mBAAmB,CAAC,IAAI,CAAC,CAAC;IAC9D,qBAAqB,CAAC,WAAW,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import eslint from "@eslint/js";
|
|
3
|
+
import { defineConfig } from "eslint/config";
|
|
4
|
+
import tsEslint from "typescript-eslint";
|
|
5
|
+
|
|
6
|
+
export default defineConfig(
|
|
7
|
+
{
|
|
8
|
+
ignores: ["**/dist/**/*", "**/.temp/**/*"],
|
|
9
|
+
},
|
|
10
|
+
eslint.configs.recommended,
|
|
11
|
+
...tsEslint.configs.recommended,
|
|
12
|
+
{
|
|
13
|
+
rules: {
|
|
14
|
+
"@typescript-eslint/no-unused-vars": [
|
|
15
|
+
"warn",
|
|
16
|
+
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import "typespec-rust-emitter";
|
|
2
|
+
|
|
3
|
+
namespace Mtlkms.Learning.Models {
|
|
4
|
+
// ── COMMON ─────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
@error
|
|
7
|
+
model ApiError {
|
|
8
|
+
@doc("Machine-readable error code.")
|
|
9
|
+
code: string;
|
|
10
|
+
|
|
11
|
+
@doc("Human-readable message.")
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
model NotFoundError extends ApiError {
|
|
16
|
+
code: "NOT_FOUND";
|
|
17
|
+
}
|
|
18
|
+
model ValidationError extends ApiError {
|
|
19
|
+
code: "VALIDATION_ERROR";
|
|
20
|
+
}
|
|
21
|
+
model ConflictError extends ApiError {
|
|
22
|
+
code: "CONFLICT";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── SCALARS & ENUMS ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
@format("uuid")
|
|
28
|
+
scalar Uuid extends string;
|
|
29
|
+
|
|
30
|
+
@pattern("^#[0-9A-Fa-f]{6}$")
|
|
31
|
+
scalar HexColor extends string;
|
|
32
|
+
|
|
33
|
+
enum StudyStatus {
|
|
34
|
+
Starting: "Starting",
|
|
35
|
+
Paused: "Paused",
|
|
36
|
+
Stopped: "Stopped",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
enum ChartGranularity {
|
|
40
|
+
Day: "day",
|
|
41
|
+
Week: "week",
|
|
42
|
+
Month: "month",
|
|
43
|
+
Year: "year",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── AGGREGATION MIXIN ──────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
@doc("Duration statistics calculated in real-time from timelogs. Unit: seconds.")
|
|
49
|
+
model DurationStats {
|
|
50
|
+
durationTotal: int64;
|
|
51
|
+
durationDay: int64;
|
|
52
|
+
durationWeek: int64;
|
|
53
|
+
durationMonth: int64;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── MODELS: GROUP ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
model Group {
|
|
59
|
+
id: int64;
|
|
60
|
+
accountId: Uuid;
|
|
61
|
+
createdAt: utcDateTime;
|
|
62
|
+
deletedAt: utcDateTime | null;
|
|
63
|
+
|
|
64
|
+
@maxLength(255)
|
|
65
|
+
name: string;
|
|
66
|
+
|
|
67
|
+
@maxLength(44)
|
|
68
|
+
icon: string;
|
|
69
|
+
|
|
70
|
+
colorText: HexColor;
|
|
71
|
+
colorBg: HexColor;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@rustDerive("sqlx::FromRow")
|
|
75
|
+
model GroupStatistics {
|
|
76
|
+
...Group;
|
|
77
|
+
subjectCount: int64;
|
|
78
|
+
status: StudyStatus;
|
|
79
|
+
...DurationStats;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
model CreateGroupBody {
|
|
83
|
+
@maxLength(255)
|
|
84
|
+
name: string;
|
|
85
|
+
|
|
86
|
+
@maxLength(44)
|
|
87
|
+
icon: string;
|
|
88
|
+
|
|
89
|
+
colorText: HexColor;
|
|
90
|
+
colorBg: HexColor;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
model UpdateGroupBody {
|
|
94
|
+
@maxLength(255)
|
|
95
|
+
name?: string;
|
|
96
|
+
|
|
97
|
+
@maxLength(44)
|
|
98
|
+
icon?: string;
|
|
99
|
+
|
|
100
|
+
colorText?: HexColor;
|
|
101
|
+
colorBg?: HexColor;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── MODELS: SUBJECT ───────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
model Subject {
|
|
107
|
+
id: int64;
|
|
108
|
+
accountId: Uuid;
|
|
109
|
+
groupId: int64;
|
|
110
|
+
createdAt: utcDateTime;
|
|
111
|
+
deletedAt: utcDateTime | null;
|
|
112
|
+
|
|
113
|
+
@maxLength(255)
|
|
114
|
+
name: string;
|
|
115
|
+
|
|
116
|
+
@maxLength(44)
|
|
117
|
+
icon: string;
|
|
118
|
+
|
|
119
|
+
colorText: HexColor;
|
|
120
|
+
colorBg: HexColor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
model SubjectStatistics {
|
|
124
|
+
...Subject;
|
|
125
|
+
status: StudyStatus;
|
|
126
|
+
...DurationStats;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
model CreateSubjectBody {
|
|
130
|
+
@maxLength(255)
|
|
131
|
+
name: string;
|
|
132
|
+
|
|
133
|
+
@maxLength(44)
|
|
134
|
+
icon: string;
|
|
135
|
+
|
|
136
|
+
colorText: HexColor;
|
|
137
|
+
colorBg: HexColor;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
model UpdateSubjectBody {
|
|
141
|
+
@maxLength(255)
|
|
142
|
+
name?: string;
|
|
143
|
+
|
|
144
|
+
@maxLength(44)
|
|
145
|
+
icon?: string;
|
|
146
|
+
|
|
147
|
+
colorText?: HexColor;
|
|
148
|
+
colorBg?: HexColor;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── MODELS: SESSION ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
model Log {
|
|
154
|
+
id: int64;
|
|
155
|
+
accountId: Uuid;
|
|
156
|
+
subjectId: int64;
|
|
157
|
+
startedAt: utcDateTime;
|
|
158
|
+
stoppedAt: utcDateTime | null;
|
|
159
|
+
logContent: string | null;
|
|
160
|
+
status: StudyStatus;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
model Timelog {
|
|
164
|
+
id: int64;
|
|
165
|
+
accountId: Uuid;
|
|
166
|
+
logId: int64;
|
|
167
|
+
subjectId: int64;
|
|
168
|
+
startedAt: utcDateTime;
|
|
169
|
+
stoppedAt: utcDateTime | null;
|
|
170
|
+
durationInSeconds: int64;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@doc("Response after start/resume — returns the log and the newly opened timelog segment.")
|
|
174
|
+
model SessionOpenedResponse {
|
|
175
|
+
log: Log;
|
|
176
|
+
timelog: Timelog;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@doc("Response after pause/stop — returns the log and the newly closed timelog segment.")
|
|
180
|
+
model SessionClosedResponse {
|
|
181
|
+
log: Log;
|
|
182
|
+
timelog: Timelog;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@doc("Optional payload for start or stop operations — contains a note for the session.")
|
|
186
|
+
model SessionNoteBody {
|
|
187
|
+
logContent?: string;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import "@typespec/http";
|
|
2
|
+
import "./models.tsp";
|
|
3
|
+
|
|
4
|
+
using TypeSpec.Http;
|
|
5
|
+
using Mtlkms.Learning.Models;
|
|
6
|
+
|
|
7
|
+
@route("/learning")
|
|
8
|
+
namespace Mtlkms.Learning;
|
|
9
|
+
|
|
10
|
+
@route("/groups")
|
|
11
|
+
@tag("Groups")
|
|
12
|
+
namespace Groups {
|
|
13
|
+
@get
|
|
14
|
+
@doc("Retrieve a list of all groups for the current account (from view_learning_group).")
|
|
15
|
+
op list(): {
|
|
16
|
+
@statusCode statusCode: 200;
|
|
17
|
+
@body body: GroupStatistics[];
|
|
18
|
+
} | {
|
|
19
|
+
@statusCode statusCode: 401;
|
|
20
|
+
@body error: ApiError;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
@post
|
|
24
|
+
@useAuth(BearerAuth)
|
|
25
|
+
@doc("Create a new group.")
|
|
26
|
+
op create(@body body: CreateGroupBody): {
|
|
27
|
+
@statusCode statusCode: 201;
|
|
28
|
+
@body body: Group;
|
|
29
|
+
} | {
|
|
30
|
+
@statusCode statusCode: 400;
|
|
31
|
+
@body error: ValidationError;
|
|
32
|
+
} | {
|
|
33
|
+
@statusCode statusCode: 401;
|
|
34
|
+
@body error: ApiError;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
@get
|
|
38
|
+
@route("/{id}")
|
|
39
|
+
@doc("Get the details of a group by its ID.")
|
|
40
|
+
op getById(@path id: int64): {
|
|
41
|
+
@statusCode statusCode: 200;
|
|
42
|
+
@body body: GroupStatistics;
|
|
43
|
+
} | {
|
|
44
|
+
@statusCode statusCode: 401;
|
|
45
|
+
@body error: ApiError;
|
|
46
|
+
} | {
|
|
47
|
+
@statusCode statusCode: 404;
|
|
48
|
+
@body error: NotFoundError;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
@patch
|
|
52
|
+
@route("/{id}")
|
|
53
|
+
@useAuth(BearerAuth)
|
|
54
|
+
@doc("Update a group. Only provide the fields that need to be changed.")
|
|
55
|
+
op update(@path id: int64, @body body: UpdateGroupBody):
|
|
56
|
+
| {
|
|
57
|
+
@statusCode statusCode: 200;
|
|
58
|
+
@body body: Group;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
@statusCode statusCode: 400;
|
|
62
|
+
@body error: ValidationError;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
@statusCode statusCode: 401;
|
|
66
|
+
@body error: ApiError;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
@statusCode statusCode: 404;
|
|
70
|
+
@body error: NotFoundError;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
@delete
|
|
74
|
+
@route("/{id}")
|
|
75
|
+
@useAuth(BearerAuth)
|
|
76
|
+
@doc("Soft delete a group. All subjects within the group will also be soft deleted.")
|
|
77
|
+
op delete(@path id: int64): {
|
|
78
|
+
@statusCode statusCode: 204;
|
|
79
|
+
} | {
|
|
80
|
+
@statusCode statusCode: 401;
|
|
81
|
+
@body error: ApiError;
|
|
82
|
+
} | {
|
|
83
|
+
@statusCode statusCode: 404;
|
|
84
|
+
@body error: NotFoundError;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@route("/groups/{groupId}/subjects")
|
|
89
|
+
@tag("Subjects")
|
|
90
|
+
namespace Subjects {
|
|
91
|
+
@get
|
|
92
|
+
@doc("Retrieve a list of subjects for a group (from view_subject_statistics).")
|
|
93
|
+
op list(@path groupId: int64): {
|
|
94
|
+
@statusCode statusCode: 200;
|
|
95
|
+
@body body: SubjectStatistics[];
|
|
96
|
+
} | {
|
|
97
|
+
@statusCode statusCode: 401;
|
|
98
|
+
@body error: ApiError;
|
|
99
|
+
} | {
|
|
100
|
+
@statusCode statusCode: 404;
|
|
101
|
+
|
|
102
|
+
@doc("The group does not exist.")
|
|
103
|
+
@body
|
|
104
|
+
error: NotFoundError;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
@post
|
|
108
|
+
@useAuth(BearerAuth)
|
|
109
|
+
@doc("Create a new subject within the specified group.")
|
|
110
|
+
op create(@path groupId: int64, @body body: CreateSubjectBody):
|
|
111
|
+
| {
|
|
112
|
+
@statusCode statusCode: 201;
|
|
113
|
+
@body body: Subject;
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
@statusCode statusCode: 400;
|
|
117
|
+
@body error: ValidationError;
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
@statusCode statusCode: 401;
|
|
121
|
+
@body error: ApiError;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
@statusCode statusCode: 404;
|
|
125
|
+
@body error: NotFoundError;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
@get
|
|
129
|
+
@route("/{id}")
|
|
130
|
+
@doc("Get the details of a subject by its ID.")
|
|
131
|
+
op getById(@path groupId: int64, @path id: int64): {
|
|
132
|
+
@statusCode statusCode: 200;
|
|
133
|
+
@body body: SubjectStatistics;
|
|
134
|
+
} | {
|
|
135
|
+
@statusCode statusCode: 401;
|
|
136
|
+
@body error: ApiError;
|
|
137
|
+
} | {
|
|
138
|
+
@statusCode statusCode: 404;
|
|
139
|
+
@body error: NotFoundError;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
@patch
|
|
143
|
+
@route("/{id}")
|
|
144
|
+
@useAuth(BearerAuth)
|
|
145
|
+
@doc("Update a subject. Only provide the fields that need to be changed.")
|
|
146
|
+
op update(
|
|
147
|
+
@path groupId: int64,
|
|
148
|
+
@path id: int64,
|
|
149
|
+
@body body: UpdateSubjectBody,
|
|
150
|
+
):
|
|
151
|
+
| {
|
|
152
|
+
@statusCode statusCode: 200;
|
|
153
|
+
@body body: Subject;
|
|
154
|
+
}
|
|
155
|
+
| {
|
|
156
|
+
@statusCode statusCode: 400;
|
|
157
|
+
@body error: ValidationError;
|
|
158
|
+
}
|
|
159
|
+
| {
|
|
160
|
+
@statusCode statusCode: 401;
|
|
161
|
+
@body error: ApiError;
|
|
162
|
+
}
|
|
163
|
+
| {
|
|
164
|
+
@statusCode statusCode: 404;
|
|
165
|
+
@body error: NotFoundError;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
@delete
|
|
169
|
+
@route("/{id}")
|
|
170
|
+
@useAuth(BearerAuth)
|
|
171
|
+
@doc("Soft delete a subject.")
|
|
172
|
+
op delete(@path groupId: int64, @path id: int64): {
|
|
173
|
+
@statusCode statusCode: 204;
|
|
174
|
+
} | {
|
|
175
|
+
@statusCode statusCode: 401;
|
|
176
|
+
@body error: ApiError;
|
|
177
|
+
} | {
|
|
178
|
+
@statusCode statusCode: 404;
|
|
179
|
+
@body error: NotFoundError;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── NAMESPACE: SESSIONS ───────────────────────────────────────────────────
|
|
184
|
+
// State machine:
|
|
185
|
+
//
|
|
186
|
+
// (none) ──start──► Starting ──pause──► Paused
|
|
187
|
+
// ▲ │
|
|
188
|
+
// └────resume─────────┘
|
|
189
|
+
// │
|
|
190
|
+
// stop
|
|
191
|
+
// │
|
|
192
|
+
// ▼
|
|
193
|
+
// Stopped
|
|
194
|
+
//
|
|
195
|
+
// Each start/resume operation opens a new Timelog.
|
|
196
|
+
// Each pause/stop operation closes the current Timelog (sets stoppedAt and calculates durationInSeconds).
|
|
197
|
+
|
|
198
|
+
@route("/subjects/{subjectId}/sessions")
|
|
199
|
+
@tag("Sessions")
|
|
200
|
+
namespace Sessions {
|
|
201
|
+
@post
|
|
202
|
+
@useAuth(BearerAuth)
|
|
203
|
+
@doc("""
|
|
204
|
+
Start a new study session for the subject.
|
|
205
|
+
The server creates a Log (status=Starting) and the first Timelog.
|
|
206
|
+
Returns 409 if the subject already has a session in Starting or Paused status.
|
|
207
|
+
""")
|
|
208
|
+
op start(@path subjectId: int64, @body body: SessionNoteBody):
|
|
209
|
+
| {
|
|
210
|
+
@statusCode statusCode: 201;
|
|
211
|
+
@body body: SessionOpenedResponse;
|
|
212
|
+
}
|
|
213
|
+
| {
|
|
214
|
+
@statusCode statusCode: 401;
|
|
215
|
+
@body error: ApiError;
|
|
216
|
+
}
|
|
217
|
+
| {
|
|
218
|
+
@statusCode statusCode: 404;
|
|
219
|
+
@body error: NotFoundError;
|
|
220
|
+
}
|
|
221
|
+
| {
|
|
222
|
+
@statusCode statusCode: 409;
|
|
223
|
+
|
|
224
|
+
@doc("The subject already has an active session (status is Starting or Paused).")
|
|
225
|
+
@body
|
|
226
|
+
error: ConflictError;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
@post
|
|
230
|
+
@route("/{logId}/pause")
|
|
231
|
+
@useAuth(BearerAuth)
|
|
232
|
+
@doc("""
|
|
233
|
+
Pause the currently running session (transition from Starting to Paused).
|
|
234
|
+
The server closes the current Timelog (sets stoppedAt and calculates durationInSeconds).
|
|
235
|
+
Returns 409 if the session is not in the Starting state.
|
|
236
|
+
""")
|
|
237
|
+
op pause(@path subjectId: int64, @path logId: int64):
|
|
238
|
+
| {
|
|
239
|
+
@statusCode statusCode: 200;
|
|
240
|
+
@body body: SessionClosedResponse;
|
|
241
|
+
}
|
|
242
|
+
| {
|
|
243
|
+
@statusCode statusCode: 401;
|
|
244
|
+
@body error: ApiError;
|
|
245
|
+
}
|
|
246
|
+
| {
|
|
247
|
+
@statusCode statusCode: 404;
|
|
248
|
+
@body error: NotFoundError;
|
|
249
|
+
}
|
|
250
|
+
| {
|
|
251
|
+
@statusCode statusCode: 409;
|
|
252
|
+
|
|
253
|
+
@doc("The session is not in the Starting state.")
|
|
254
|
+
@body
|
|
255
|
+
error: ConflictError;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
@post
|
|
259
|
+
@route("/{logId}/resume")
|
|
260
|
+
@useAuth(BearerAuth)
|
|
261
|
+
@doc("""
|
|
262
|
+
Resume a paused session (transition from Paused to Starting).
|
|
263
|
+
The server creates a new Timelog.
|
|
264
|
+
Returns 409 if the session is not in the Paused state.
|
|
265
|
+
""")
|
|
266
|
+
op resume(@path subjectId: int64, @path logId: int64):
|
|
267
|
+
| {
|
|
268
|
+
@statusCode statusCode: 200;
|
|
269
|
+
@body body: SessionOpenedResponse;
|
|
270
|
+
}
|
|
271
|
+
| {
|
|
272
|
+
@statusCode statusCode: 401;
|
|
273
|
+
@body error: ApiError;
|
|
274
|
+
}
|
|
275
|
+
| {
|
|
276
|
+
@statusCode statusCode: 404;
|
|
277
|
+
@body error: NotFoundError;
|
|
278
|
+
}
|
|
279
|
+
| {
|
|
280
|
+
@statusCode statusCode: 409;
|
|
281
|
+
|
|
282
|
+
@doc("The session is not in the Paused state.")
|
|
283
|
+
@body
|
|
284
|
+
error: ConflictError;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
@post
|
|
288
|
+
@route("/{logId}/stop")
|
|
289
|
+
@useAuth(BearerAuth)
|
|
290
|
+
@doc("""
|
|
291
|
+
End the session (transition from Starting or Paused to Stopped).
|
|
292
|
+
The server closes the current Timelog if it is running, and updates Log.stoppedAt.
|
|
293
|
+
Returns 409 if the session is already Stopped.
|
|
294
|
+
""")
|
|
295
|
+
op stop(
|
|
296
|
+
@path subjectId: int64,
|
|
297
|
+
@path logId: int64,
|
|
298
|
+
@body body: SessionNoteBody,
|
|
299
|
+
):
|
|
300
|
+
| {
|
|
301
|
+
@statusCode statusCode: 200;
|
|
302
|
+
@body body: SessionClosedResponse;
|
|
303
|
+
}
|
|
304
|
+
| {
|
|
305
|
+
@statusCode statusCode: 401;
|
|
306
|
+
@body error: ApiError;
|
|
307
|
+
}
|
|
308
|
+
| {
|
|
309
|
+
@statusCode statusCode: 404;
|
|
310
|
+
@body error: NotFoundError;
|
|
311
|
+
}
|
|
312
|
+
| {
|
|
313
|
+
@statusCode statusCode: 409;
|
|
314
|
+
|
|
315
|
+
@doc("The session is already in the Stopped state.")
|
|
316
|
+
@body
|
|
317
|
+
error: ConflictError;
|
|
318
|
+
};
|
|
319
|
+
}
|