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.
@@ -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
 
@@ -28,12 +29,20 @@ pub trait Server: Send + Sync {
28
29
 
29
30
  async fn events_accounts_events(&self, account_id: String) -> Result<EventsAccountsEventsResponse>;
30
31
  async fn pets_list(&self, first_query: String, second_query: String) -> Result<PetsListResponse>;
31
- async fn items_list(&self) -> Result<ItemsListResponse>;
32
+ async fn items_list<C: EtagCache + Send + Sync>(
33
+ &self, cache: &C
34
+ ) -> Result<ItemsListResponse>;
32
35
  async fn items_get_item(&self, id: String) -> Result<ItemsGetItemResponse>;
33
36
  async fn items_create_item(&mut self, body: Item) -> Result<ItemsCreateItemResponse>;
34
37
  async fn items_update_item(&mut self, id: String, body: Item) -> Result<ItemsUpdateItemResponse>;
38
+ async fn images_list_public<C: EtagCache + Send + Sync>(
39
+ &self, cache: &C, account_id: uuid::Uuid
40
+ ) -> Result<ImagesListPublicResponse>;
41
+ async fn images_get_image<C: EtagCache + Send + Sync>(
42
+ &self, cache: &C, account_id: uuid::Uuid, image_id: String
43
+ ) -> Result<ImagesGetImageResponse>;
35
44
  async fn articles_get_article<C: EtagCache + Send + Sync>(
36
- &self, cache: &C, id: String
45
+ &self, claims: Self::Claims, cache: &C, id: String
37
46
  ) -> Result<ArticlesGetArticleResponse>;
38
47
  async fn consuming_consume_and_delete(self, id: String) -> Result<ConsumingConsumeAndDeleteResponse>;
39
48
  async fn consuming_upload(&self, account_id: uuid::Uuid, body: Multipart) -> Result<ConsumingUploadResponse>;
@@ -122,6 +131,38 @@ impl IntoResponse for ItemsUpdateItemResponse {
122
131
  }
123
132
  }
124
133
 
134
+ #[allow(clippy::type_complexity)]
135
+ pub enum ImagesListPublicResponse {
136
+ Ok(Json<Vec<Item>>),
137
+ NotModified,
138
+ }
139
+
140
+ impl IntoResponse for ImagesListPublicResponse {
141
+ fn into_response(self) -> axum::response::Response {
142
+ match self {
143
+
144
+ ImagesListPublicResponse::Ok(body) => (StatusCode::OK, body).into_response(),
145
+ ImagesListPublicResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
146
+ }
147
+ }
148
+ }
149
+
150
+ #[allow(clippy::type_complexity)]
151
+ pub enum ImagesGetImageResponse {
152
+ Ok(Json<Item>),
153
+ NotModified,
154
+ }
155
+
156
+ impl IntoResponse for ImagesGetImageResponse {
157
+ fn into_response(self) -> axum::response::Response {
158
+ match self {
159
+
160
+ ImagesGetImageResponse::Ok(body) => (StatusCode::OK, body).into_response(),
161
+ ImagesGetImageResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
162
+ }
163
+ }
164
+ }
165
+
125
166
  #[allow(clippy::type_complexity)]
126
167
  pub enum ArticlesGetArticleResponse {
127
168
  Ok(Json<Article>),
@@ -235,22 +276,38 @@ where
235
276
  }
236
277
  }
237
278
 
