typespec-rust-emitter 0.10.2 → 0.10.5
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/AGENTS.md +65 -0
- package/CHANGELOG.md +24 -0
- package/dist/src/emitter.js +63 -7
- package/dist/src/emitter.js.map +1 -1
- package/dist/test/hello.test.js +67 -0
- package/dist/test/hello.test.js.map +1 -1
- package/dist/test/test-host.js +6 -1
- package/dist/test/test-host.js.map +1 -1
- package/example/lib/learning/models.tsp +43 -0
- package/example/lib/learning/operations.tsp +31 -0
- package/example/output-rust/Cargo.lock +29 -0
- package/example/output-rust/Cargo.toml +1 -0
- package/example/output-rust/src/generated/server.rs +99 -0
- package/example/output-rust/src/generated/types.rs +96 -48
- package/example/package-lock.json +170 -127
- package/example/package.json +3 -1
- package/example/tspconfig.yaml +1 -1
- package/package.json +4 -1
- package/src/emitter.ts +77 -8
- package/test/hello.test.ts +79 -0
- package/test/test-host.ts +6 -1
package/src/emitter.ts
CHANGED
|
@@ -194,6 +194,7 @@ interface ResponseInfo {
|
|
|
194
194
|
statusCode: number;
|
|
195
195
|
bodyType: string | undefined;
|
|
196
196
|
bodyDescription: string | undefined;
|
|
197
|
+
isSse?: boolean;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
function getDecoratorName(decorator: {
|
|
@@ -403,10 +404,21 @@ function getOperationResponses(
|
|
|
403
404
|
statusCode,
|
|
404
405
|
bodyType: bodyInfo.type,
|
|
405
406
|
bodyDescription: bodyInfo.description,
|
|
407
|
+
isSse: bodyInfo.isSse,
|
|
406
408
|
});
|
|
407
409
|
}
|
|
408
410
|
} else if (returnType.kind === "Model") {
|
|
409
411
|
const model = returnType as Model;
|
|
412
|
+
if (model.name === "SSEStream") {
|
|
413
|
+
responses.push({
|
|
414
|
+
statusCode: 200,
|
|
415
|
+
bodyType:
|
|
416
|
+
"axum::response::sse::Sse<std::pin::Pin<Box<dyn futures::stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>> + Send>>>",
|
|
417
|
+
bodyDescription: "Server-Sent Events stream",
|
|
418
|
+
isSse: true,
|
|
419
|
+
});
|
|
420
|
+
return responses;
|
|
421
|
+
}
|
|
410
422
|
for (const [propName, prop] of model.properties) {
|
|
411
423
|
if (propName === "body") {
|
|
412
424
|
const { type: rustType } = getRustTypeForProperty(
|
|
@@ -446,9 +458,20 @@ function getBodyFromResponse(
|
|
|
446
458
|
variant: { type: Type },
|
|
447
459
|
program: Program,
|
|
448
460
|
anonymousEnums: Map<string, AnonymousStringLiteralUnion>,
|
|
449
|
-
): {
|
|
461
|
+
): {
|
|
462
|
+
type: string | undefined;
|
|
463
|
+
description: string | undefined;
|
|
464
|
+
isSse?: boolean;
|
|
465
|
+
} {
|
|
450
466
|
if (variant.type.kind === "Model") {
|
|
451
467
|
const model = variant.type as Model;
|
|
468
|
+
if (model.name === "SSEStream") {
|
|
469
|
+
return {
|
|
470
|
+
type: "axum::response::sse::Sse<std::pin::Pin<Box<dyn futures::stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>> + Send>>>",
|
|
471
|
+
description: "Server-Sent Events stream",
|
|
472
|
+
isSse: true,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
452
475
|
for (const [_propName, prop] of model.properties) {
|
|
453
476
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
454
477
|
const decorators = (prop as any).decorators;
|
|
@@ -639,6 +662,13 @@ pub trait Server: Send + Sync {
|
|
|
639
662
|
}
|
|
640
663
|
}
|
|
641
664
|
|
|
665
|
+
// Add query parameters
|
|
666
|
+
for (const param of opInfo.parameters) {
|
|
667
|
+
if (param.location === "query") {
|
|
668
|
+
paramParts.push(`${param.rustName}: ${param.rustType}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
642
672
|
// Add body parameter
|
|
643
673
|
if (opInfo.body) {
|
|
644
674
|
const bodyType = getRustTypeForProperty(
|
|
@@ -705,11 +735,14 @@ function generateResponseEnums(
|
|
|
705
735
|
const variantName = getStatusVariantName(resp.statusCode);
|
|
706
736
|
if (!resp.bodyType) {
|
|
707
737
|
variants.push(` ${variantName},`);
|
|
738
|
+
} else if (resp.isSse) {
|
|
739
|
+
variants.push(` ${variantName}(${resp.bodyType}),`);
|
|
708
740
|
} else {
|
|
709
741
|
variants.push(` ${variantName}(Json<${resp.bodyType}>),`);
|
|
710
742
|
}
|
|
711
743
|
}
|
|
712
|
-
parts.push(
|
|
744
|
+
parts.push(`#[allow(clippy::type_complexity)]
|
|
745
|
+
pub enum ${responseName} {
|
|
713
746
|
${variants.join("\n")}
|
|
714
747
|
}
|
|
715
748
|
`);
|
|
@@ -725,6 +758,10 @@ ${variants.join("\n")}
|
|
|
725
758
|
parts.push(
|
|
726
759
|
` ${responseName}::${variantName} => ${statusCodeStr}.into_response(),`,
|
|
727
760
|
);
|
|
761
|
+
} else if (resp.isSse) {
|
|
762
|
+
parts.push(
|
|
763
|
+
` ${responseName}::${variantName}(body) => body.into_response(),`,
|
|
764
|
+
);
|
|
728
765
|
} else {
|
|
729
766
|
parts.push(
|
|
730
767
|
` ${responseName}::${variantName}(body) => (${statusCodeStr}, body).into_response(),`,
|
|
@@ -747,6 +784,7 @@ function generateRouter(
|
|
|
747
784
|
anonymousEnums: Map<string, AnonymousStringLiteralUnion>,
|
|
748
785
|
): string {
|
|
749
786
|
const handlers: string[] = [];
|
|
787
|
+
const queryTypeStructs: string[] = [];
|
|
750
788
|
const publicRoutes: string[] = [];
|
|
751
789
|
const protectedRoutes: string[] = [];
|
|
752
790
|
const usedMethods = new Set<string>();
|
|
@@ -772,6 +810,10 @@ function generateRouter(
|
|
|
772
810
|
|
|
773
811
|
const pathParams = opInfo.parameters.filter((p) => p.location === "path");
|
|
774
812
|
const hasPathParams = pathParams.length > 0;
|
|
813
|
+
const queryParams = opInfo.parameters.filter(
|
|
814
|
+
(p) => p.location === "query",
|
|
815
|
+
);
|
|
816
|
+
const hasQueryParams = queryParams.length > 0;
|
|
775
817
|
const hasBody = !!opInfo.body;
|
|
776
818
|
|
|
777
819
|
// Build extractor lines and server method call arguments
|
|
@@ -805,6 +847,21 @@ function generateRouter(
|
|
|
805
847
|
}
|
|
806
848
|
}
|
|
807
849
|
|
|
850
|
+
// Query params come after Path
|
|
851
|
+
if (hasQueryParams && queryParams.length > 0) {
|
|
852
|
+
const queryTypeName = `${toPascalCase(handlerFnName)}Query`;
|
|
853
|
+
const queryFields = queryParams
|
|
854
|
+
.map((p) => ` pub ${p.rustName}: ${p.rustType}`)
|
|
855
|
+
.join(",\n");
|
|
856
|
+
queryTypeStructs.push(
|
|
857
|
+
`#[derive(Debug, Clone, serde::Deserialize)]\npub struct ${queryTypeName} {\n${queryFields}\n}`,
|
|
858
|
+
);
|
|
859
|
+
extractorLines.push(` Query(params): Query<${queryTypeName}>,`);
|
|
860
|
+
for (const param of queryParams) {
|
|
861
|
+
serverArgs.push(`params.${param.rustName}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
808
865
|
// Json body comes last
|
|
809
866
|
if (hasBody && opInfo.body) {
|
|
810
867
|
const bodyType = getRustTypeForProperty(
|
|
@@ -859,10 +916,15 @@ where
|
|
|
859
916
|
const routerBody = buildRouterBody(publicRoutes, protectedRoutes);
|
|
860
917
|
|
|
861
918
|
const parts: string[] = [];
|
|
862
|
-
parts.push(`use axum::
|
|
919
|
+
parts.push(`use axum::extract::Query;
|
|
920
|
+
use axum::routing::{${methodImports}};
|
|
863
921
|
use axum::Router;
|
|
864
922
|
|
|
865
923
|
`);
|
|
924
|
+
if (queryTypeStructs.length > 0) {
|
|
925
|
+
parts.push(queryTypeStructs.join("\n\n"));
|
|
926
|
+
parts.push("\n\n");
|
|
927
|
+
}
|
|
866
928
|
parts.push(handlers.join("\n\n"));
|
|
867
929
|
parts.push(`
|
|
868
930
|
pub fn create_router<S, M>(service: S, middleware: M) -> Router
|
|
@@ -1026,7 +1088,9 @@ function getRustTypeForProperty(
|
|
|
1026
1088
|
};
|
|
1027
1089
|
}
|
|
1028
1090
|
const values = variants.map((v) => (v.type as StringLiteral).value);
|
|
1029
|
-
const
|
|
1091
|
+
const sanitized = values.map((v) => v.replace(/_/g, ""));
|
|
1092
|
+
const firstTwo = sanitized.slice(0, 2).map(toPascalCase).join("");
|
|
1093
|
+
const enumName = `Enum${firstTwo}${sanitized.length}`;
|
|
1030
1094
|
if (!anonymousEnums.has(enumName)) {
|
|
1031
1095
|
anonymousEnums.set(enumName, {
|
|
1032
1096
|
enumName,
|
|
@@ -1179,12 +1243,12 @@ function emitStringLiteralUnion(union: Union): string {
|
|
|
1179
1243
|
const variants = Array.from(union.variants.values());
|
|
1180
1244
|
|
|
1181
1245
|
parts.push(
|
|
1182
|
-
`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\npub enum ${name} {`,
|
|
1246
|
+
`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\n#[allow(clippy::enum_variant_names)]\npub enum ${name} {`,
|
|
1183
1247
|
);
|
|
1184
1248
|
|
|
1185
1249
|
for (let i = 0; i < variants.length; i++) {
|
|
1186
1250
|
const literalType = variants[i].type as StringLiteral;
|
|
1187
|
-
const variantName =
|
|
1251
|
+
const variantName = toPascalCase(literalType.value);
|
|
1188
1252
|
const serdeValue = literalType.value;
|
|
1189
1253
|
if (i === 0) {
|
|
1190
1254
|
parts.push(` #[default]`);
|
|
@@ -1515,11 +1579,11 @@ export async function $onEmit(
|
|
|
1515
1579
|
for (const [enumName, anonEnum] of anonymousEnums) {
|
|
1516
1580
|
const parts: string[] = [];
|
|
1517
1581
|
parts.push(
|
|
1518
|
-
`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\npub enum ${enumName} {`,
|
|
1582
|
+
`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\n#[allow(clippy::enum_variant_names)]\npub enum ${enumName} {`,
|
|
1519
1583
|
);
|
|
1520
1584
|
for (let i = 0; i < anonEnum.variants.length; i++) {
|
|
1521
1585
|
const literal = anonEnum.variants[i];
|
|
1522
|
-
const variantName =
|
|
1586
|
+
const variantName = toPascalCase(literal.value);
|
|
1523
1587
|
if (i === 0) {
|
|
1524
1588
|
parts.push(` #[default]`);
|
|
1525
1589
|
}
|
|
@@ -1571,6 +1635,11 @@ export async function $onEmit(
|
|
|
1571
1635
|
content: outputContent,
|
|
1572
1636
|
});
|
|
1573
1637
|
|
|
1638
|
+
await emitFile(context.program, {
|
|
1639
|
+
path: resolvePath(context.emitterOutputDir, "mod.rs"),
|
|
1640
|
+
content: "pub mod server;\npub mod types;\n",
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1574
1643
|
if (namespaceGroups.length > 0) {
|
|
1575
1644
|
const serverTrait = generateServerTrait(
|
|
1576
1645
|
context.program,
|
package/test/hello.test.ts
CHANGED
|
@@ -50,6 +50,17 @@ describe("Rust emitter", () => {
|
|
|
50
50
|
strictEqual(output.includes("Pending,"), true);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it("emits anonymous string enum without underscores", async () => {
|
|
54
|
+
const results = await emit(`
|
|
55
|
+
model Event {
|
|
56
|
+
eventType: "event_one" | "event_two" | "event_three",
|
|
57
|
+
}
|
|
58
|
+
`);
|
|
59
|
+
const output = results["types.rs"];
|
|
60
|
+
const badNameContainsUnderscore = output.includes("Enum_event_one_");
|
|
61
|
+
strictEqual(badNameContainsUnderscore, false);
|
|
62
|
+
});
|
|
63
|
+
|
|
53
64
|
it("emits integer enum", async () => {
|
|
54
65
|
const results = await emit(`
|
|
55
66
|
enum Priority {
|
|
@@ -369,4 +380,72 @@ describe("Rust emitter", () => {
|
|
|
369
380
|
const output = results["types.rs"];
|
|
370
381
|
strictEqual(output.includes("impl IntoResponse for ApiError"), false);
|
|
371
382
|
});
|
|
383
|
+
|
|
384
|
+
it("emits query parameters in handlers", async () => {
|
|
385
|
+
const results = await emit(`
|
|
386
|
+
import "@typespec/http";
|
|
387
|
+
using TypeSpec.Http;
|
|
388
|
+
|
|
389
|
+
model CalendarItem {
|
|
390
|
+
date: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
@route("/calendar/{accountId}")
|
|
394
|
+
namespace Calendar {
|
|
395
|
+
@get
|
|
396
|
+
@tag("Calendar")
|
|
397
|
+
op getCalendar(
|
|
398
|
+
@path accountId: string,
|
|
399
|
+
@query year: int32,
|
|
400
|
+
@query month: int32,
|
|
401
|
+
): {
|
|
402
|
+
@statusCode statusCode: 200;
|
|
403
|
+
@body body: CalendarItem[];
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
`);
|
|
407
|
+
const server = results["server.rs"];
|
|
408
|
+
strictEqual(server.includes("Query(params): Query"), true);
|
|
409
|
+
strictEqual(server.includes("pub year: i32"), true);
|
|
410
|
+
strictEqual(server.includes("pub month: i32"), true);
|
|
411
|
+
strictEqual(server.includes("params.year"), true);
|
|
412
|
+
strictEqual(server.includes("params.month"), true);
|
|
413
|
+
});
|
|
414
|
+
it("emits SSEStream properly", async () => {
|
|
415
|
+
const results = await emit(`
|
|
416
|
+
import "@typespec/http";
|
|
417
|
+
import "@typespec/sse";
|
|
418
|
+
import "@typespec/events";
|
|
419
|
+
using TypeSpec.Http;
|
|
420
|
+
using TypeSpec.SSE;
|
|
421
|
+
|
|
422
|
+
model TestEventData {
|
|
423
|
+
data: string;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@TypeSpec.Events.events
|
|
427
|
+
union TestEvent {
|
|
428
|
+
data: TestEventData
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
@route("/events")
|
|
432
|
+
namespace Events {
|
|
433
|
+
@get
|
|
434
|
+
op stream(): SSEStream<TestEvent>;
|
|
435
|
+
}
|
|
436
|
+
`);
|
|
437
|
+
const server = results["server.rs"];
|
|
438
|
+
strictEqual(
|
|
439
|
+
server.includes(
|
|
440
|
+
"axum::response::sse::Sse<std::pin::Pin<Box<dyn futures::stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>> + Send>>>",
|
|
441
|
+
),
|
|
442
|
+
true,
|
|
443
|
+
);
|
|
444
|
+
strictEqual(
|
|
445
|
+
server.includes(
|
|
446
|
+
"EventsStreamResponse::Ok(body) => body.into_response(),",
|
|
447
|
+
),
|
|
448
|
+
true,
|
|
449
|
+
);
|
|
450
|
+
});
|
|
372
451
|
});
|
package/test/test-host.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
|
|
|
3
3
|
import { createTester } from "@typespec/compiler/testing";
|
|
4
4
|
|
|
5
5
|
export const Tester = createTester(resolvePath(import.meta.dirname, "../.."), {
|
|
6
|
-
libraries: [
|
|
6
|
+
libraries: [
|
|
7
|
+
"@typespec/http",
|
|
8
|
+
"@typespec/sse",
|
|
9
|
+
"@typespec/events",
|
|
10
|
+
"typespec-rust-emitter",
|
|
11
|
+
],
|
|
7
12
|
}).emit("typespec-rust-emitter");
|
|
8
13
|
|
|
9
14
|
export async function emitWithDiagnostics(
|