typespec-rust-emitter 0.13.1 → 0.14.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.
@@ -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
 
@@ -34,6 +35,15 @@ pub trait Server: Send + Sync {
34
35
  async fn items_get_item(&self, id: String) -> Result<ItemsGetItemResponse>;
35
36
  async fn items_create_item(&mut self, body: Item) -> Result<ItemsCreateItemResponse>;
36
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_list_recent<C: EtagCache + Send + Sync>(
42
+ &mut self, cache: &C, account_id: uuid::Uuid, user_timezone: String, last_id: Option<uuid::Uuid>, limit: i32
43
+ ) -> Result<ImagesListRecentResponse>;
44
+ async fn images_get_image<C: EtagCache + Send + Sync>(
45
+ &self, cache: &C, account_id: uuid::Uuid, image_id: String
46
+ ) -> Result<ImagesGetImageResponse>;
37
47
  async fn articles_get_article<C: EtagCache + Send + Sync>(
38
48
  &self, claims: Self::Claims, cache: &C, id: String
39
49
  ) -> Result<ArticlesGetArticleResponse>;
@@ -124,6 +134,54 @@ impl IntoResponse for ItemsUpdateItemResponse {
124
134
  }
125
135
  }
126
136
 
137
+ #[allow(clippy::type_complexity)]
138
+ pub enum ImagesListPublicResponse {
139
+ Ok(Json<Vec<Item>>),
140
+ NotModified,
141
+ }
142
+
143
+ impl IntoResponse for ImagesListPublicResponse {
144
+ fn into_response(self) -> axum::response::Response {
145
+ match self {
146
+
147
+ ImagesListPublicResponse::Ok(body) => (StatusCode::OK, body).into_response(),
148
+ ImagesListPublicResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
149
+ }
150
+ }
151
+ }
152
+
153
+ #[allow(clippy::type_complexity)]
154
+ pub enum ImagesListRecentResponse {
155
+ Ok(Json<Vec<Item>>),
156
+ NotModified,
157
+ }
158
+
159
+ impl IntoResponse for ImagesListRecentResponse {
160
+ fn into_response(self) -> axum::response::Response {
161
+ match self {
162
+
163
+ ImagesListRecentResponse::Ok(body) => (StatusCode::OK, body).into_response(),
164
+ ImagesListRecentResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
165
+ }
166
+ }
167
+ }
168
+
169
+ #[allow(clippy::type_complexity)]
170
+ pub enum ImagesGetImageResponse {
171
+ Ok(Json<Item>),
172
+ NotModified,
173
+ }
174
+
175
+ impl IntoResponse for ImagesGetImageResponse {
176
+ fn into_response(self) -> axum::response::Response {
177
+ match self {
178
+
179
+ ImagesGetImageResponse::Ok(body) => (StatusCode::OK, body).into_response(),
180
+ ImagesGetImageResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
181
+ }
182
+ }
183
+ }
184
+
127
185
  #[allow(clippy::type_complexity)]