238
- pub async fn items_list_handler<S>(
279
+ pub async fn items_list_handler<S, C>(
239
280
  axum::extract::State(service): axum::extract::State<S>,
240
-
281
+ axum::extract::State(cache): axum::extract::State<C>,
282
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
241
283
  ) -> impl axum::response::IntoResponse
242
284
  where
243
- S: Server+ Clone + Send + Sync + 'static,
285
+ S: Server + Send + Sync + 'static,
244
286
  S::Claims: Send + Sync + Clone + 'static,
287
+ C: EtagCache + Clone + Send + Sync + 'static,
245
288
  {
246
- let result = service.items_list().await;
289
+ // Check If-None-Match against the cache
290
+ let cache_key = "article-list";
291
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
292
+ (cache.get(cache_key.as_ref()).await, if_none_match)
293
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
294
+ && !inm.precondition_passes(&etag)
295
+ {
296
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
297
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
298
+ return res;
299
+ }
300
+ // Forward to business logic
301
+ let result = service.items_list(&cache).await;
247
302
  match result {
248
303
  Ok(response) => {
249
304
  let mut res = response.into_response();
250
- res.headers_mut().insert(
251
- axum::http::header::CACHE_CONTROL,
252
- axum::http::HeaderValue::from_static("no-cache"),
253
- );
305
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
306
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
307
+ {
308
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
309
+ }
310
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
254
311
  res
255
312
  }
256
313
  Err(e) => (
@@ -271,7 +328,14 @@ where
271
328
  {
272
329
  let result = service.items_get_item(id).await;
273
330
  match result {
274
- Ok(response) => response.into_response(),
331
+ Ok(response) => {
332
+ let mut res = response.into_response();
333
+ res.headers_mut().insert(
334
+ axum::http::header::CACHE_CONTROL,
335
+ axum::http::HeaderValue::from_static("no-cache"),
336
+ );
337
+ res
338
+ }
275
339
  Err(e) => (
276
340
  axum::http::StatusCode::INTERNAL_SERVER_ERROR,
277
341
  format!("Internal error: {e}"),
@@ -319,36 +383,124 @@ where
319
383
  }
320
384
  }
321
385
 
386
+ pub async fn images_list_public_handler<S, C>(
387
+ axum::extract::State(service): axum::extract::State<S>,
388
+ axum::extract::State(cache): axum::extract::State<C>,
389
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
390
+ Path(account_id): Path<uuid::Uuid>,
391
+ ) -> impl axum::response::IntoResponse
392
+ where
393
+ S: Server + Send + Sync + 'static,
394
+ S::Claims: Send + Sync + Clone + 'static,
395
+ C: EtagCache + Clone + Send + Sync + 'static,
396
+ {
397
+ // Check If-None-Match against the cache
398
+ let cache_key = format!("img:list:pub:{account_id}");
399
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
400
+ (cache.get(cache_key.as_ref()).await, if_none_match)
401
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
402
+ && !inm.precondition_passes(&etag)
403
+ {
404
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
405
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
406
+ return res;
407
+ }
408
+ // Forward to business logic
409
+ let result = service.images_list_public(&cache, account_id).await;
410
+ match result {
411
+ Ok(response) => {
412
+ let mut res = response.into_response();
413
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
414
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
415
+ {
416
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
417
+ }
418
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
419
+ res
420
+ }
421
+ Err(e) => (
422
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
423
+ format!("Internal error: {e}"),
424
+ )
425
+ .into_response(),
426
+ }
427
+ }
428
+
429
+ pub async fn images_get_image_handler<S, C>(
430
+ axum::extract::State(service): axum::extract::State<S>,
431
+ axum::extract::State(cache): axum::extract::State<C>,
432
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
433
+ Path((account_id, image_id)): Path<(uuid::Uuid, String)>,
434
+ ) -> impl axum::response::IntoResponse
435
+ where
436
+ S: Server + Send + Sync + 'static,
437
+ S::Claims: Send + Sync + Clone + 'static,
438
+ C: EtagCache + Clone + Send + Sync + 'static,
439
+ {
440
+ // Check If-None-Match against the cache
441
+ let cache_key = format!("img:detail:pub:{account_id}:{image_id}");
442
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
443
+ (cache.get(cache_key.as_ref()).await, if_none_match)
444
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
445
+ && !inm.precondition_passes(&etag)
446
+ {
447
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
448
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=60"));
449
+ return res;
450
+ }
451
+ // Forward to business logic
452
+ let result = service.images_get_image(&cache, account_id, image_id).await;
453
+ match result {
454
+ Ok(response) => {
455
+ let mut res = response.into_response();
456
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
457
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
458
+ {
459
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
460
+ }
461
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=60"));
462
+ res
463
+ }
464
+ Err(e) => (
465
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
466
+ format!("Internal error: {e}"),
467
+ )
468
+ .into_response(),
469
+ }
470
+ }
471
+
322
472
  pub async fn articles_get_article_handler<S, C>(
323
473
  axum::extract::State(service): axum::extract::State<S>,
324
474
  axum::extract::State(cache): axum::extract::State<C>,
325
475
  if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
476
+ Extension(claims): Extension<S::Claims>,
326
477
  Path(id): Path<String>,
327
478
  ) -> impl axum::response::IntoResponse
328
479
  where
329
480
  S: Server + Send + Sync + 'static,
481
+ S::Claims: Send + Sync + Clone + 'static,
330
482
  C: EtagCache + Clone + Send + Sync + 'static,
331
483
  {
332
484
  // Check If-None-Match against the cache
333
- let cache_key = "article-list";
334
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
335
- // If the client's ETag matches, respond 304 immediately
336
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
337
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
338
- res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
339
- return res;
340
- }
485
+ let cache_key = format!("article-list:{id}");
486
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
487
+ (cache.get(cache_key.as_ref()).await, if_none_match)
488
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
489
+ && !inm.precondition_passes(&etag)
490
+ {
491
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
492
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
493
+ return res;
341
494
  }
342
495
  // Forward to business logic
343
- let result = service.articles_get_article(&cache, id).await;
496
+ let result = service.articles_get_article(claims, &cache, id).await;
344
497
  match result {
345
498
  Ok(response) => {
346
499
  let mut res = response.into_response();
347
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
348
- res.headers_mut().insert(
349
- axum::http::header::ETAG,
350
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
351
- );
500
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
501
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
502
+ {
503
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
352
504
  }
353
505
  res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
354
506
  res
@@ -413,13 +565,18 @@ where
413
565
  let public = Router::new()
414
566
  .route("/events/{accountId}", get(events_accounts_events_handler::<S>))
415
567
  .route("/pets", get(pets_list_handler::<S>))
416
- .route("/items", get(items_list_handler::<S>))
568
+ .route("/items", get(items_list_handler::<S, C>))
417
569
  .route("/items/{id}", get(items_get_item_handler::<S>))
418
570
  .route("/items", post(items_create_item_handler::<S>))
419
571
  .route("/items", put(items_update_item_handler::<S>))
420
- .route("/articles", get(articles_get_article_handler::<S, C>))
572
+ .route("/accounts/{accountId}/images", get(images_list_public_handler::<S, C>))
573
+ .route("/accounts/{accountId}/images/{imageId}", get(images_get_image_handler::<S, C>))
421
574
  .route("/consuming", post(consuming_upload_handler::<S>))
422
575
  ;
423
576
  router = router.merge(public);
577
+ let protected = Router::new()
578
+ .route("/articles", get(articles_get_article_handler::<S, C>))
579
+ ;
580
+ router = router.merge(middleware(protected));
424
581
  router.with_state(service)
425
582
  }
@@ -7,8 +7,9 @@ use axum::response::IntoResponse;
7
7
  use axum::response::sse::{Event, KeepAlive, Sse};
8
8
  use generated::server::{
9
9
  ArticlesGetArticleResponse, ConsumingConsumeAndDeleteResponse, ConsumingUploadResponse,
10
- EtagCache, EventsAccountsEventsResponse, ItemsCreateItemResponse, ItemsGetItemResponse,
11
- ItemsListResponse, ItemsUpdateItemResponse, PetsListResponse, Server,
10
+ EtagCache, EventsAccountsEventsResponse, ImagesGetImageResponse, ImagesListPublicResponse,
11
+ ItemsCreateItemResponse, ItemsGetItemResponse, ItemsListResponse, ItemsUpdateItemResponse,
12
+ PetsListResponse, Server,
12
13
  };
13
14
  use std::collections::HashMap;
14
15
  use std::convert::Infallible;
@@ -20,11 +21,12 @@ use tokio_stream::{wrappers::IntervalStream, StreamExt as _};
20
21
  #[derive(Clone, Default)]
21
22
  struct InMemoryCache(Arc<Mutex<HashMap<String, String>>>);
22
23
 
24
+ #[async_trait]
23
25
  impl EtagCache for InMemoryCache {
24
- fn get(&self, key: &str) -> Option<String> {
26
+ async fn get(&self, key: &str) -> Option<String> {
25
27
  self.0.lock().unwrap().get(key).cloned()
26
28
  }
27
- fn set(&self, key: &str, etag: &str) {
29
+ async fn set(&self, key: &str, etag: &str) {
28
30
  self.0.lock().unwrap().insert(key.to_owned(), etag.to_owned());
29
31
  }
30
32
  }
@@ -67,7 +69,11 @@ impl Server for AppState {
67
69
  ])))
68
70
  }
69
71
 
70
- async fn items_list(&self) -> eyre::Result<ItemsListResponse> {
72
+ async fn items_list<C: EtagCache + Send + Sync>(
73
+ &self,
74
+ cache: &C,
75
+ ) -> eyre::Result<ItemsListResponse> {
76
+ cache.set("article-list", "\"items-etag\"").await;
71
77
  Ok(ItemsListResponse::Ok(Json(vec![generated::types::Item {
72
78
  name: "list-item".to_string(),
73
79
  value: 123,
@@ -102,13 +108,55 @@ impl Server for AppState {
102
108
  })))
103
109
  }
104
110
 
111
+ async fn images_list_public<C: EtagCache + Send + Sync>(
112
+ &self,
113
+ cache: &C,
114
+ account_id: uuid::Uuid,
115
+ ) -> eyre::Result<ImagesListPublicResponse> {
116
+ cache
117
+ .set(
118
+ &format!("img:list:pub:{}", account_id),
119
+ "\"images-list-etag\"",
120
+ )
121
+ .await;
122
+ Ok(ImagesListPublicResponse::Ok(Json(vec![
123
+ generated::types::Item {
124
+ name: "public-image-1".to_string(),
125
+ value: 200,
126
+ },
127
+ generated::types::Item {
128
+ name: "public-image-2".to_string(),
129
+ value: 201,
130
+ },
131
+ ])))
132
+ }
133
+
134
+ async fn images_get_image<C: EtagCache + Send + Sync>(
135
+ &self,
136
+ cache: &C,
137
+ account_id: uuid::Uuid,
138
+ image_id: String,
139
+ ) -> eyre::Result<ImagesGetImageResponse> {
140
+ cache
141
+ .set(
142
+ &format!("img:detail:pub:{}:{}", account_id, image_id),
143
+ "\"image-detail-etag\"",
144
+ )
145
+ .await;
146
+ Ok(ImagesGetImageResponse::Ok(Json(generated::types::Item {
147
+ name: format!("image-{}", image_id),
148
+ value: 300,
149
+ })))
150
+ }
151
+
105
152
  async fn articles_get_article<C: EtagCache + Send + Sync>(
106
153
  &self,
154
+ _claims: Self::Claims,
107
155
  cache: &C,
108
156
  id: String,
109
157
  ) -> eyre::Result<ArticlesGetArticleResponse> {
110
158
  let etag = format!("\"article-etag-{}\"", id);
111
- cache.set(&format!("articles_get_article:{}", id), &etag);
159
+ cache.set(&format!("article-list:{}", id), &etag).await;
112
160
  Ok(ArticlesGetArticleResponse::Ok(Json(
113
161
  generated::types::Article {
114
162
  id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-rust-emitter",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "TypeSpec emitter that generates idiomatic Rust types and structs",
5
5
  "keywords": [
6
6
  "typespec",
@@ -1,36 +1,36 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
- import { emit } from '../dist/test/test-host.js';
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { emit } from "../dist/test/test-host.js";
4
4
 
5
5
  async function main() {
6
6
  const feature = process.argv[2];
7
7
  if (!feature) {
8
- console.error('Usage: node scripts/update-golden.js <feature>');
8
+ console.error("Usage: node scripts/update-golden.js <feature>");
9
9
  process.exit(1);
10
10
  }
11
11
 
12
12
  const specPath = resolve(process.cwd(), `test/golden/${feature}/spec.tsp`);
13
- const spec = readFileSync(specPath, 'utf8');
14
-
13
+ const spec = readFileSync(specPath, "utf8");
14
+
15
15
  console.log(`Emitting spec for ${feature}...`);
16
16
  const out = await emit(spec);
17
17
 
18
- if (out['types.rs']) {
18
+ if (out["types.rs"]) {
19
19
  const p = resolve(process.cwd(), `test/golden/${feature}/types.rs`);
20
- writeFileSync(p, out['types.rs']);
20
+ writeFileSync(p, out["types.rs"]);
21
21
  console.log(`Updated types.rs`);
22
22
  }
23
-
24
- if (out['server.rs']) {
23
+
24
+ if (out["server.rs"]) {
25
25
  const p = resolve(process.cwd(), `test/golden/${feature}/server.rs`);
26
- writeFileSync(p, out['server.rs']);
26
+ writeFileSync(p, out["server.rs"]);
27
27
  console.log(`Updated server.rs`);
28
28
  }
29
29
 
30
30
  console.log(`Golden files updated for: ${feature}`);
31
31
  }
32
32
 
33
- main().catch(err => {
33
+ main().catch((err) => {
34
34
  console.error(err);
35
35
  process.exit(1);
36
36
  });
@@ -1,3 +1,5 @@
1
+ import type { ParameterInfo } from "../models/types.js";
2
+
1
3
  /**
2
4
  * generateEtagCacheTrait
3
5
  *
@@ -7,109 +9,105 @@
7
9
  export function generateEtagCacheTrait(): string {
8
10
  return `/// Pluggable ETag cache backend.
9
11
  /// Implement this for Redis, Memcached, in-memory HashMap, or any store.
12
+ #[async_trait]
10
13
  pub trait EtagCache {
11
14
  /// Return the stored ETag string for \`key\`, or \`None\` if not cached.
12
- fn get(&self, key: &str) -> Option<String>;
15
+ async fn get(&self, key: &str) -> Option<String>;
13
16
  /// Store \`etag\` under \`key\`.
14
- fn set(&self, key: &str, etag: &str);
17
+ async fn set(&self, key: &str, etag: &str);
15
18
  }
16
19
  `;
17
20
  }
18
21
 
22
+ function rustStringLiteral(value: string): string {
23
+ return `"${value
24
+ .replace(/\\/g, "\\\\")
25
+ .replace(/"/g, '\\"')
26
+ .replace(/\n/g, "\\n")
27
+ .replace(/\r/g, "\\r")
28
+ .replace(/\t/g, "\\t")}"`;
29
+ }
30
+
31
+ function buildCacheKeyExpression(opts: {
32
+ handlerFnName: string;
33
+ etagKey?: string;
34
+ pathParams: ParameterInfo[];
35
+ }): string {
36
+ const { handlerFnName, etagKey, pathParams } = opts;
37
+ const pathSuffix = pathParams.map((p) => `{${p.rustName}}`).join(":");
38
+ const paramNames = new Map<string, string>();
39
+ for (const param of pathParams) {
40
+ paramNames.set(param.name, param.rustName);
41
+ paramNames.set(param.rustName, param.rustName);
42
+ }
43
+
44
+ let template = etagKey ?? handlerFnName;
45
+ template = template.replace(/\{([^}]+)\}/g, (match, name: string) => {
46
+ const rustName = paramNames.get(name);
47
+ return rustName ? `{${rustName}}` : match;
48
+ });
49
+
50
+ const hasExplicitPathParam = pathParams.some(
51
+ (param) =>
52
+ template.includes(`{${param.name}}`) ||
53
+ template.includes(`{${param.rustName}}`),
54
+ );
55
+ if (pathSuffix && !hasExplicitPathParam) {
56
+ template = `${template}:${pathSuffix}`;
57
+ }
58
+
59
+ if (!template.includes("{")) {
60
+ return rustStringLiteral(template);
61
+ }
62
+ return `format!(${rustStringLiteral(template)})`;
63
+ }
64
+
19
65
  /**
20
66
  * generateEtagHandler
21
67
  *
22
68
  * Returns the Rust source for a single ETag-aware axum handler.
23
69
  *
24
70
  * @param handlerFnName snake_case name, e.g. "articles_get_article"
25
- * @param cacheKey format string for the cache key, e.g. "articles_get_article:{id}"
26
- * @param pathParams list of { rustName, rustType } for Path extractor
71
+ * @param pathParams route path parameters used for cache key scoping
72
+ * @param extractorLines axum extractors needed by the operation
27
73
  * @param serverArgsStr comma-separated args forwarded to the trait method
28
74
  * @param responseName PascalCase name of the response enum, e.g. "ArticlesGetArticleResponse"
29
75
  */
30
76
  export function generateEtagHandler(opts: {
31
77
  handlerFnName: string;
32
- cacheKey: string;
33
- pathParams: { rustName: string; rustType: string }[];
78
+ pathParams: ParameterInfo[];
79
+ extractorLines: string[];
34
80
  serverArgsStr: string;
35
81
  responseName: string;
36
82
  etagKey?: string;
37
83
  cacheControl?: string;
84
+ serviceBinding?: string;
38
85
  }): string {
39
86
  const {
40
87
  handlerFnName,
41
- cacheKey,
42
88
  pathParams,
89
+ extractorLines,
43
90
  serverArgsStr,
44
91
  etagKey,
45
92
  cacheControl,
93
+ serviceBinding = "service",
46
94
  } = opts;
47
95
 
48
- // Build Path extractor
49
- let pathExtractor = "";
50
- if (pathParams.length === 1) {
51
- pathExtractor = ` Path(${pathParams[0].rustName}): Path<${pathParams[0].rustType}>,\n`;
52
- } else if (pathParams.length > 1) {
53
- const names = pathParams.map((p) => p.rustName).join(", ");
54
- const types = pathParams.map((p) => p.rustType).join(", ");
55
- pathExtractor = ` Path((${names})): Path<(${types})>,\n`;
56
- }
57
-
58
- const effectiveCacheKey = etagKey ? `"${etagKey}"` : `format!("${cacheKey}")`;
59
-
60
- let responseLogic = ` match result {
61
- Ok(response) => {
62
- let res = response.into_response();
63
- `;
64
-
65
- const hasHeaders = !!cacheControl; // Currently only cacheControl adds headers here, but etag check also does.
66
- // Wait, the etag check logic is different.
67
-
68
- if (cacheControl) {
69
- responseLogic = ` match result {
70
- Ok(response) => {
71
- let mut res = response.into_response();
72
- res.headers_mut().insert(
73
- axum::http::header::CACHE_CONTROL,
74
- axum::http::HeaderValue::from_static("${cacheControl}"),
75
- );
76
- `;
77
- }
78
-
79
- // We should also potentially add the ETag header if we have one in cache
80
- // But wait, the ETag value in cache is only valid if the response is successful and matches the cached version.
81
- // Usually, the business logic would set the ETag in the cache.
82
-
83
- responseLogic += ` if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
84
- let mut res = res; // Ensure it's mutable if we need to add etag
85
- let mut res = if res.headers().get(axum::http::header::ETAG).is_none() {
86
- let mut res = res;
87
- res.headers_mut().insert(
88
- axum::http::header::ETAG,
89
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
90
- );
91
- res
92
- } else {
93
- res
94
- };
95
- res
96
- } else {
97
- res
98
- }
99
- }
100
- `;
101
-
102
- // Actually, let's keep it simpler for now to avoid complexity in the template.
103
- // I'll just fix the let_and_return for the common case.
96
+ const effectiveCacheKey = buildCacheKeyExpression({
97
+ handlerFnName,
98
+ etagKey,
99
+ pathParams,
100
+ });
101
+ const operationExtractors =
102
+ extractorLines.length > 0 ? `${extractorLines.join("\n")}\n` : "";
104
103
 
105
104
  const finalResponseLogic = ` match result {
106
105
  Ok(response) => {
107
106
  let mut res = response.into_response();
108
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
109
- res.headers_mut().insert(
110
- axum::http::header::ETAG,
111
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
112
- );
107
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
108
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
109
+ {
110
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
113
111
  }
114
112
  ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
115
113
  res
@@ -122,26 +120,28 @@ export function generateEtagHandler(opts: {
122
120
  }`;
123
121
 
124
122
  return `pub async fn ${handlerFnName}_handler<S, C>(
125
- axum::extract::State(service): axum::extract::State<S>,
123
+ axum::extract::State(${serviceBinding}): axum::extract::State<S>,
126
124
  axum::extract::State(cache): axum::extract::State<C>,
127
125
  if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
128
- ${pathExtractor}) -> impl axum::response::IntoResponse
126
+ ${operationExtractors}) -> impl axum::response::IntoResponse
129
127
  where
130
128
  S: Server + Send + Sync + 'static,
129
+ S::Claims: Send + Sync + Clone + 'static,
131
130
  C: EtagCache + Clone + Send + Sync + 'static,
132
131
  {
133
132
  // Check If-None-Match against the cache
134
133
  let cache_key = ${effectiveCacheKey};
135
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
136
- // If the client's ETag matches, respond 304 immediately
137
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
138
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
139
- ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
140
- return res;
141
- }
134
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
135
+ (cache.get(cache_key.as_ref()).await, if_none_match)
136
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
137
+ && !inm.precondition_passes(&etag)
138
+ {
139
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
140
+ ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
141
+ return res;
142
142
  }
143
143
  // Forward to business logic
144
- let result = service.${handlerFnName}(&cache, ${serverArgsStr}).await;
144
+ let result = ${serviceBinding}.${handlerFnName}(${serverArgsStr}).await;
145
145
  ${finalResponseLogic}
146
146
  }`;
147
147
  }