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.
- package/.prettierignore +2 -0
- package/CHANGELOG.md +15 -0
- package/dist/src/generator/etag_router.d.ts +6 -7
- package/dist/src/generator/etag_router.js +59 -70
- package/dist/src/generator/etag_router.js.map +1 -1
- package/dist/src/generator/router.js +7 -4
- package/dist/src/generator/router.js.map +1 -1
- package/dist/src/generator/server_trait.js.map +1 -1
- package/dist/src/parser/operations.js.map +1 -1
- package/dist/test/etag_cache.test.js +65 -4
- package/dist/test/etag_cache.test.js.map +1 -1
- package/example/main.tsp +28 -1
- package/example/output-rust/src/generated/server.rs +186 -29
- package/example/output-rust/src/main.rs +54 -6
- package/package.json +1 -1
- package/scripts/update-golden.js +12 -12
- package/src/generator/etag_router.ts +78 -78
- package/src/generator/router.ts +7 -4
- package/src/generator/server_trait.ts +5 -1
- package/src/parser/operations.ts +11 -3
- package/test/etag_cache.test.ts +93 -4
- package/test/golden/etag_cache/server.rs +16 -14
|
@@ -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
|
|
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
|
|
285
|
+
S: Server + Send + Sync + 'static,
|
|
244
286
|
S::Claims: Send + Sync + Clone + 'static,
|
|
287
|
+
C: EtagCache + Clone + Send + Sync + 'static,
|
|
245
288
|
{
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
axum::http::
|
|
252
|
-
|
|
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) =>
|
|
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))) =
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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("/
|
|
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,
|
|
11
|
-
|
|
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
|
|
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!("
|
|
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
package/scripts/update-golden.js
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from
|
|
2
|
-
import { resolve } from
|
|
3
|
-
import { emit } from
|
|
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(
|
|
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,
|
|
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[
|
|
18
|
+
if (out["types.rs"]) {
|
|
19
19
|
const p = resolve(process.cwd(), `test/golden/${feature}/types.rs`);
|
|
20
|
-
writeFileSync(p, out[
|
|
20
|
+
writeFileSync(p, out["types.rs"]);
|
|
21
21
|
console.log(`Updated types.rs`);
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
if (out[
|
|
23
|
+
|
|
24
|
+
if (out["server.rs"]) {
|
|
25
25
|
const p = resolve(process.cwd(), `test/golden/${feature}/server.rs`);
|
|
26
|
-
writeFileSync(p, out[
|
|
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
|
|
26
|
-
* @param
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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
|
-
${
|
|
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))) =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 =
|
|
144
|
+
let result = ${serviceBinding}.${handlerFnName}(${serverArgsStr}).await;
|
|
145
145
|
${finalResponseLogic}
|
|
146
146
|
}`;
|
|
147
147
|
}
|