typespec-rust-emitter 0.12.0 → 0.13.1

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 (138) hide show
  1. package/AGENTS.md +82 -80
  2. package/CHANGELOG.md +24 -0
  3. package/dist/src/decorators/cache_control.d.ts +6 -0
  4. package/dist/src/decorators/cache_control.js +9 -0
  5. package/dist/src/decorators/cache_control.js.map +1 -0
  6. package/dist/src/decorators/etag_cache.d.ts +6 -0
  7. package/dist/src/decorators/etag_cache.js +9 -0
  8. package/dist/src/decorators/etag_cache.js.map +1 -0
  9. package/dist/src/decorators/index.d.ts +6 -0
  10. package/dist/src/decorators/index.js +7 -0
  11. package/dist/src/decorators/index.js.map +1 -0
  12. package/dist/src/decorators/rust_attr.d.ts +3 -0
  13. package/dist/src/decorators/rust_attr.js +45 -0
  14. package/dist/src/decorators/rust_attr.js.map +1 -0
  15. package/dist/src/decorators/rust_derive.d.ts +3 -0
  16. package/dist/src/decorators/rust_derive.js +39 -0
  17. package/dist/src/decorators/rust_derive.js.map +1 -0
  18. package/dist/src/decorators/rust_impl.d.ts +2 -0
  19. package/dist/src/decorators/rust_impl.js +19 -0
  20. package/dist/src/decorators/rust_impl.js.map +1 -0
  21. package/dist/src/decorators/rust_self.d.ts +3 -0
  22. package/dist/src/decorators/rust_self.js +35 -0
  23. package/dist/src/decorators/rust_self.js.map +1 -0
  24. package/dist/src/emitter.d.ts +2 -11
  25. package/dist/src/emitter.js +7 -1282
  26. package/dist/src/emitter.js.map +1 -1
  27. package/dist/src/formatter/index.d.ts +2 -0
  28. package/dist/src/formatter/index.js +3 -0
  29. package/dist/src/formatter/index.js.map +1 -0
  30. package/dist/src/formatter/mappings.d.ts +4 -0
  31. package/dist/src/formatter/mappings.js +68 -0
  32. package/dist/src/formatter/mappings.js.map +1 -0
  33. package/dist/src/formatter/strings.d.ts +4 -0
  34. package/dist/src/formatter/strings.js +32 -0
  35. package/dist/src/formatter/strings.js.map +1 -0
  36. package/dist/src/generator/etag_router.d.ts +28 -0
  37. package/dist/src/generator/etag_router.js +76 -0
  38. package/dist/src/generator/etag_router.js.map +1 -0
  39. package/dist/src/generator/index.d.ts +5 -0
  40. package/dist/src/generator/index.js +6 -0
  41. package/dist/src/generator/index.js.map +1 -0
  42. package/dist/src/generator/response_enums.d.ts +6 -0
  43. package/dist/src/generator/response_enums.js +58 -0
  44. package/dist/src/generator/response_enums.js.map +1 -0
  45. package/dist/src/generator/router.d.ts +7 -0
  46. package/dist/src/generator/router.js +231 -0
  47. package/dist/src/generator/router.js.map +1 -0
  48. package/dist/src/generator/server_trait.d.ts +6 -0
  49. package/dist/src/generator/server_trait.js +97 -0
  50. package/dist/src/generator/server_trait.js.map +1 -0
  51. package/dist/src/generator/types_file.d.ts +11 -0
  52. package/dist/src/generator/types_file.js +209 -0
  53. package/dist/src/generator/types_file.js.map +1 -0
  54. package/dist/src/index.d.ts +1 -1
  55. package/dist/src/index.js +1 -1
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/lib.js +1 -1
  58. package/dist/src/lib.js.map +1 -1
  59. package/dist/src/models/index.d.ts +2 -0
  60. package/dist/src/models/index.js +3 -0
  61. package/dist/src/models/index.js.map +1 -0
  62. package/dist/src/models/keys.d.ts +6 -0
  63. package/dist/src/models/keys.js +8 -0
  64. package/dist/src/models/keys.js.map +1 -0
  65. package/dist/src/models/types.d.ts +45 -0
  66. package/dist/src/models/types.js +2 -0
  67. package/dist/src/models/types.js.map +1 -0
  68. package/dist/src/parser/decorators.d.ts +18 -0
  69. package/dist/src/parser/decorators.js +28 -0
  70. package/dist/src/parser/decorators.js.map +1 -0
  71. package/dist/src/parser/index.d.ts +6 -0
  72. package/dist/src/parser/index.js +7 -0
  73. package/dist/src/parser/index.js.map +1 -0
  74. package/dist/src/parser/operations.d.ts +13 -0
  75. package/dist/src/parser/operations.js +127 -0
  76. package/dist/src/parser/operations.js.map +1 -0
  77. package/dist/src/parser/parameters.d.ts +5 -0
  78. package/dist/src/parser/parameters.js +98 -0
  79. package/dist/src/parser/parameters.js.map +1 -0
  80. package/dist/src/parser/responses.d.ts +13 -0
  81. package/dist/src/parser/responses.js +132 -0
  82. package/dist/src/parser/responses.js.map +1 -0
  83. package/dist/src/parser/routes.d.ts +4 -0
  84. package/dist/src/parser/routes.js +36 -0
  85. package/dist/src/parser/routes.js.map +1 -0
  86. package/dist/src/parser/types.d.ts +9 -0
  87. package/dist/src/parser/types.js +157 -0
  88. package/dist/src/parser/types.js.map +1 -0
  89. package/dist/test/etag_cache.test.d.ts +1 -0
  90. package/dist/test/etag_cache.test.js +86 -0
  91. package/dist/test/etag_cache.test.js.map +1 -0
  92. package/dist/test/test-host.d.ts +11 -0
  93. package/dist/test/test-host.js +28 -0
  94. package/dist/test/test-host.js.map +1 -1
  95. package/example/main.tsp +30 -1
  96. package/example/output-rust/Cargo.lock +48 -0
  97. package/example/output-rust/Cargo.toml +1 -0
  98. package/example/output-rust/src/generated/server.rs +153 -12
  99. package/example/output-rust/src/generated/types.rs +6 -0
  100. package/example/output-rust/src/main.rs +65 -27
  101. package/justfile +31 -2
  102. package/package.json +1 -1
  103. package/scripts/update-golden.js +36 -0
  104. package/src/decorators/cache_control.ts +14 -0
  105. package/src/decorators/etag_cache.ts +14 -0
  106. package/src/decorators/index.ts +6 -0
  107. package/src/decorators/rust_attr.ts +61 -0
  108. package/src/decorators/rust_derive.ts +55 -0
  109. package/src/decorators/rust_impl.ts +29 -0
  110. package/src/decorators/rust_self.ts +42 -0
  111. package/src/emitter.ts +18 -1654
  112. package/src/formatter/index.ts +2 -0
  113. package/src/formatter/mappings.ts +70 -0
  114. package/src/formatter/strings.ts +33 -0
  115. package/src/generator/etag_router.ts +97 -0
  116. package/src/generator/index.ts +5 -0
  117. package/src/generator/response_enums.ts +76 -0
  118. package/src/generator/router.ts +284 -0
  119. package/src/generator/server_trait.ts +134 -0
  120. package/src/generator/types_file.ts +297 -0
  121. package/src/index.ts +3 -1
  122. package/src/lib.ts +1 -1
  123. package/src/lib.tsp +3 -1
  124. package/src/models/index.ts +2 -0
  125. package/src/models/keys.ts +7 -0
  126. package/src/models/types.ts +54 -0
  127. package/src/parser/decorators.ts +34 -0
  128. package/src/parser/index.ts +6 -0
  129. package/src/parser/operations.ts +158 -0
  130. package/src/parser/parameters.ts +117 -0
  131. package/src/parser/responses.ts +170 -0
  132. package/src/parser/routes.ts +47 -0
  133. package/src/parser/types.ts +215 -0
  134. package/test/etag_cache.test.ts +104 -0
  135. package/test/golden/etag_cache/server.rs +110 -0
  136. package/test/golden/etag_cache/spec.tsp +20 -0
  137. package/test/golden/etag_cache/types.rs +13 -0
  138. package/test/test-host.ts +43 -0