128
186
  pub enum ArticlesGetArticleResponse {
129
187
  Ok(Json<Article>),
@@ -191,6 +249,16 @@ pub struct ItemsUpdateItemQuery {
191
249
  pub id: String
192
250
  }
193
251
 
252
+ #[derive(Debug, Clone, serde::Deserialize)]
253
+ pub struct ImagesListRecentQuery {
254
+ #[serde(rename = "userTimezone")]
255
+ pub user_timezone: String,
256
+ #[serde(rename = "lastId")]
257
+ pub last_id: Option<uuid::Uuid>,
258
+ #[serde(rename = "limit")]
259
+ pub limit: i32
260
+ }
261
+
194
262
  #[derive(Debug, Clone, serde::Deserialize)]
195
263
  pub struct ConsumingConsumeAndDeleteQuery {
196
264
  #[serde(rename = "id")]
@@ -249,24 +317,24 @@ where
249
317
  {
250
318
  // Check If-None-Match against the cache
251
319
  let cache_key = "article-list";
252
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
253
- // If the client's ETag matches, respond 304 immediately
254
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
255
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
256
- res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
257
- return res;
258
- }
320
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
321
+ (cache.get(cache_key.as_ref()).await, if_none_match)
322
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
323
+ && !inm.precondition_passes(&etag)
324
+ {
325
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
326
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
327
+ return res;
259
328
  }
260
329
  // Forward to business logic
261
330
  let result = service.items_list(&cache).await;
262
331
  match result {
263
332
  Ok(response) => {
264
333
  let mut res = response.into_response();
265
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
266
- res.headers_mut().insert(
267
- axum::http::header::ETAG,
268
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
269
- );
334
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
335
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
336
+ {
337
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
270
338
  }
271
339
  res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
272
340
  res
@@ -344,6 +412,136 @@ where
344
412
  }
345
413
  }
346
414
 
415
+ pub async fn images_list_public_handler<S, C>(
416
+ axum::extract::State(service): axum::extract::State<S>,
417
+ axum::extract::State(cache): axum::extract::State<C>,
418
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
419
+ Path(account_id): Path<uuid::Uuid>,
420
+ ) -> impl axum::response::IntoResponse
421
+ where
422
+ S: Server + Send + Sync + 'static,
423
+ S::Claims: Send + Sync + Clone + 'static,
424
+ C: EtagCache + Clone + Send + Sync + 'static,
425
+ {
426
+ // Check If-None-Match against the cache
427
+ let cache_key = format!("img:list:pub:{account_id}");
428
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
429
+ (cache.get(cache_key.as_ref()).await, if_none_match)
430
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
431
+ && !inm.precondition_passes(&etag)
432
+ {
433
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
434
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
435
+ return res;
436
+ }
437
+ // Forward to business logic
438
+ let result = service.images_list_public(&cache, account_id).await;
439
+ match result {
440
+ Ok(response) => {
441
+ let mut res = response.into_response();
442
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
443
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
444
+ {
445
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
446
+ }
447
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
448
+ res
449
+ }
450
+ Err(e) => (
451
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
452
+ format!("Internal error: {e}"),
453
+ )
454
+ .into_response(),
455
+ }
456
+ }
457
+
458
+ pub async fn images_list_recent_handler<S, C>(
459
+ axum::extract::State(mut service): axum::extract::State<S>,
460
+ axum::extract::State(cache): axum::extract::State<C>,
461
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
462
+ Path(account_id): Path<uuid::Uuid>,
463
+ Query(params): Query<ImagesListRecentQuery>,
464
+ ) -> impl axum::response::IntoResponse
465
+ where
466
+ S: Server + Send + Sync + 'static,
467
+ S::Claims: Send + Sync + Clone + 'static,
468
+ C: EtagCache + Clone + Send + Sync + 'static,
469
+ {
470
+ // Check If-None-Match against the cache
471
+ let cache_key = format!("img:recent:{account_id}:tz:{}:after:{:?}:limit:{}", params.user_timezone, params.last_id, params.limit);
472
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
473
+ (cache.get(cache_key.as_ref()).await, if_none_match)
474
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
475
+ && !inm.precondition_passes(&etag)
476
+ {
477
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
478
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
479
+ return res;
480
+ }
481
+ // Forward to business logic
482
+ let result = service.images_list_recent(&cache, account_id, params.user_timezone, params.last_id, params.limit).await;
483
+ match result {
484
+ Ok(response) => {
485
+ let mut res = response.into_response();
486
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
487
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
488
+ {
489
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
490
+ }
491
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("no-cache, max-age=2592000"));
492
+ res
493
+ }
494
+ Err(e) => (
495
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
496
+ format!("Internal error: {e}"),
497
+ )
498
+ .into_response(),
499
+ }
500
+ }
501
+
502
+ pub async fn images_get_image_handler<S, C>(
503
+ axum::extract::State(service): axum::extract::State<S>,
504
+ axum::extract::State(cache): axum::extract::State<C>,
505
+ if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
506
+ Path((account_id, image_id)): Path<(uuid::Uuid, String)>,
507
+ ) -> impl axum::response::IntoResponse
508
+ where
509
+ S: Server + Send + Sync + 'static,
510
+ S::Claims: Send + Sync + Clone + 'static,
511
+ C: EtagCache + Clone + Send + Sync + 'static,
512
+ {
513
+ // Check If-None-Match against the cache
514
+ let cache_key = format!("img:detail:pub:{account_id}:{image_id}");
515
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
516
+ (cache.get(cache_key.as_ref()).await, if_none_match)
517
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
518
+ && !inm.precondition_passes(&etag)
519
+ {
520
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
521
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=60"));
522
+ return res;
523
+ }
524
+ // Forward to business logic
525
+ let result = service.images_get_image(&cache, account_id, image_id).await;
526
+ match result {
527
+ Ok(response) => {
528
+ let mut res = response.into_response();
529
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
530
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
531
+ {
532
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
533
+ }
534
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=60"));
535
+ res
536
+ }
537
+ Err(e) => (
538
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
539
+ format!("Internal error: {e}"),
540
+ )
541
+ .into_response(),
542
+ }
543
+ }
544
+
347
545
  pub async fn articles_get_article_handler<S, C>(
348
546
  axum::extract::State(service): axum::extract::State<S>,
349
547
  axum::extract::State(cache): axum::extract::State<C>,
@@ -357,25 +555,25 @@ where
357
555
  C: EtagCache + Clone + Send + Sync + 'static,
358
556
  {
359
557
  // Check If-None-Match against the cache
360
- let cache_key = "article-list";
361
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
362
- // If the client's ETag matches, respond 304 immediately
363
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
364
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
365
- res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
366
- return res;
367
- }
558
+ let cache_key = format!("article-list:{id}");
559
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
560
+ (cache.get(cache_key.as_ref()).await, if_none_match)
561
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
562
+ && !inm.precondition_passes(&etag)
563
+ {
564
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
565
+ res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
566
+ return res;
368
567
  }
369
568
  // Forward to business logic
370
569
  let result = service.articles_get_article(claims, &cache, id).await;
371
570
  match result {
372
571
  Ok(response) => {
373
572
  let mut res = response.into_response();
374
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
375
- res.headers_mut().insert(
376
- axum::http::header::ETAG,
377
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
378
- );
573
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
574
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
575
+ {
576
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
379
577
  }
380
578
  res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
381
579
  res
@@ -444,6 +642,9 @@ where
444
642
  .route("/items/{id}", get(items_get_item_handler::<S>))
445
643
  .route("/items", post(items_create_item_handler::<S>))
446
644
  .route("/items", put(items_update_item_handler::<S>))
645
+ .route("/accounts/{accountId}/images", get(images_list_public_handler::<S, C>))
646
+ .route("/accounts/{accountId}/images/recent", get(images_list_recent_handler::<S, C>))
647
+ .route("/accounts/{accountId}/images/{imageId}", get(images_get_image_handler::<S, C>))
447
648
  .route("/consuming", post(consuming_upload_handler::<S>))
448
649
  ;
449
650
  router = router.merge(public);
@@ -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
+ ImagesListRecentResponse, ItemsCreateItemResponse, ItemsGetItemResponse, ItemsListResponse,
12
+ ItemsUpdateItemResponse, 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
  }
@@ -71,7 +73,7 @@ impl Server for AppState {
71
73
  &self,
72
74
  cache: &C,
73
75
  ) -> eyre::Result<ItemsListResponse> {
74
- cache.set("article-list", "\"items-etag\"");
76
+ cache.set("article-list", "\"items-etag\"").await;
75
77
  Ok(ItemsListResponse::Ok(Json(vec![generated::types::Item {
76
78
  name: "list-item".to_string(),
77
79
  value: 123,
@@ -106,6 +108,72 @@ impl Server for AppState {
106
108
  })))
107
109
  }
108
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_list_recent<C: EtagCache + Send + Sync>(
135
+ &mut self,
136
+ cache: &C,
137
+ account_id: uuid::Uuid,
138
+ user_timezone: String,
139
+ last_id: Option<uuid::Uuid>,
140
+ limit: i32,
141
+ ) -> eyre::Result<ImagesListRecentResponse> {
142
+ cache
143
+ .set(
144
+ &format!(
145
+ "img:recent:{}:tz:{}:after:{:?}:limit:{}",
146
+ account_id, user_timezone, last_id, limit
147
+ ),
148
+ "\"recent-images-etag\"",
149
+ )
150
+ .await;
151
+ Ok(ImagesListRecentResponse::Ok(Json(vec![
152
+ generated::types::Item {
153
+ name: "recent-image-1".to_string(),
154
+ value: limit,
155
+ },
156
+ ])))
157
+ }
158
+
159
+ async fn images_get_image<C: EtagCache + Send + Sync>(
160
+ &self,
161
+ cache: &C,
162
+ account_id: uuid::Uuid,
163
+ image_id: String,
164
+ ) -> eyre::Result<ImagesGetImageResponse> {
165
+ cache
166
+ .set(
167
+ &format!("img:detail:pub:{}:{}", account_id, image_id),
168
+ "\"image-detail-etag\"",
169
+ )
170
+ .await;
171
+ Ok(ImagesGetImageResponse::Ok(Json(generated::types::Item {
172
+ name: format!("image-{}", image_id),
173
+ value: 300,
174
+ })))
175
+ }
176
+
109
177
  async fn articles_get_article<C: EtagCache + Send + Sync>(
110
178
  &self,
111
179
  _claims: Self::Claims,
@@ -113,7 +181,7 @@ impl Server for AppState {
113
181
  id: String,
114
182
  ) -> eyre::Result<ArticlesGetArticleResponse> {
115
183
  let etag = format!("\"article-etag-{}\"", id);
116
- cache.set(&format!("articles_get_article:{}", id), &etag);
184
+ cache.set(&format!("article-list:{}", id), &etag).await;
117
185
  Ok(ArticlesGetArticleResponse::Ok(Json(
118
186
  generated::types::Article {
119
187
  id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-rust-emitter",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
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,29 +9,90 @@
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
+ queryParams?: ParameterInfo[];
36
+ }): string {
37
+ const { handlerFnName, etagKey, pathParams, queryParams = [] } = opts;
38
+ const pathSuffix = pathParams.map((p) => `{${p.rustName}}`).join(":");
39
+ const paramNames = new Map<string, string>();
40
+ for (const param of pathParams) {
41
+ paramNames.set(param.name, param.rustName);
42
+ paramNames.set(param.rustName, param.rustName);
43
+ }
44
+ const queryParamNames = new Map<string, ParameterInfo>();
45
+ for (const param of queryParams) {
46
+ queryParamNames.set(param.name, param);
47
+ queryParamNames.set(param.rustName, param);
48
+ }
49
+
50
+ let template = etagKey ?? handlerFnName;
51
+ const formatArgs: string[] = [];
52
+ template = template.replace(/\{([^}]+)\}/g, (match, name: string) => {
53
+ const rustName = paramNames.get(name);
54
+ if (rustName) {
55
+ return `{${rustName}}`;
56
+ }
57
+ const queryParam = queryParamNames.get(name);
58
+ if (queryParam) {
59
+ formatArgs.push(`params.${queryParam.rustName}`);
60
+ return queryParam.rustType.startsWith("Option<") ? "{:?}" : "{}";
61
+ }
62
+ return match;
63
+ });
64
+
65
+ const hasExplicitPathParam = pathParams.some(
66
+ (param) =>
67
+ template.includes(`{${param.name}}`) ||
68
+ template.includes(`{${param.rustName}}`),
69
+ );
70
+ if (pathSuffix && !hasExplicitPathParam) {
71
+ template = `${template}:${pathSuffix}`;
72
+ }
73
+
74
+ if (!template.includes("{")) {
75
+ return rustStringLiteral(template);
76
+ }
77
+ const args = formatArgs.length > 0 ? `, ${formatArgs.join(", ")}` : "";
78
+ return `format!(${rustStringLiteral(template)}${args})`;
79
+ }
80
+
19
81
  /**
20
82
  * generateEtagHandler
21
83
  *
22
84
  * Returns the Rust source for a single ETag-aware axum handler.
23
85
  *
24
86
  * @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}"
87
+ * @param pathParams route path parameters used for cache key scoping
26
88
  * @param extractorLines axum extractors needed by the operation
27
89
  * @param serverArgsStr comma-separated args forwarded to the trait method
28
90
  * @param responseName PascalCase name of the response enum, e.g. "ArticlesGetArticleResponse"
29
91
  */
30
92
  export function generateEtagHandler(opts: {
31
93
  handlerFnName: string;
32
- cacheKey: string;
94
+ pathParams: ParameterInfo[];
95
+ queryParams?: ParameterInfo[];
33
96
  extractorLines: string[];
34
97
  serverArgsStr: string;
35
98
  responseName: string;
@@ -39,7 +102,8 @@ export function generateEtagHandler(opts: {
39
102
  }): string {
40
103
  const {
41
104
  handlerFnName,
42
- cacheKey,
105
+ pathParams,
106
+ queryParams = [],
43
107
  extractorLines,
44
108
  serverArgsStr,
45
109
  etagKey,
@@ -47,18 +111,23 @@ export function generateEtagHandler(opts: {
47
111
  serviceBinding = "service",
48
112
  } = opts;
49
113
 
50
- const effectiveCacheKey = etagKey ? `"${etagKey}"` : `format!("${cacheKey}")`;
114
+ const effectiveCacheKey = buildCacheKeyExpression({
115
+ handlerFnName,
116
+ etagKey,
117
+ pathParams,
118
+ queryParams,
119
+ });
120
+ const serviceReceiver = serviceBinding.replace(/^mut\s+/, "");
51
121
  const operationExtractors =
52
122
  extractorLines.length > 0 ? `${extractorLines.join("\n")}\n` : "";
53
123
 
54
124
  const finalResponseLogic = ` match result {
55
125
  Ok(response) => {
56
126
  let mut res = response.into_response();
57
- if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
58
- res.headers_mut().insert(
59
- axum::http::header::ETAG,
60
- axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
61
- );
127
+ if let Some(stored_etag) = cache.get(cache_key.as_ref()).await
128
+ && let Ok(etag) = axum::http::HeaderValue::from_str(&stored_etag)
129
+ {
130
+ res.headers_mut().insert(axum::http::header::ETAG, etag);
62
131
  }
63
132
  ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
64
133
  res
@@ -82,16 +151,17 @@ where
82
151
  {
83
152
  // Check If-None-Match against the cache
84
153
  let cache_key = ${effectiveCacheKey};
85
- if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
86
- // If the client's ETag matches, respond 304 immediately
87
- if stored_etag == format!("{:?}", inm).trim_matches('"') {
88
- let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
89
- ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
90
- return res;
91
- }
154
+ if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) =
155
+ (cache.get(cache_key.as_ref()).await, if_none_match)
156
+ && let Ok(etag) = stored_etag.parse::<axum_extra::headers::ETag>()
157
+ && !inm.precondition_passes(&etag)
158
+ {
159
+ let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
160
+ ${cacheControl ? `res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("${cacheControl}"));` : ""}
161
+ return res;
92
162
  }
93
163
  // Forward to business logic
94
- let result = ${serviceBinding}.${handlerFnName}(${serverArgsStr}).await;
164
+ let result = ${serviceReceiver}.${handlerFnName}(${serverArgsStr}).await;
95
165
  ${finalResponseLogic}
96
166
  }`;
97
167
  }