typespec-rust-emitter 0.13.0 → 0.14.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.
@@ -117,15 +117,18 @@ export function generateRouter(
117
117
  const serverArgsStr = serverArgs.join(", ");
118
118
 
119
119
  if (hasEtagCache(program, op)) {
120
- const cacheKey = `${handlerFnName}:${pathParams.map((p) => `{${p.rustName}}`).join(":")}`;
120
+ const cachedServerArgs = isProtected
121
+ ? [serverArgs[0], "&cache", ...serverArgs.slice(1)]
122
+ : ["&cache", ...serverArgs];
121
123
  const code = generateEtagHandler({
122
124
  handlerFnName,
123
- cacheKey,
124
125
  pathParams,
125
- serverArgsStr,
126
+ extractorLines,
127
+ serverArgsStr: cachedServerArgs.join(", "),
126
128
  responseName: `${nsName}${toPascalCase(opInfo.name)}Response`,
127
129
  etagKey: opInfo.etagKey,
128
130
  cacheControl: opInfo.cacheControl,
131
+ serviceBinding,
129
132
  });
130
133
  handlers.push(code);
131
134
  const routePath = `"${opInfo.path}"`;
@@ -140,7 +143,7 @@ export function generateRouter(
140
143
 
141
144
  const serverCall = `service.${traitFnName}(${serverArgsStr}).await`;
142
145
 
143
- let responseHandling = opInfo.cacheControl
146
+ const responseHandling = opInfo.cacheControl
144
147
  ? `match result {
145
148
  Ok(response) => {
146
149
  let mut res = response.into_response();
@@ -29,7 +29,11 @@ use eyre::Result;
29
29
 
30
30
  `);
31
31
 
32
- if (namespaceGroups.some((g) => g.operations.some((o) => hasEtagCache(program, o)))) {
32
+ if (
33
+ namespaceGroups.some((g) =>
34
+ g.operations.some((o) => hasEtagCache(program, o)),
35
+ )
36
+ ) {
33
37
  parts.push(generateEtagCacheTrait());
34
38
  parts.push("\n");
35
39
  }
@@ -128,7 +128,9 @@ export function emitOperationInfo(
128
128
  const opName = op.name.replace(/[^a-zA-Z0-9_]/g, "_");
129
129
  const etagVal = program.stateMap(etagCacheKey).get(op);
130
130
  const etagKey = typeof etagVal === "string" ? etagVal : undefined;
131
- const cacheControl = program.stateMap(cacheControlKey).get(op) as string | undefined;
131
+ const cacheControl = program.stateMap(cacheControlKey).get(op) as
132
+ | string
133
+ | undefined;
132
134
 
133
135
  return {
134
136
  name: opName,
@@ -144,7 +146,10 @@ export function emitOperationInfo(
144
146
  };
145
147
  }
146
148
 
147
- export function getEtagKey(program: Program, operation: Operation): string | undefined {
149
+ export function getEtagKey(
150
+ program: Program,
151
+ operation: Operation,
152
+ ): string | undefined {
148
153
  const val = program.stateMap(etagCacheKey).get(operation);
149
154
  return typeof val === "string" ? val : undefined;
150
155
  }
@@ -153,6 +158,9 @@ export function hasEtagCache(program: Program, operation: Operation): boolean {
153
158
  return program.stateMap(etagCacheKey).has(operation);
154
159
  }
155
160
 
156
- export function getCacheControl(program: Program, operation: Operation): string | undefined {
161
+ export function getCacheControl(
162
+ program: Program,
163
+ operation: Operation,
164
+ ): string | undefined {
157
165
  return program.stateMap(cacheControlKey).get(operation) as string | undefined;
158
166
  }
@@ -28,17 +28,29 @@ describe("@etagCache decorator", () => {
28
28
  const server = results["server.rs"];
29
29
  strictEqual(server.includes("pub trait EtagCache"), true);
30
30
  strictEqual(
31
- server.includes("fn get(&self, key: &str) -> Option<String>"),
31
+ server.includes("async fn get(&self, key: &str) -> Option<String>"),
32
+ true,
33
+ );
34
+ strictEqual(
35
+ server.includes("async fn set(&self, key: &str, etag: &str)"),
32
36
  true,
33
37
  );
34
- strictEqual(server.includes("fn set(&self, key: &str, etag: &str)"), true);
35
38
  });
36
39
 
37
40
  it("generates cache-aware handler with 304 short-circuit", async () => {
38
41
  const results = await emit(ETAG_SPEC);
39
42
  const server = results["server.rs"];
40
43
  strictEqual(server.includes("axum::http::StatusCode::NOT_MODIFIED"), true);
41
- strictEqual(server.includes("cache.get(cache_key.as_ref())"), true);
44
+ strictEqual(server.includes("cache.get(cache_key.as_ref()).await"), true);
45
+ strictEqual(
46
+ server.includes("stored_etag.parse::<axum_extra::headers::ETag>()"),
47
+ true,
48
+ );
49
+ strictEqual(server.includes("!inm.precondition_passes(&etag)"), true);
50
+ strictEqual(
51
+ server.includes("HeaderValue::from_str(&stored_etag).unwrap()"),
52
+ false,
53
+ );
42
54
  });
43
55
 
44
56
  it("supports custom etagKey and @cacheControl", async () => {
@@ -58,10 +70,87 @@ describe("@etagCache decorator", () => {
58
70
  const results = await emit(spec);
59
71
  const server = results["server.rs"];
60
72
  strictEqual(server.includes('let cache_key = "my-key";'), true);
61
- strictEqual(server.includes('axum::http::header::CACHE_CONTROL'), true);
73
+ strictEqual(server.includes("axum::http::header::CACHE_CONTROL"), true);
62
74
  strictEqual(server.includes('"public"'), true);
63
75
  });
64
76
 
77
+ it("interpolates path parameters in custom etag keys", async () => {
78
+ const spec = `
79
+ import "@typespec/http";
80
+ import "typespec-rust-emitter";
81
+ using TypeSpec.Http;
82
+
83
+ @route("/accounts/{accountId}/images")
84
+ namespace Images {
85
+ @etagCache("img:list:pub:{accountId}")
86
+ @get
87
+ op listPublic(@path accountId: string): { @statusCode statusCode: 200; };
88
+ }
89
+ `;
90
+ const results = await emit(spec);
91
+ const server = results["server.rs"];
92
+ strictEqual(
93
+ server.includes('let cache_key = format!("img:list:pub:{account_id}");'),
94
+ true,
95
+ );
96
+ });
97
+
98
+ it("appends path parameters to static etag keys", async () => {
99
+ const spec = `
100
+ import "@typespec/http";
101
+ import "typespec-rust-emitter";
102
+ using TypeSpec.Http;
103
+
104
+ @route("/accounts/{accountId}/images")
105
+ namespace Images {
106
+ @etagCache("img:list:pub")
107
+ @get
108
+ op listPublic(@path accountId: string): { @statusCode statusCode: 200; };
109
+ }
110
+ `;
111
+ const results = await emit(spec);
112
+ const server = results["server.rs"];
113
+ strictEqual(
114
+ server.includes('let cache_key = format!("img:list:pub:{account_id}");'),
115
+ true,
116
+ );
117
+ });
118
+
119
+ it("orders cache after claims when combined with @useAuth", async () => {
120
+ const spec = `
121
+ import "@typespec/http";
122
+ import "typespec-rust-emitter";
123
+ using TypeSpec.Http;
124
+
125
+ model Article { id: string; title: string; }
126
+
127
+ @route("/articles")
128
+ namespace Articles {
129
+ @etagCache("article-list")
130
+ @useAuth(BearerAuth)
131
+ @get
132
+ op getArticle(@path id: string): {
133
+ @statusCode statusCode: 200;
134
+ @body body: Article;
135
+ };
136
+ }
137
+ `;
138
+ const results = await emit(spec);
139
+ const server = results["server.rs"];
140
+ strictEqual(
141
+ server.includes(
142
+ "async fn articles_get_article<C: EtagCache + Send + Sync>(\n &self, claims: Self::Claims, cache: &C, id: String",
143
+ ),
144
+ true,
145
+ );
146
+ strictEqual(
147
+ server.includes(
148
+ "let result = service.articles_get_article(claims, &cache, id).await;",
149
+ ),
150
+ true,
151
+ );
152
+ });
153
+
65
154
  it("matches golden file", async () => {
66
155
  const results = await emit(ETAG_SPEC);
67
156
  compareWithGolden(results, "etag_cache", "server.rs");
@@ -12,11 +12,12 @@ use eyre::Result;
12
12
 
13
13
  /// Pluggable ETag cache backend.
14
14
  /// Implement this for Redis, Memcached, in-memory HashMap, or any store.
15
+ #[async_trait]
15
16
  pub trait EtagCache {
16
17
  /// Return the stored ETag string for `key`, or `None` if not cached.
17
- fn get(&self, key: &str) -> Option<String>;
18
+ async fn get(&self, key: &str) -> Option<String>;
18
19
  /// Store `etag` under `key`.
19
- fn set(&self, key: &str, etag: &str);
20
+ async fn set(&self, key: &str, etag: &str);
20
21
  }
21
22
 
22
23
 
@@ -59,28 +60,29 @@ pub async fn articles_get_article_handler<S, C>(
59
60
  ) -> impl axum::response::IntoResponse
60
61
  where
61
62
  S: Server + Send + Sync + 'static,
63
+ S::Claims: Send + Sync + Clone + 'static,
62
64
  C: EtagCache + Clone + Send + Sync + 'static,
63
65
  {
64
66
  // Check If-None-Match against the cache
65
67
  let cache_key = format!("articles_get_article:{id}");
66
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
67
- // If the client's ETag matches, respond 304 immediately
68
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
69
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
70
-
71
- return res;
72
- }
68
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
69
+ (cache.get(cache_key.as_ref()).await, if_none_match)
70
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
71
+ && !inm.precondition_passes(&etag)
72
+ {
73
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
74
+
75
+ return res;
73
76
  }
74
77
  // Forward to business logic
75
78
  let result = service.articles_get_article(&cache, id).await;
76
79
  match result {
77
80
  Ok(response) => {
78
81
  let mut res = response.into_response();
79
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
80
- res.headers_mut().insert(
81
- axum::http::header::ETAG,
82
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
83
- );
82
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
83
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
84
+ {
85
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
84
86
  }
85
87
 
86
88
  res