@@ -0,0 +1,104 @@
1
+ import { strictEqual } from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { emit, compareWithGolden } from "./test-host.js";
4
+
5
+ const ETAG_SPEC = `
6
+ import "@typespec/http";
7
+ import "typespec-rust-emitter";
8
+ using TypeSpec.Http;
9
+
10
+ model Article { id: string; title: string; }
11
+
12
+ @route("/articles")
13
+ namespace Articles {
14
+ @etagCache
15
+ @get
16
+ op getArticle(@path id: string): {
17
+ @statusCode statusCode: 200;
18
+ @body body: Article;
19
+ } | {
20
+ @statusCode statusCode: 304;
21
+ };
22
+ }
23
+ `;
24
+
25
+ describe("@etagCache decorator", () => {
26
+ it("generates EtagCache trait in server.rs", async () => {
27
+ const results = await emit(ETAG_SPEC);
28
+ const server = results["server.rs"];
29
+ strictEqual(server.includes("pub trait EtagCache"), true);
30
+ strictEqual(
31
+ server.includes("fn get(&self, key: &str) -> Option<String>"),
32
+ true,
33
+ );
34
+ strictEqual(server.includes("fn set(&self, key: &str, etag: &str)"), true);
35
+ });
36
+
37
+ it("generates cache-aware handler with 304 short-circuit", async () => {
38
+ const results = await emit(ETAG_SPEC);
39
+ const server = results["server.rs"];
40
+ strictEqual(server.includes("axum::http::StatusCode::NOT_MODIFIED"), true);
41
+ strictEqual(server.includes("cache.get(cache_key.as_ref())"), true);
42
+ });
43
+
44
+ it("supports custom etagKey and @cacheControl", async () => {
45
+ const spec = `
46
+ import "@typespec/http";
47
+ import "typespec-rust-emitter";
48
+ using TypeSpec.Http;
49
+
50
+ @route("/test")
51
+ namespace Test {
52
+ @etagCache("my-key")
53
+ @cacheControl("public")
54
+ @get
55
+ op run(): { @statusCode statusCode: 200; };
56
+ }
57
+ `;
58
+ const results = await emit(spec);
59
+ const server = results["server.rs"];
60
+ strictEqual(server.includes('let cache_key = "my-key";'), true);
61
+ strictEqual(server.includes('axum::http::header::CACHE_CONTROL'), true);
62
+ strictEqual(server.includes('"public"'), true);
63
+ });
64
+
65
+ it("orders cache after claims when combined with @useAuth", async () => {
66
+ const spec = `
67
+ import "@typespec/http";
68
+ import "typespec-rust-emitter";
69
+ using TypeSpec.Http;
70
+
71
+ model Article { id: string; title: string; }
72
+
73
+ @route("/articles")
74
+ namespace Articles {
75
+ @etagCache("article-list")
76
+ @useAuth(BearerAuth)
77
+ @get
78
+ op getArticle(@path id: string): {
79
+ @statusCode statusCode: 200;
80
+ @body body: Article;
81
+ };
82
+ }
83
+ `;
84
+ const results = await emit(spec);
85
+ const server = results["server.rs"];
86
+ strictEqual(
87
+ server.includes(
88
+ "async fn articles_get_article<C: EtagCache + Send + Sync>(\n &self, claims: Self::Claims, cache: &C, id: String",
89
+ ),
90
+ true,
91
+ );
92
+ strictEqual(
93
+ server.includes(
94
+ "let result = service.articles_get_article(claims, &cache, id).await;",
95
+ ),
96
+ true,
97
+ );
98
+ });
99
+
100
+ it("matches golden file", async () => {
101
+ const results = await emit(ETAG_SPEC);
102
+ compareWithGolden(results, "etag_cache", "server.rs");
103
+ });
104
+ });
@@ -0,0 +1,110 @@
1
+ #![allow(unused)]
2
+
3
+ use super::types::*;
4
+ use async_trait::async_trait;
5
+ use axum::extract::Path;
6
+ use axum::Extension;
7
+ use axum::http::StatusCode;
8
+ use axum::response::IntoResponse;
9
+ use axum::Json;
10
+ use eyre::Result;
11
+
12
+
13
+ /// Pluggable ETag cache backend.
14
+ /// Implement this for Redis, Memcached, in-memory HashMap, or any store.
15
+ pub trait EtagCache {
16
+ /// Return the stored ETag string for `key`, or `None` if not cached.
17
+ fn get(&self, key: &str) -> Option<String>;
18
+ /// Store `etag` under `key`.
19
+ fn set(&self, key: &str, etag: &str);
20
+ }
21
+
22
+
23
+
24
+ #[async_trait]
25
+ pub trait Server: Send + Sync {
26
+ type Claims: Send + Sync + 'static;
27
+
28
+
29
+ async fn articles_get_article<C: EtagCache + Send + Sync>(
30
+ &self, cache: &C, id: String
31
+ ) -> Result<ArticlesGetArticleResponse>;
32
+ }
33
+ #[allow(clippy::type_complexity)]
34
+ pub enum ArticlesGetArticleResponse {
35
+ Ok(Json<Article>),
36
+ NotModified,
37
+ }
38
+
39
+ impl IntoResponse for ArticlesGetArticleResponse {
40
+ fn into_response(self) -> axum::response::Response {
41
+ match self {
42
+
43
+ ArticlesGetArticleResponse::Ok(body) => (StatusCode::OK, body).into_response(),
44
+ ArticlesGetArticleResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
45
+ }
46
+ }
47
+ }
48
+
49
+ use axum::extract::{Query, Multipart};
50
+ use axum::routing::{get};
51
+ use axum::Router;
52
+
53
+
54
+ pub async fn articles_get_article_handler<S, C>(
55
+ axum::extract::State(service): axum::extract::State<S>,
56
+ axum::extract::State(cache): axum::extract::State<C>,
57
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
58
+ Path(id): Path<String>,
59
+ ) -> impl axum::response::IntoResponse
60
+ where
61
+ S: Server + Send + Sync + 'static,
62
+ S::Claims: Send + Sync + Clone + 'static,
63
+ C: EtagCache + Clone + Send + Sync + 'static,
64
+ {
65
+ // Check If-None-Match against the cache
66
+ let cache_key = format!("articles_get_article:{id}");
67
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
68
+ // If the client's ETag matches, respond 304 immediately
69
+ if stored_etag == format!("{:?}", inm).trim_matches('"') {
70
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
71
+
72
+ return res;
73
+ }
74
+ }
75
+ // Forward to business logic
76
+ let result = service.articles_get_article(&cache, id).await;
77
+ match result {
78
+ Ok(response) => {
79
+ let mut res = response.into_response();
80
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
81
+ res.headers_mut().insert(
82
+ axum::http::header::ETAG,
83
+ axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
84
+ );
85
+ }
86
+
87
+ res
88
+ }
89
+ Err(e) => (
90
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
91
+ format!("Internal error: {e}"),
92
+ )
93
+ .into_response(),
94
+ }
95
+ }
96
+
97
+ pub fn create_router<S, C, M>(service: S, cache: C, middleware: M) -> Router
98
+ where
99
+ S: Server + Clone + Send + Sync + 'static,
100
+ S::Claims: Send + Sync + Clone + 'static,
101
+ C: EtagCache + Clone + Send + Sync + 'static + axum::extract::FromRef<S>,
102
+ M: FnOnce(Router<S>) -> Router<S> + Clone + Send + Sync + 'static,
103
+ {
104
+ let mut router = Router::new().with_state(cache);
105
+ let public = Router::new()
106
+ .route("/articles", get(articles_get_article_handler::<S, C>))
107
+ ;
108
+ router = router.merge(public);
109
+ router.with_state(service)
110
+ }
@@ -0,0 +1,20 @@
1
+ import "@typespec/http";
2
+ import "typespec-rust-emitter";
3
+ using TypeSpec.Http;
4
+
5
+ model Article {
6
+ id: string;
7
+ title: string;
8
+ }
9
+
10
+ @route("/articles")
11
+ namespace Articles {
12
+ @etagCache
13
+ @get
14
+ op getArticle(@path id: string): {
15
+ @statusCode statusCode: 200;
16
+ @body body: Article;
17
+ } | {
18
+ @statusCode statusCode: 304;
19
+ };
20
+ }
@@ -0,0 +1,13 @@
1
+ #![allow(unused)]
2
+
3
+ use std::str::FromStr;
4
+ use axum::http::StatusCode;
5
+ use axum::response::IntoResponse;
6
+ use axum::Json;
7
+
8
+ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9
+ pub struct Article {
10
+ pub id: String,
11
+ pub title: String,
12
+ }
13
+
package/test/test-host.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Diagnostic, resolvePath } from "@typespec/compiler";
2
2
  import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
