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.
- package/.prettierignore +2 -0
- package/CHANGELOG.md +16 -0
- package/dist/src/generator/etag_router.d.ts +4 -2
- package/dist/src/generator/etag_router.js +71 -18
- package/dist/src/generator/etag_router.js.map +1 -1
- package/dist/src/generator/router.js +3 -3
- 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 +89 -4
- package/dist/test/etag_cache.test.js.map +1 -1
- package/example/main.tsp +41 -0
- package/example/output-rust/src/generated/server.rs +228 -27
- package/example/output-rust/src/main.rs +74 -6
- package/package.json +1 -1
- package/scripts/update-golden.js +12 -12
- package/src/generator/etag_router.ts +89 -19
- package/src/generator/router.ts +3 -3
- package/src/generator/server_trait.ts +5 -1
- package/src/parser/operations.ts +11 -3
- package/test/etag_cache.test.ts +123 -4
- package/test/golden/etag_cache/server.rs +15 -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
|
|
|
@@ -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))) =
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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))) =
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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,
|
|
11
|
-
|
|
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!("
|
|
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
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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))) =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 = ${
|
|
164
|
+
let result = ${serviceReceiver}.${handlerFnName}(${serverArgsStr}).await;
|
|
95
165
|
${finalResponseLogic}
|
|
96
166
|
}`;
|
|
97
167
|
}
|