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.
Files changed (43) hide show
  1. package/README.md +220 -0
  2. package/dist/src/emitter.d.ts +7 -0
  3. package/dist/src/emitter.js +490 -0
  4. package/dist/src/emitter.js.map +1 -0
  5. package/dist/src/index.d.ts +3 -0
  6. package/dist/src/index.js +4 -0
  7. package/dist/src/index.js.map +1 -0
  8. package/dist/src/lib.d.ts +12 -0
  9. package/dist/src/lib.js +7 -0
  10. package/dist/src/lib.js.map +1 -0
  11. package/dist/src/testing/index.d.ts +2 -0
  12. package/dist/src/testing/index.js +8 -0
  13. package/dist/src/testing/index.js.map +1 -0
  14. package/dist/test/hello.test.d.ts +1 -0
  15. package/dist/test/hello.test.js +140 -0
  16. package/dist/test/hello.test.js.map +1 -0
  17. package/dist/test/test-host.d.ts +4 -0
  18. package/dist/test/test-host.js +16 -0
  19. package/dist/test/test-host.js.map +1 -0
  20. package/eslint.config.js +20 -0
  21. package/example/lib/learning/models.tsp +189 -0
  22. package/example/lib/learning/operations.tsp +319 -0
  23. package/example/main.tsp +8 -0
  24. package/example/output-rust/Cargo.lock +1731 -0
  25. package/example/output-rust/Cargo.toml +12 -0
  26. package/example/output-rust/src/generated/mod.rs +1 -0
  27. package/example/output-rust/src/generated/types.rs +315 -0
  28. package/example/output-rust/src/main.rs +5 -0
  29. package/example/output-rust/src/mod.rs +1 -0
  30. package/example/package-lock.json +1495 -0
  31. package/example/package.json +15 -0
  32. package/example/tspconfig.yaml +10 -0
  33. package/justfile +15 -0
  34. package/package.json +64 -0
  35. package/prettierrc.yaml +8 -0
  36. package/src/emitter.ts +685 -0
  37. package/src/index.ts +3 -0
  38. package/src/lib.ts +8 -0
  39. package/src/lib.tsp +6 -0
  40. package/src/testing/index.ts +8 -0
  41. package/test/hello.test.ts +168 -0
  42. package/test/test-host.ts +20 -0
  43. 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"}
@@ -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
+ }