3
3
  import { createTester } from "@typespec/compiler/testing";
4
+ import { readFileSync } from "node:fs";
4
5
 
5
6
  export const Tester = createTester(resolvePath(import.meta.dirname, "../.."), {
6
7
  libraries: [
@@ -23,3 +24,45 @@ export async function emit(code: string): Promise<Record<string, string>> {
23
24
  expectDiagnosticEmpty(diagnostics);
24
25
  return result;
25
26
  }
27
+
28
+ /**
29
+ * compareWithGolden
30
+ *
31
+ * Reads an expected file from test/golden/<goldenDir>/<fileName>
32
+ * and asserts that the emitted output matches it exactly.
33
+ *
34
+ * @param emitted - Record produced by emit() e.g. { "types.rs": "...", "server.rs": "..." }
35
+ * @param goldenDir - Subdirectory name inside test/golden/
36
+ * @param fileName - File key to check, e.g. "server.rs"
37
+ */
38
+ export function compareWithGolden(
39
+ emitted: Record<string, string>,
40
+ goldenDir: string,
41
+ fileName: string,
42
+ ): void {
43
+ const goldenPath = resolvePath(
44
+ import.meta.dirname,
45
+ "../../test",
46
+ "golden",
47
+ goldenDir,
48
+ fileName,
49
+ );
50
+ const expected = readFileSync(goldenPath, "utf8");
51
+ const actual = emitted[fileName];
52
+ if (actual === undefined) {
53
+ throw new Error(
54
+ `Emitter did not produce "${fileName}". Keys: ${Object.keys(emitted).join(", ")}`,
55
+ );
56
+ }
57
+ if (actual !== expected) {
58
+ // Show a useful diff-style message.
59
+ const expectedLines = expected.split("\n");
60
+ const actualLines = actual.split("\n");
61
+ const firstDiff = expectedLines.findIndex((l, i) => l !== actualLines[i]);
62
+ throw new Error(
63
+ `Golden mismatch in ${goldenDir}/${fileName} at line ${firstDiff + 1}.\n` +
64
+ `Expected: ${expectedLines[firstDiff]}\n` +
65
+ `Actual: ${actualLines[firstDiff]}`,
66
+ );
67
+ }
68
+ }