typespec-rust-emitter 0.10.6 → 0.11.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/src/emitter.ts CHANGED
@@ -39,6 +39,9 @@ interface RustAttrInfo {
39
39
  const rustDeriveKey = Symbol("rustDerive");
40
40
  const rustAttrKey = Symbol("rustAttr");
41
41
  const rustImplKey = Symbol("rustImpl");
42
+ const rustSelfReceiverKey = Symbol("rustSelfReceiver");
43
+
44
+ type SelfReceiver = "&self" | "&mut self" | "self";
42
45
 
43
46
  interface RustImplInfo {
44
47
  impl: string;
@@ -168,6 +171,42 @@ export function $rustImpl(
168
171
  }
169
172
  }
170
173
 
174
+ export function $rustMut(context: DecoratorContext, target: Type) {
175
+ if (target.kind !== "Operation") {
176
+ context.program.reportDiagnostic({
177
+ code: "rust-mut-invalid-target",
178
+ message: `@rustMut can only be applied to operations`,
179
+ severity: "error",
180
+ target: context.decoratorTarget,
181
+ });
182
+ return;
183
+ }
184
+
185
+ const ns = target.namespace ? getNamespaceFullName(target.namespace) : "";
186
+ if (!ns.startsWith("TypeSpec")) {
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ (target as any)[rustSelfReceiverKey] = "&mut self";
189
+ }
190
+ }
191
+
192
+ export function $rustOwn(context: DecoratorContext, target: Type) {
193
+ if (target.kind !== "Operation") {
194
+ context.program.reportDiagnostic({
195
+ code: "rust-own-invalid-target",
196
+ message: `@rustOwn can only be applied to operations`,
197
+ severity: "error",
198
+ target: context.decoratorTarget,
199
+ });
200
+ return;
201
+ }
202
+
203
+ const ns = target.namespace ? getNamespaceFullName(target.namespace) : "";
204
+ if (!ns.startsWith("TypeSpec")) {
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ (target as any)[rustSelfReceiverKey] = "self";
207
+ }
208
+ }
209
+
171
210
  type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
172
211
 
173
212
  interface OperationInfo {
@@ -298,6 +337,14 @@ function hasAuthDecorator(operation: Operation): boolean {
298
337
  return false;
299
338
  }
300
339
 
340
+ function getSelfReceiver(operation: Operation): SelfReceiver {
341
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
342
+ const receiver = (operation as any)[rustSelfReceiverKey] as
343
+ | SelfReceiver
344
+ | undefined;
345
+ return receiver ?? "&self";
346
+ }
347
+
301
348
  function getOperationParameters(
302
349
  program: Program,
303
350
  operation: Operation,
@@ -393,7 +440,28 @@ function getOperationResponses(
393
440
  anonymousEnums: Map<string, AnonymousStringLiteralUnion>,
394
441
  ): ResponseInfo[] {
395
442
  const responses: ResponseInfo[] = [];
396
- const returnType = operation.returnType;
443
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
444
+ const returnType = operation.returnType as any;
445
+
446
+ // Check for Array return type first (use runtime check since TS doesn't narrow Array)
447
+ if (
448
+ returnType.kind !== "Union" &&
449
+ returnType.kind !== "Model" &&
450
+ returnType.valueType
451
+ ) {
452
+ // This is an Array type
453
+ const { type: rustType } = getRustTypeForProperty(
454
+ returnType.valueType,
455
+ program,
456
+ anonymousEnums,
457
+ );
458
+ responses.push({
459
+ statusCode: 200,
460
+ bodyType: rustType,
461
+ bodyDescription: "",
462
+ });
463
+ return responses;
464
+ }
397
465
 
398
466
  if (returnType.kind === "Union") {
399
467
  const union = returnType as Union;
@@ -418,6 +486,7 @@ function getOperationResponses(
418
486
  });
419
487
  return responses;
420
488
  }
489
+ let foundStatusCode = false;
421
490
  for (const [propName, prop] of model.properties) {
422
491
  if (propName === "body") {
423
492
  const { type: rustType } = getRustTypeForProperty(
@@ -430,8 +499,27 @@ function getOperationResponses(
430
499
  bodyType: rustType,
431
500
  bodyDescription: getDoc(program, prop),
432
501
  });
502
+ } else if (propName === "statusCode") {
503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
+ const typeAny = prop.type as any;
505
+ if (typeAny.value !== undefined) {
506
+ const statusCode = typeAny.value as number;
507
+ responses.push({
508
+ statusCode,
509
+ bodyType: undefined,
510
+ bodyDescription: undefined,
511
+ });
512
+ foundStatusCode = true;
513
+ }
433
514
  }
434
515
  }
516
+ if (!foundStatusCode && responses.length === 0) {
517
+ responses.push({
518
+ statusCode: 200,
519
+ bodyType: undefined,
520
+ bodyDescription: undefined,
521
+ });
522
+ }
435
523
  }
436
524
 
437
525
  return responses;
@@ -679,25 +767,26 @@ pub trait Server: Send + Sync {
679
767
  }
680
768
 
681
769
  const paramsStr = paramParts.join(", ");
770
+ const selfReceiver = getSelfReceiver(op);
682
771
 
683
772
  if (isProtected) {
684
773
  if (paramsStr) {
685
774
  parts.push(
686
- ` async fn ${fnName}(&self, claims: Self::Claims, ${paramsStr}) -> Result<${responseName}>;`,
775
+ ` async fn ${fnName}(${selfReceiver}, claims: Self::Claims, ${paramsStr}) -> Result<${responseName}>;`,
687
776
  );
688
777
  } else {
689
778
  parts.push(
690
- ` async fn ${fnName}(&self, claims: Self::Claims) -> Result<${responseName}>;`,
779
+ ` async fn ${fnName}(${selfReceiver}, claims: Self::Claims) -> Result<${responseName}>;`,
691
780
  );
692
781
  }
693
782
  } else {
694
783
  if (paramsStr) {
695
784
  parts.push(
696
- ` async fn ${fnName}(&self, ${paramsStr}) -> Result<${responseName}>;`,
785
+ ` async fn ${fnName}(${selfReceiver}, ${paramsStr}) -> Result<${responseName}>;`,
697
786
  );
698
787
  } else {
699
788
  parts.push(
700
- ` async fn ${fnName}(&self) -> Result<${responseName}>;`,
789
+ ` async fn ${fnName}(${selfReceiver}) -> Result<${responseName}>;`,
701
790
  );
702
791
  }
703
792
  }
@@ -806,6 +895,7 @@ function generateRouter(
806
895
  const handlerFnName = toRustIdent(`${nsName}_${opInfo.name}`);
807
896
  const traitFnName = handlerFnName;
808
897
  const isProtected = hasAuthDecorator(op);
898
+ const selfReceiver = getSelfReceiver(op);
809
899
 
810
900
  const pathParams = opInfo.parameters.filter((p) => p.location === "path");
811
901
  const hasPathParams = pathParams.length > 0;
@@ -822,6 +912,8 @@ function generateRouter(
822
912
  const serverArgs: string[] = [];
823
913
 
824
914
  // State is always first (added in handler template)
915
+ const serviceBinding =
916
+ selfReceiver === "&mut self" ? "mut service" : "service";
825
917
 
826
918
  // Extension (claims) comes after State
827
919
  if (isProtected) {
@@ -850,7 +942,10 @@ function generateRouter(
850
942
  if (hasQueryParams && queryParams.length > 0) {
851
943
  const queryTypeName = `${toPascalCase(handlerFnName)}Query`;
852
944
  const queryFields = queryParams
853
- .map((p) => ` pub ${p.rustName}: ${p.rustType}`)
945
+ .map(
946
+ (p) =>
947
+ ` #[serde(rename = "${p.name}")]\n pub ${p.rustName}: ${p.rustType}`,
948
+ )
854
949
  .join(",\n");
855
950
  queryTypeStructs.push(
856
951
  `#[derive(Debug, Clone, serde::Deserialize)]\npub struct ${queryTypeName} {\n${queryFields}\n}`,
@@ -877,12 +972,37 @@ function generateRouter(
877
972
  const serverCall = `service.${traitFnName}(${serverArgsStr}).await`;
878
973
 
879
974
  // All handlers use <S> generics, Claims is now an associated type
880
- const handlerCode = `pub async fn ${handlerFnName}_handler<S>(
975
+ // For &mut self, we need Clone because service is extracted multiple times
976
+ // For self, we can't use Clone (would need Arc/Mutex or different pattern)
977
+ const needsClone = selfReceiver !== "self" ? "+ Clone" : "";
978
+ const handlerCode =
979
+ selfReceiver === "self"
980
+ ? `// NOTE: ${handlerFnName} takes self and cannot be used with the router pattern.
981
+ // It consumes the service, so you need to implement your own handler pattern.
982
+ pub async fn ${handlerFnName}_handler<S>(
881
983
  axum::extract::State(service): axum::extract::State<S>,
882
984
  ${extractorLines.join("\n")}
883
985
  ) -> impl axum::response::IntoResponse
884
986
  where
885
- S: Server + Clone + Send + Sync + 'static,
987
+ S: Server + Send + Sync + 'static,
988
+ S::Claims: Send + Sync + Clone + 'static,
989
+ {
990
+ let result = service.${traitFnName}(${serverArgsStr}).await;
991
+ match result {
992
+ Ok(response) => response.into_response(),
993
+ Err(e) => (
994
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
995
+ format!("Internal error: {e}"),
996
+ )
997
+ .into_response(),
998
+ }
999
+ }`
1000
+ : `pub async fn ${handlerFnName}_handler<S>(
1001
+ axum::extract::State(${serviceBinding}): axum::extract::State<S>,
1002
+ ${extractorLines.join("\n")}
1003
+ ) -> impl axum::response::IntoResponse
1004
+ where
1005
+ S: Server${needsClone} + Send + Sync + 'static,
886
1006
  S::Claims: Send + Sync + Clone + 'static,
887
1007
  {
888
1008
  let result = ${serverCall};
@@ -898,6 +1018,11 @@ where
898
1018
 
899
1019
  handlers.push(handlerCode);
900
1020
 
1021
+ // Don't add routes for self methods (they consume the service)
1022
+ if (selfReceiver === "self") {
1023
+ continue;
1024
+ }
1025
+
901
1026
  const routePath = `"${opInfo.path}"`;
902
1027
  let routeStmt = "";
903
1028
  if (isProtected) {
package/src/index.ts CHANGED
@@ -5,5 +5,7 @@ export {
5
5
  $rustAttr,
6
6
  $rustAttrs,
7
7
  $rustImpl,
8
+ $rustMut,
9
+ $rustOwn,
8
10
  } from "./emitter.js";
9
11
  export { $lib } from "./lib.js";
package/src/lib.tsp CHANGED
@@ -7,3 +7,5 @@ extern dec rustDerives(target: Model | Enum, ...derives: valueof string[]);
7
7
  extern dec rustAttr(target: Model | Enum | ModelProperty, attr: valueof string);
8
8
  extern dec rustAttrs(target: Model | Enum | ModelProperty, ...attrs: valueof string[]);
9
9
  extern dec rustImpl(target: Model, impl: valueof string);
10
+ extern dec rustMut(target: Operation);
11
+ extern dec rustOwn(target: Operation);
@@ -1,6 +1,6 @@
1
1
  import { strictEqual } from "node:assert";
2
2
  import { describe, it } from "node:test";
3
- import { emit } from "./test-host.js";
3
+ import { emit, emitWithDiagnostics } from "./test-host.js";
4
4
 
5
5
  describe("Rust emitter", () => {
6
6
  it("emits basic model", async () => {
@@ -443,4 +443,122 @@ describe("Rust emitter", () => {
443
443
  true,
444
444
  );
445
445
  });
446
+
447
+ it("uses &self by default in trait methods", async () => {
448
+ const results = await emit(`
449
+ import "@typespec/http";
450
+ import "typespec-rust-emitter";
451
+ using TypeSpec.Http;
452
+
453
+ @route("/test")
454
+ namespace Test {
455
+ @get
456
+ op getItem(): { @statusCode status: 200; @body body: string };
457
+ }
458
+ `);
459
+ const server = results["server.rs"];
460
+ strictEqual(server.includes("async fn test_get_item(&self)"), true);
461
+ });
462
+
463
+ it("uses &mut self with @rustMut decorator", async () => {
464
+ const results = await emit(`
465
+ import "@typespec/http";
466
+ import "typespec-rust-emitter";
467
+ using TypeSpec.Http;
468
+
469
+ @route("/test")
470
+ namespace Test {
471
+ @rustMut
472
+ @post
473
+ op createItem(@body name: string): { @statusCode status: 200; @body body: string };
474
+ }
475
+ `);
476
+ const server = results["server.rs"];
477
+ strictEqual(server.includes("test_create_item(&mut self,"), true);
478
+ });
479
+
480
+ it("uses self with @rustOwn decorator", async () => {
481
+ const results = await emit(`
482
+ import "@typespec/http";
483
+ import "typespec-rust-emitter";
484
+ using TypeSpec.Http;
485
+
486
+ @route("/test")
487
+ namespace Test {
488
+ @rustOwn
489
+ @delete
490
+ op deleteItem(): { @statusCode status: 200; @body body: string };
491
+ }
492
+ `);
493
+ const server = results["server.rs"];
494
+ strictEqual(server.includes("test_delete_item(self)"), true);
495
+ });
496
+
497
+ it("@rustMut works with protected routes", async () => {
498
+ const results = await emit(`
499
+ import "@typespec/http";
500
+ import "typespec-rust-emitter";
501
+ using TypeSpec.Http;
502
+
503
+ @route("/test")
504
+ namespace Test {
505
+ @rustMut
506
+ @post
507
+ op createItem(@body name: string, @header Authorization: string): { @statusCode status: 200; @body body: string };
508
+ }
509
+ `);
510
+ const server = results["server.rs"];
511
+ strictEqual(server.includes("test_create_item(&mut self,"), true);
512
+ });
513
+
514
+ it("@rustOwn works with protected routes", async () => {
515
+ const results = await emit(`
516
+ import "@typespec/http";
517
+ import "typespec-rust-emitter";
518
+ using TypeSpec.Http;
519
+
520
+ @route("/test")
521
+ namespace Test {
522
+ @rustOwn
523
+ @delete
524
+ op deleteItem(@query id: string): { @statusCode status: 200; @body body: string };
525
+ }
526
+ `);
527
+ const server = results["server.rs"];
528
+ strictEqual(server.includes("test_delete_item(self,"), true);
529
+ });
530
+
531
+ it("reports error when @rustMut is applied to non-operation", async () => {
532
+ const [, diagnostics] = await emitWithDiagnostics(`
533
+ import "@typespec/http";
534
+ import "typespec-rust-emitter";
535
+ using TypeSpec.Http;
536
+
537
+ @rustMut
538
+ model Test {
539
+ name: string;
540
+ }
541
+ `);
542
+ const hasError = diagnostics.some(
543
+ (d) => d.code === "decorator-wrong-target",
544
+ );
545
+ strictEqual(hasError, true);
546
+ });
547
+
548
+ it("reports error when @rustOwn is applied to non-operation", async () => {
549
+ const [, diagnostics] = await emitWithDiagnostics(`
550
+ import "@typespec/http";
551
+ import "typespec-rust-emitter";
552
+ using TypeSpec.Http;
553
+
554
+ @rustOwn
555
+ model Test {
556
+ name: string;
557
+ }
558
+ `);
559
+ const hasError = diagnostics.some(
560
+ (d) => d.code === "decorator-wrong-target",
561
+ );
562
+ strictEqual(hasError, true);
563
+ });
446
564
  });
@@ -1,234 +0,0 @@
1
- import "typespec-rust-emitter";
2
- import "@typespec/events";
3
-
4
- using TypeSpec.Events;
5
-
6
- namespace Mtlkms.Learning.Models {
7
- // ── COMMON ─────────────────────────────────────────────────────────────────
8
-
9
- @error
10
- model ApiError {
11
- @doc("Machine-readable error code.")
12
- code: string;
13
-
14
- @doc("Human-readable message.")
15
- message: string;
16
- }
17
-
18
- model NotFoundError extends ApiError {
19
- code: "NOT_FOUND";
20
- }
21
- model ValidationError extends ApiError {
22
- code: "VALIDATION_ERROR";
23
- }
24
- model ConflictError extends ApiError {
25
- code: "CONFLICT";
26
- }
27
-
28
- // ── SCALARS & ENUMS ───────────────────────────────────────────────────────
29
-
30
- @format("uuid")
31
- scalar Uuid extends string;
32
-
33
- @pattern("^#[0-9A-Fa-f]{6}$")
34
- scalar HexColor extends string;
35
-
36
- @rustDerive("sqlx::Type")
37
- @rustAttr("sqlx(type_name = \"study_status\")")
38
- enum StudyStatus {
39
- Starting: "Starting",
40
- Paused: "Paused",
41
- Stopped: "Stopped",
42
- }
43
-
44
- enum ChartGranularity {
45
- Day: "day",
46
- Week: "week",
47
- Month: "month",
48
- Year: "year",
49
- }
50
-
51
- // ── AGGREGATION MIXIN ──────────────────────────────────────────────────────
52
-
53
- @doc("Duration statistics calculated in real-time from timelogs. Unit: seconds.")
54
- model DurationStats {
55
- durationTotal: int64;
56
- durationDay: int64;
57
- durationWeek: int64;
58
- durationMonth: int64;
59
- }
60
-
61
- model CalendarHeatmapItem {
62
- date: string;
63
- duration: int64;
64
- }
65
-
66
- // ── MODELS: GROUP ─────────────────────────────────────────────────────────
67
-
68
- model Group {
69
- id: int64;
70
- accountId: Uuid;
71
- createdAt: utcDateTime;
72
- deletedAt: utcDateTime | null;
73
-
74
- @maxLength(255)
75
- name: string;
76
-
77
- @maxLength(44)
78
- icon: string;
79
-
80
- colorText: HexColor;
81
- colorBg: HexColor;
82
- }
83
-
84
- @rustDerive("sqlx::FromRow")
85
- model GroupStatistics {
86
- ...Group;
87
- subjectCount: int64;
88
- status: StudyStatus;
89
- ...DurationStats;
90
- }
91
-
92
- model CreateGroupBody {
93
- @maxLength(255)
94
- name: string;
95
-
96
- @maxLength(44)
97
- icon: string;
98
-
99
- colorText: HexColor;
100
- colorBg: HexColor;
101
- }
102
-
103
- model UpdateGroupBody {
104
- @maxLength(255)
105
- name?: string;
106
-
107
- @maxLength(44)
108
- icon?: string;
109
-
110
- colorText?: HexColor;
111
- colorBg?: HexColor;
112
- }
113
-
114
- // ── MODELS: SUBJECT ───────────────────────────────────────────────────────
115
-
116
- model Subject {
117
- id: int64;
118
- accountId: Uuid;
119
- groupId: int64;
120
- createdAt: utcDateTime;
121
- deletedAt: utcDateTime | null;
122
-
123
- @maxLength(255)
124
- name: string;
125
-
126
- @maxLength(44)
127
- icon: string;
128
-
129
- colorText: HexColor;
130
- colorBg: HexColor;
131
- }
132
-
133
- model SubjectStatistics {
134
- ...Subject;
135
- status: StudyStatus;
136
- ...DurationStats;
137
- }
138
-
139
- model CreateSubjectBody {
140
- @maxLength(255)
141
- name: string;
142
-
143
- @maxLength(44)
144
- icon: string;
145
-
146
- colorText: HexColor;
147
- colorBg: HexColor;
148
- }
149
-
150
- model UpdateSubjectBody {
151
- @maxLength(255)
152
- name?: string;
153
-
154
- @maxLength(44)
155
- icon?: string;
156
-
157
- colorText?: HexColor;
158
- colorBg?: HexColor;
159
- }
160
-
161
- // ── MODELS: SESSION ───────────────────────────────────────────────────────
162
-
163
- model Log {
164
- id: int64;
165
- accountId: Uuid;
166
- subjectId: int64;
167
- startedAt: utcDateTime;
168
- stoppedAt: utcDateTime | null;
169
- logContent: string | null;
170
- status: StudyStatus;
171
- }
172
-
173
- model Timelog {
174
- id: int64;
175
- accountId: Uuid;
176
- logId: int64;
177
- subjectId: int64;
178
- startedAt: utcDateTime;
179
- stoppedAt: utcDateTime | null;
180
- durationInSeconds: int64;
181
- }
182
-
183
- model LogWithTimelogs {
184
- ...Log;
185
- timelogs: Timelog[];
186
- }
187
-
188
- @doc("Event payload for learning data changes.")
189
- model SessionEvent {
190
- eventType: "session" | "session_pause" | "session_resume" | "session_stop";
191
- session: LogWithTimelogs;
192
- }
193
-
194
- @doc("Event payload for group CRUD.")
195
- model GroupEvent {
196
- eventType: "group_created" | "group_updated" | "group_deleted";
197
- group: Group;
198
- }
199
-
200
- @doc("Event payload for subject CRUD.")
201
- model SubjectEvent {
202
- eventType:
203
- | "subject_created"
204
- | "subject_updated"
205
- | "subject_deleted"
206
- | "subject_moved";
207
- subject: Subject;
208
- }
209
-
210
- @doc("Union of all learning events for SSE.")
211
- @events
212
- union LearningEvents {
213
- sessionEvent: SessionEvent,
214
- groupEvent: GroupEvent,
215
- subjectEvent: SubjectEvent,
216
- }
217
-
218
- @doc("Response after start/resume — returns the log and the newly opened timelog segment.")
219
- model SessionOpenedResponse {
220
- log: Log;
221
- timelog: Timelog;
222
- }
223
-
224
- @doc("Response after pause/stop — returns the log and the newly closed timelog segment.")
225
- model SessionClosedResponse {
226
- log: Log;
227
- timelog: Timelog;
228
- }
229
-
230
- @doc("Optional payload for start or stop operations — contains a note for the session.")
231
- model SessionNoteBody {
232
- logContent?: string;
233
- }
234
- }