typespec-rust-emitter 0.12.0 → 0.13.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/AGENTS.md +82 -80
- package/CHANGELOG.md +24 -0
- package/dist/src/decorators/cache_control.d.ts +6 -0
- package/dist/src/decorators/cache_control.js +9 -0
- package/dist/src/decorators/cache_control.js.map +1 -0
- package/dist/src/decorators/etag_cache.d.ts +6 -0
- package/dist/src/decorators/etag_cache.js +9 -0
- package/dist/src/decorators/etag_cache.js.map +1 -0
- package/dist/src/decorators/index.d.ts +6 -0
- package/dist/src/decorators/index.js +7 -0
- package/dist/src/decorators/index.js.map +1 -0
- package/dist/src/decorators/rust_attr.d.ts +3 -0
- package/dist/src/decorators/rust_attr.js +45 -0
- package/dist/src/decorators/rust_attr.js.map +1 -0
- package/dist/src/decorators/rust_derive.d.ts +3 -0
- package/dist/src/decorators/rust_derive.js +39 -0
- package/dist/src/decorators/rust_derive.js.map +1 -0
- package/dist/src/decorators/rust_impl.d.ts +2 -0
- package/dist/src/decorators/rust_impl.js +19 -0
- package/dist/src/decorators/rust_impl.js.map +1 -0
- package/dist/src/decorators/rust_self.d.ts +3 -0
- package/dist/src/decorators/rust_self.js +35 -0
- package/dist/src/decorators/rust_self.js.map +1 -0
- package/dist/src/emitter.d.ts +2 -11
- package/dist/src/emitter.js +7 -1282
- package/dist/src/emitter.js.map +1 -1
- package/dist/src/formatter/index.d.ts +2 -0
- package/dist/src/formatter/index.js +3 -0
- package/dist/src/formatter/index.js.map +1 -0
- package/dist/src/formatter/mappings.d.ts +4 -0
- package/dist/src/formatter/mappings.js +68 -0
- package/dist/src/formatter/mappings.js.map +1 -0
- package/dist/src/formatter/strings.d.ts +4 -0
- package/dist/src/formatter/strings.js +32 -0
- package/dist/src/formatter/strings.js.map +1 -0
- package/dist/src/generator/etag_router.d.ts +28 -0
- package/dist/src/generator/etag_router.js +76 -0
- package/dist/src/generator/etag_router.js.map +1 -0
- package/dist/src/generator/index.d.ts +5 -0
- package/dist/src/generator/index.js +6 -0
- package/dist/src/generator/index.js.map +1 -0
- package/dist/src/generator/response_enums.d.ts +6 -0
- package/dist/src/generator/response_enums.js +58 -0
- package/dist/src/generator/response_enums.js.map +1 -0
- package/dist/src/generator/router.d.ts +7 -0
- package/dist/src/generator/router.js +231 -0
- package/dist/src/generator/router.js.map +1 -0
- package/dist/src/generator/server_trait.d.ts +6 -0
- package/dist/src/generator/server_trait.js +97 -0
- package/dist/src/generator/server_trait.js.map +1 -0
- package/dist/src/generator/types_file.d.ts +11 -0
- package/dist/src/generator/types_file.js +209 -0
- package/dist/src/generator/types_file.js.map +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib.js +1 -1
- package/dist/src/lib.js.map +1 -1
- package/dist/src/models/index.d.ts +2 -0
- package/dist/src/models/index.js +3 -0
- package/dist/src/models/index.js.map +1 -0
- package/dist/src/models/keys.d.ts +6 -0
- package/dist/src/models/keys.js +8 -0
- package/dist/src/models/keys.js.map +1 -0
- package/dist/src/models/types.d.ts +45 -0
- package/dist/src/models/types.js +2 -0
- package/dist/src/models/types.js.map +1 -0
- package/dist/src/parser/decorators.d.ts +18 -0
- package/dist/src/parser/decorators.js +28 -0
- package/dist/src/parser/decorators.js.map +1 -0
- package/dist/src/parser/index.d.ts +6 -0
- package/dist/src/parser/index.js +7 -0
- package/dist/src/parser/index.js.map +1 -0
- package/dist/src/parser/operations.d.ts +13 -0
- package/dist/src/parser/operations.js +127 -0
- package/dist/src/parser/operations.js.map +1 -0
- package/dist/src/parser/parameters.d.ts +5 -0
- package/dist/src/parser/parameters.js +98 -0
- package/dist/src/parser/parameters.js.map +1 -0
- package/dist/src/parser/responses.d.ts +13 -0
- package/dist/src/parser/responses.js +132 -0
- package/dist/src/parser/responses.js.map +1 -0
- package/dist/src/parser/routes.d.ts +4 -0
- package/dist/src/parser/routes.js +36 -0
- package/dist/src/parser/routes.js.map +1 -0
- package/dist/src/parser/types.d.ts +9 -0
- package/dist/src/parser/types.js +157 -0
- package/dist/src/parser/types.js.map +1 -0
- package/dist/test/etag_cache.test.d.ts +1 -0
- package/dist/test/etag_cache.test.js +86 -0
- package/dist/test/etag_cache.test.js.map +1 -0
- package/dist/test/test-host.d.ts +11 -0
- package/dist/test/test-host.js +28 -0
- package/dist/test/test-host.js.map +1 -1
- package/example/main.tsp +30 -1
- package/example/output-rust/Cargo.lock +48 -0
- package/example/output-rust/Cargo.toml +1 -0
- package/example/output-rust/src/generated/server.rs +153 -12
- package/example/output-rust/src/generated/types.rs +6 -0
- package/example/output-rust/src/main.rs +65 -27
- package/justfile +31 -2
- package/package.json +1 -1
- package/scripts/update-golden.js +36 -0
- package/src/decorators/cache_control.ts +14 -0
- package/src/decorators/etag_cache.ts +14 -0
- package/src/decorators/index.ts +6 -0
- package/src/decorators/rust_attr.ts +61 -0
- package/src/decorators/rust_derive.ts +55 -0
- package/src/decorators/rust_impl.ts +29 -0
- package/src/decorators/rust_self.ts +42 -0
- package/src/emitter.ts +18 -1654
- package/src/formatter/index.ts +2 -0
- package/src/formatter/mappings.ts +70 -0
- package/src/formatter/strings.ts +33 -0
- package/src/generator/etag_router.ts +97 -0
- package/src/generator/index.ts +5 -0
- package/src/generator/response_enums.ts +76 -0
- package/src/generator/router.ts +284 -0
- package/src/generator/server_trait.ts +134 -0
- package/src/generator/types_file.ts +297 -0
- package/src/index.ts +3 -1
- package/src/lib.ts +1 -1
- package/src/lib.tsp +3 -1
- package/src/models/index.ts +2 -0
- package/src/models/keys.ts +7 -0
- package/src/models/types.ts +54 -0
- package/src/parser/decorators.ts +34 -0
- package/src/parser/index.ts +6 -0
- package/src/parser/operations.ts +158 -0
- package/src/parser/parameters.ts +117 -0
- package/src/parser/responses.ts +170 -0
- package/src/parser/routes.ts +47 -0
- package/src/parser/types.ts +215 -0
- package/test/etag_cache.test.ts +104 -0
- package/test/golden/etag_cache/server.rs +110 -0
- package/test/golden/etag_cache/spec.tsp +20 -0
- package/test/golden/etag_cache/types.rs +13 -0
- package/test/test-host.ts +43 -0
|
@@ -9,6 +9,18 @@ use axum::response::IntoResponse;
|
|
|
9
9
|
use axum::Json;
|
|
10
10
|
use eyre::Result;
|
|
11
11
|
|
|
12
|
+
|
|
13
|
+
/// Pluggable ETag cache backend.
|
|
14
|
+
/// Implement this for Redis, Memcached, in-memory HashMap, or any store.
|
|
15
|
+
pub trait EtagCache {
|
|
16
|
+
/// Return the stored ETag string for `key`, or `None` if not cached.
|
|
17
|
+
fn get(&self, key: &str) -> Option<String>;
|
|
18
|
+
/// Store `etag` under `key`.
|
|
19
|
+
fn set(&self, key: &str, etag: &str);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
12
24
|
#[async_trait]
|
|
13
25
|
pub trait Server: Send + Sync {
|
|
14
26
|
type Claims: Send + Sync + 'static;
|
|
@@ -16,9 +28,15 @@ pub trait Server: Send + Sync {
|
|
|
16
28
|
|
|
17
29
|
async fn events_accounts_events(&self, account_id: String) -> Result<EventsAccountsEventsResponse>;
|
|
18
30
|
async fn pets_list(&self, first_query: String, second_query: String) -> Result<PetsListResponse>;
|
|
31
|
+
async fn items_list<C: EtagCache + Send + Sync>(
|
|
32
|
+
&self, cache: &C
|
|
33
|
+
) -> Result<ItemsListResponse>;
|
|
19
34
|
async fn items_get_item(&self, id: String) -> Result<ItemsGetItemResponse>;
|
|
20
35
|
async fn items_create_item(&mut self, body: Item) -> Result<ItemsCreateItemResponse>;
|
|
21
36
|
async fn items_update_item(&mut self, id: String, body: Item) -> Result<ItemsUpdateItemResponse>;
|
|
37
|
+
async fn articles_get_article<C: EtagCache + Send + Sync>(
|
|
38
|
+
&self, claims: Self::Claims, cache: &C, id: String
|
|
39
|
+
) -> Result<ArticlesGetArticleResponse>;
|
|
22
40
|
async fn consuming_consume_and_delete(self, id: String) -> Result<ConsumingConsumeAndDeleteResponse>;
|
|
23
41
|
async fn consuming_upload(&self, account_id: uuid::Uuid, body: Multipart) -> Result<ConsumingUploadResponse>;
|
|
24
42
|
}
|
|
@@ -50,6 +68,20 @@ impl IntoResponse for PetsListResponse {
|
|
|
50
68
|
}
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
#[allow(clippy::type_complexity)]
|
|
72
|
+
pub enum ItemsListResponse {
|
|
73
|
+
Ok(Json<Vec<Item>>),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
impl IntoResponse for ItemsListResponse {
|
|
77
|
+
fn into_response(self) -> axum::response::Response {
|
|
78
|
+
match self {
|
|
79
|
+
|
|
80
|
+
ItemsListResponse::Ok(body) => (StatusCode::OK, body).into_response(),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
#[allow(clippy::type_complexity)]
|
|
54
86
|
pub enum ItemsGetItemResponse {
|
|
55
87
|
Ok(Json<Item>),
|
|
@@ -92,6 +124,22 @@ impl IntoResponse for ItemsUpdateItemResponse {
|
|
|
92
124
|
}
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
#[allow(clippy::type_complexity)]
|
|
128
|
+
pub enum ArticlesGetArticleResponse {
|
|
129
|
+
Ok(Json<Article>),
|
|
130
|
+
NotModified,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
impl IntoResponse for ArticlesGetArticleResponse {
|
|
134
|
+
fn into_response(self) -> axum::response::Response {
|
|
135
|
+
match self {
|
|
136
|
+
|
|
137
|
+
ArticlesGetArticleResponse::Ok(body) => (StatusCode::OK, body).into_response(),
|
|
138
|
+
ArticlesGetArticleResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
95
143
|
#[allow(clippy::type_complexity)]
|
|
96
144
|
pub enum ConsumingConsumeAndDeleteResponse {
|
|
97
145
|
Ok,
|
|
@@ -137,12 +185,6 @@ pub struct PetsListQuery {
|
|
|
137
185
|
pub second_query: String
|
|
138
186
|
}
|
|
139
187
|
|
|
140
|
-
#[derive(Debug, Clone, serde::Deserialize)]
|
|
141
|
-
pub struct ItemsGetItemQuery {
|
|
142
|
-
#[serde(rename = "id")]
|
|
143
|
-
pub id: String
|
|
144
|
-
}
|
|
145
|
-
|
|
146
188
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
147
189
|
pub struct ItemsUpdateItemQuery {
|
|
148
190
|
#[serde(rename = "id")]
|
|
@@ -195,17 +237,66 @@ where
|
|
|
195
237
|
}
|
|
196
238
|
}
|
|
197
239
|
|
|
240
|
+
pub async fn items_list_handler<S, C>(
|
|
241
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
242
|
+
axum::extract::State(cache): axum::extract::State<C>,
|
|
243
|
+
if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
|
|
244
|
+
) -> impl axum::response::IntoResponse
|
|
245
|
+
where
|
|
246
|
+
S: Server + Send + Sync + 'static,
|
|
247
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
248
|
+
C: EtagCache + Clone + Send + Sync + 'static,
|
|
249
|
+
{
|
|
250
|
+
// Check If-None-Match against the cache
|
|
251
|
+
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
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Forward to business logic
|
|
261
|
+
let result = service.items_list(&cache).await;
|
|
262
|
+
match result {
|
|
263
|
+
Ok(response) => {
|
|
264
|
+
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
|
+
);
|
|
270
|
+
}
|
|
271
|
+
res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
|
|
272
|
+
res
|
|
273
|
+
}
|
|
274
|
+
Err(e) => (
|
|
275
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
276
|
+
format!("Internal error: {e}"),
|
|
277
|
+
)
|
|
278
|
+
.into_response(),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
198
282
|
pub async fn items_get_item_handler<S>(
|
|
199
283
|
axum::extract::State(service): axum::extract::State<S>,
|
|
200
|
-
|
|
284
|
+
Path(id): Path<String>,
|
|
201
285
|
) -> impl axum::response::IntoResponse
|
|
202
286
|
where
|
|
203
287
|
S: Server+ Clone + Send + Sync + 'static,
|
|
204
288
|
S::Claims: Send + Sync + Clone + 'static,
|
|
205
289
|
{
|
|
206
|
-
let result = service.items_get_item(
|
|
290
|
+
let result = service.items_get_item(id).await;
|
|
207
291
|
match result {
|
|
208
|
-
Ok(response) =>
|
|
292
|
+
Ok(response) => {
|
|
293
|
+
let mut res = response.into_response();
|
|
294
|
+
res.headers_mut().insert(
|
|
295
|
+
axum::http::header::CACHE_CONTROL,
|
|
296
|
+
axum::http::HeaderValue::from_static("no-cache"),
|
|
297
|
+
);
|
|
298
|
+
res
|
|
299
|
+
}
|
|
209
300
|
Err(e) => (
|
|
210
301
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
211
302
|
format!("Internal error: {e}"),
|
|
@@ -253,6 +344,50 @@ where
|
|
|
253
344
|
}
|
|
254
345
|
}
|
|
255
346
|
|
|
347
|
+
pub async fn articles_get_article_handler<S, C>(
|
|
348
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
349
|
+
axum::extract::State(cache): axum::extract::State<C>,
|
|
350
|
+
if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
|
|
351
|
+
Extension(claims): Extension<S::Claims>,
|
|
352
|
+
Path(id): Path<String>,
|
|
353
|
+
) -> impl axum::response::IntoResponse
|
|
354
|
+
where
|
|
355
|
+
S: Server + Send + Sync + 'static,
|
|
356
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
357
|
+
C: EtagCache + Clone + Send + Sync + 'static,
|
|
358
|
+
{
|
|
359
|
+
// 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
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Forward to business logic
|
|
370
|
+
let result = service.articles_get_article(claims, &cache, id).await;
|
|
371
|
+
match result {
|
|
372
|
+
Ok(response) => {
|
|
373
|
+
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
|
+
);
|
|
379
|
+
}
|
|
380
|
+
res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
|
|
381
|
+
res
|
|
382
|
+
}
|
|
383
|
+
Err(e) => (
|
|
384
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
385
|
+
format!("Internal error: {e}"),
|
|
386
|
+
)
|
|
387
|
+
.into_response(),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
256
391
|
// NOTE: consuming_consume_and_delete takes self and cannot be used with the router pattern.
|
|
257
392
|
// It consumes the service, so you need to implement your own handler pattern.
|
|
258
393
|
pub async fn consuming_consume_and_delete_handler<S>(
|
|
@@ -294,21 +429,27 @@ where
|
|
|
294
429
|
}
|
|
295
430
|
}
|
|
296
431
|
|
|
297
|
-
pub fn create_router<S, M>(service: S, middleware: M) -> Router
|
|
432
|
+
pub fn create_router<S, C, M>(service: S, cache: C, middleware: M) -> Router
|
|
298
433
|
where
|
|
299
434
|
S: Server + Clone + Send + Sync + 'static,
|
|
300
435
|
S::Claims: Send + Sync + Clone + 'static,
|
|
436
|
+
C: EtagCache + Clone + Send + Sync + 'static + axum::extract::FromRef<S>,
|
|
301
437
|
M: FnOnce(Router<S>) -> Router<S> + Clone + Send + Sync + 'static,
|
|
302
438
|
{
|
|
303
|
-
let mut router = Router::new();
|
|
439
|
+
let mut router = Router::new().with_state(cache);
|
|
304
440
|
let public = Router::new()
|
|
305
441
|
.route("/events/{accountId}", get(events_accounts_events_handler::<S>))
|
|
306
442
|
.route("/pets", get(pets_list_handler::<S>))
|
|
307
|
-
.route("/items", get(
|
|
443
|
+
.route("/items", get(items_list_handler::<S, C>))
|
|
444
|
+
.route("/items/{id}", get(items_get_item_handler::<S>))
|
|
308
445
|
.route("/items", post(items_create_item_handler::<S>))
|
|
309
446
|
.route("/items", put(items_update_item_handler::<S>))
|
|
310
447
|
.route("/consuming", post(consuming_upload_handler::<S>))
|
|
311
448
|
;
|
|
312
449
|
router = router.merge(public);
|
|
450
|
+
let protected = Router::new()
|
|
451
|
+
.route("/articles", get(articles_get_article_handler::<S, C>))
|
|
452
|
+
;
|
|
453
|
+
router = router.merge(middleware(protected));
|
|
313
454
|
router.with_state(service)
|
|
314
455
|
}
|
|
@@ -16,6 +16,12 @@ pub struct Item {
|
|
|
16
16
|
pub value: i32,
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
20
|
+
pub struct Article {
|
|
21
|
+
pub id: String,
|
|
22
|
+
pub title: String,
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
20
26
|
#[serde(untagged)]
|
|
21
27
|
pub enum MyEvent {
|
|
@@ -2,20 +2,43 @@ mod generated;
|
|
|
2
2
|
|
|
3
3
|
use async_trait::async_trait;
|
|
4
4
|
use axum::Json;
|
|
5
|
+
use axum::extract::FromRef;
|
|
5
6
|
use axum::response::IntoResponse;
|
|
6
7
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
7
8
|
use generated::server::{
|
|
8
|
-
ConsumingConsumeAndDeleteResponse, ConsumingUploadResponse,
|
|
9
|
-
|
|
10
|
-
Server,
|
|
9
|
+
ArticlesGetArticleResponse, ConsumingConsumeAndDeleteResponse, ConsumingUploadResponse,
|
|
10
|
+
EtagCache, EventsAccountsEventsResponse, ItemsCreateItemResponse, ItemsGetItemResponse,
|
|
11
|
+
ItemsListResponse, ItemsUpdateItemResponse, PetsListResponse, Server,
|
|
11
12
|
};
|
|
13
|
+
use std::collections::HashMap;
|
|
12
14
|
use std::convert::Infallible;
|
|
15
|
+
use std::sync::{Arc, Mutex};
|
|
13
16
|
use std::time::Duration;
|
|
14
17
|
use tokio::time::interval;
|
|
15
|
-
use tokio_stream::{StreamExt as _
|
|
18
|
+
use tokio_stream::{wrappers::IntervalStream, StreamExt as _};
|
|
19
|
+
|
|
20
|
+
#[derive(Clone, Default)]
|
|
21
|
+
struct InMemoryCache(Arc<Mutex<HashMap<String, String>>>);
|
|
22
|
+
|
|
23
|
+
impl EtagCache for InMemoryCache {
|
|
24
|
+
fn get(&self, key: &str) -> Option<String> {
|
|
25
|
+
self.0.lock().unwrap().get(key).cloned()
|
|
26
|
+
}
|
|
27
|
+
fn set(&self, key: &str, etag: &str) {
|
|
28
|
+
self.0.lock().unwrap().insert(key.to_owned(), etag.to_owned());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
16
31
|
|
|
17
32
|
#[derive(Clone)]
|
|
18
|
-
struct AppState
|
|
33
|
+
struct AppState {
|
|
34
|
+
cache: InMemoryCache,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
impl FromRef<AppState> for InMemoryCache {
|
|
38
|
+
fn from_ref(state: &AppState) -> Self {
|
|
39
|
+
state.cache.clone()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
|
|
20
43
|
#[async_trait]
|
|
21
44
|
impl Server for AppState {
|
|
@@ -44,6 +67,17 @@ impl Server for AppState {
|
|
|
44
67
|
])))
|
|
45
68
|
}
|
|
46
69
|
|
|
70
|
+
async fn items_list<C: EtagCache + Send + Sync>(
|
|
71
|
+
&self,
|
|
72
|
+
cache: &C,
|
|
73
|
+
) -> eyre::Result<ItemsListResponse> {
|
|
74
|
+
cache.set("article-list", "\"items-etag\"");
|
|
75
|
+
Ok(ItemsListResponse::Ok(Json(vec![generated::types::Item {
|
|
76
|
+
name: "list-item".to_string(),
|
|
77
|
+
value: 123,
|
|
78
|
+
}])))
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
async fn items_get_item(&self, _id: String) -> eyre::Result<ItemsGetItemResponse> {
|
|
48
82
|
Ok(ItemsGetItemResponse::Ok(Json(generated::types::Item {
|
|
49
83
|
name: "test".to_string(),
|
|
@@ -72,6 +106,22 @@ impl Server for AppState {
|
|
|
72
106
|
})))
|
|
73
107
|
}
|
|
74
108
|
|
|
109
|
+
async fn articles_get_article<C: EtagCache + Send + Sync>(
|
|
110
|
+
&self,
|
|
111
|
+
_claims: Self::Claims,
|
|
112
|
+
cache: &C,
|
|
113
|
+
id: String,
|
|
114
|
+
) -> eyre::Result<ArticlesGetArticleResponse> {
|
|
115
|
+
let etag = format!("\"article-etag-{}\"", id);
|
|
116
|
+
cache.set(&format!("articles_get_article:{}", id), &etag);
|
|
117
|
+
Ok(ArticlesGetArticleResponse::Ok(Json(
|
|
118
|
+
generated::types::Article {
|
|
119
|
+
id,
|
|
120
|
+
title: "Example Article".to_string(),
|
|
121
|
+
},
|
|
122
|
+
)))
|
|
123
|
+
}
|
|
124
|
+
|
|
75
125
|
async fn consuming_consume_and_delete(
|
|
76
126
|
self,
|
|
77
127
|
_id: String,
|
|
@@ -90,7 +140,10 @@ impl Server for AppState {
|
|
|
90
140
|
|
|
91
141
|
#[tokio::main]
|
|
92
142
|
async fn main() {
|
|
93
|
-
let
|
|
143
|
+
let state = AppState {
|
|
144
|
+
cache: InMemoryCache::default(),
|
|
145
|
+
};
|
|
146
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
94
147
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
95
148
|
println!("Server running on {}", listener.local_addr().unwrap());
|
|
96
149
|
axum::serve(listener, app).await.unwrap();
|
|
@@ -105,26 +158,24 @@ mod tests {
|
|
|
105
158
|
|
|
106
159
|
#[tokio::test]
|
|
107
160
|
async fn test_sse_endpoint() {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let app = generated::server::create_router(
|
|
161
|
+
let state = AppState {
|
|
162
|
+
cache: InMemoryCache::default(),
|
|
163
|
+
};
|
|
164
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
112
165
|
|
|
113
166
|
let req = Request::builder()
|
|
114
|
-
.uri("/events/123")
|
|
115
|
-
// Wait, actually I should check if axum matches {accountId} or if it fails.
|
|
167
|
+
.uri("/events/123")
|
|
116
168
|
.body(axum::body::Body::empty())
|
|
117
169
|
.unwrap();
|
|
118
170
|
|
|
119
171
|
let response = app.oneshot(req).await.unwrap();
|
|
120
172
|
|
|
121
|
-
// If axum doesn't match, we will get 404. Let's handle both.
|
|
122
173
|
if response.status() == 404 {
|
|
123
174
|
let req2 = Request::builder()
|
|
124
175
|
.uri("/events/{accountId}")
|
|
125
176
|
.body(axum::body::Body::empty())
|
|
126
177
|
.unwrap();
|
|
127
|
-
let app = generated::server::create_router(
|
|
178
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
128
179
|
let response2 = app.oneshot(req2).await.unwrap();
|
|
129
180
|
assert_eq!(response2.status(), 200);
|
|
130
181
|
return;
|
|
@@ -136,7 +187,6 @@ mod tests {
|
|
|
136
187
|
"text/event-stream"
|
|
137
188
|
);
|
|
138
189
|
|
|
139
|
-
// Read body frames sequentially
|
|
140
190
|
let mut body = response.into_body();
|
|
141
191
|
let mut timestamps = Vec::new();
|
|
142
192
|
|
|
@@ -149,18 +199,6 @@ mod tests {
|
|
|
149
199
|
}
|
|
150
200
|
}
|
|
151
201
|
|
|
152
|
-
// We should have received 3 events
|
|
153
202
|
assert_eq!(timestamps.len(), 3);
|
|
154
|
-
|
|
155
|
-
// Verify delay between events (approx 100ms)
|
|
156
|
-
for i in 1..timestamps.len() {
|
|
157
|
-
let diff = timestamps[i].duration_since(timestamps[i - 1]);
|
|
158
|
-
// Give it some slack for CI / slow execution, but ensure it's not instantaneous
|
|
159
|
-
assert!(
|
|
160
|
-
diff.as_millis() >= 80,
|
|
161
|
-
"Events were too fast! Diff: {}ms",
|
|
162
|
-
diff.as_millis()
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
203
|
}
|
|
166
204
|
}
|
package/justfile
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
set dotenv-load := true
|
|
2
2
|
|
|
3
|
+
# ─── Build & Test ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
# Build TypeScript and run all Jest/node:test tests.
|
|
6
|
+
test:
|
|
7
|
+
npm run build && npm test
|
|
8
|
+
|
|
9
|
+
# Lint and format source.
|
|
10
|
+
lint:
|
|
11
|
+
npm run lint && npm run format:check
|
|
12
|
+
|
|
13
|
+
# ─── Rust Validation ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
# Re-compile the example TypeSpec spec and validate the generated Rust code.
|
|
3
16
|
check-rust:
|
|
4
17
|
cd example && tsp compile . && cd output-rust && cargo check && cargo clippy
|
|
5
18
|
|
|
6
|
-
|
|
7
|
-
|
|
19
|
+
# ─── Combined ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
# Run everything: TypeScript tests + Rust check. Use this before a release.
|
|
22
|
+
check-all: test check-rust
|
|
23
|
+
@echo "✓ All checks passed."
|
|
24
|
+
|
|
25
|
+
# ─── Golden File Helpers ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
# Re-generate and overwrite the golden file for a given feature.
|
|
28
|
+
# Usage: just update-golden etag_cache
|
|
29
|
+
update-golden feature:
|
|
30
|
+
node scripts/update-golden.js {{feature}}
|
|
31
|
+
|
|
32
|
+
# ─── Release ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# Publish to npm. Run check-all first.
|
|
35
|
+
publish: check-all
|
|
36
|
+
npm publish
|
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { emit } from '../dist/test/test-host.js';
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
const feature = process.argv[2];
|
|
7
|
+
if (!feature) {
|
|
8
|
+
console.error('Usage: node scripts/update-golden.js <feature>');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const specPath = resolve(process.cwd(), `test/golden/${feature}/spec.tsp`);
|
|
13
|
+
const spec = readFileSync(specPath, 'utf8');
|
|
14
|
+
|
|
15
|
+
console.log(`Emitting spec for ${feature}...`);
|
|
16
|
+
const out = await emit(spec);
|
|
17
|
+
|
|
18
|
+
if (out['types.rs']) {
|
|
19
|
+
const p = resolve(process.cwd(), `test/golden/${feature}/types.rs`);
|
|
20
|
+
writeFileSync(p, out['types.rs']);
|
|
21
|
+
console.log(`Updated types.rs`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (out['server.rs']) {
|
|
25
|
+
const p = resolve(process.cwd(), `test/golden/${feature}/server.rs`);
|
|
26
|
+
writeFileSync(p, out['server.rs']);
|
|
27
|
+
console.log(`Updated server.rs`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`Golden files updated for: ${feature}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main().catch(err => {
|
|
34
|
+
console.error(err);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DecoratorContext, Operation } from "@typespec/compiler";
|
|
2
|
+
import { cacheControlKey } from "../models/keys.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @cacheControl decorator implementation.
|
|
6
|
+
* Stores the cache control value on the operation.
|
|
7
|
+
*/
|
|
8
|
+
export function $cacheControl(
|
|
9
|
+
context: DecoratorContext,
|
|
10
|
+
target: Operation,
|
|
11
|
+
value: string,
|
|
12
|
+
) {
|
|
13
|
+
context.program.stateMap(cacheControlKey).set(target, value);
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DecoratorContext, Operation } from "@typespec/compiler";
|
|
2
|
+
import { etagCacheKey } from "../models/keys.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @etagCache decorator implementation.
|
|
6
|
+
* Stores the optional etagKey on the operation.
|
|
7
|
+
*/
|
|
8
|
+
export function $etagCache(
|
|
9
|
+
context: DecoratorContext,
|
|
10
|
+
target: Operation,
|
|
11
|
+
etagKey?: string,
|
|
12
|
+
) {
|
|
13
|
+
context.program.stateMap(etagCacheKey).set(target, etagKey ?? true);
|
|
14
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DecoratorContext,
|
|
3
|
+
getNamespaceFullName,
|
|
4
|
+
Namespace,
|
|
5
|
+
Type,
|
|
6
|
+
} from "@typespec/compiler";
|
|
7
|
+
import { rustAttrKey } from "../models/keys.js";
|
|
8
|
+
import { RustAttrInfo } from "../models/types.js";
|
|
9
|
+
|
|
10
|
+
export function $rustAttr(
|
|
11
|
+
context: DecoratorContext,
|
|
12
|
+
target: Type,
|
|
13
|
+
attr: string,
|
|
14
|
+
) {
|
|
15
|
+
if (
|
|
16
|
+
target.kind !== "Model" &&
|
|
17
|
+
target.kind !== "Enum" &&
|
|
18
|
+
target.kind !== "ModelProperty"
|
|
19
|
+
) {
|
|
20
|
+
context.program.reportDiagnostic({
|
|
21
|
+
code: "rust-attr-invalid-target",
|
|
22
|
+
message: `@rustAttr can only be applied to models, enums, and model properties`,
|
|
23
|
+
severity: "error",
|
|
24
|
+
target: context.decoratorTarget,
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let ns: Namespace | undefined;
|
|
30
|
+
if (target.kind === "Model") {
|
|
31
|
+
ns = target.namespace;
|
|
32
|
+
} else if (target.kind === "Enum") {
|
|
33
|
+
ns = target.namespace;
|
|
34
|
+
} else if (target.kind === "ModelProperty") {
|
|
35
|
+
ns = target.model?.namespace;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const nsFullName = ns ? getNamespaceFullName(ns) : "";
|
|
39
|
+
if (!nsFullName.startsWith("TypeSpec")) {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
const info = (target as any)[rustAttrKey] as RustAttrInfo | undefined;
|
|
42
|
+
if (info) {
|
|
43
|
+
if (!info.attrs.includes(attr)) {
|
|
44
|
+
info.attrs.push(attr);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
(target as any)[rustAttrKey] = { attrs: [attr] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function $rustAttrs(
|
|
54
|
+
context: DecoratorContext,
|
|
55
|
+
target: Type,
|
|
56
|
+
...attrs: string[]
|
|
57
|
+
) {
|
|
58
|
+
for (const attr of attrs) {
|
|
59
|
+
$rustAttr(context, target, attr);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DecoratorContext,
|
|
3
|
+
getNamespaceFullName,
|
|
4
|
+
Type,
|
|
5
|
+
} from "@typespec/compiler";
|
|
6
|
+
import { rustDeriveKey } from "../models/keys.js";
|
|
7
|
+
import { RustDeriveInfo } from "../models/types.js";
|
|
8
|
+
|
|
9
|
+
export function $rustDerive(
|
|
10
|
+
context: DecoratorContext,
|
|
11
|
+
target: Type,
|
|
12
|
+
derive: string,
|
|
13
|
+
) {
|
|
14
|
+
if (target.kind !== "Model" && target.kind !== "Enum") {
|
|
15
|
+
context.program.reportDiagnostic({
|
|
16
|
+
code: "rust-derive-invalid-target",
|
|
17
|
+
message: `@rustDerive can only be applied to models and enums`,
|
|
18
|
+
severity: "error",
|
|
19
|
+
target: context.decoratorTarget,
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ns =
|
|
25
|
+
target.kind === "Model"
|
|
26
|
+
? target.namespace
|
|
27
|
+
? getNamespaceFullName(target.namespace)
|
|
28
|
+
: ""
|
|
29
|
+
: target.namespace
|
|
30
|
+
? getNamespaceFullName(target.namespace)
|
|
31
|
+
: "";
|
|
32
|
+
|
|
33
|
+
if (!ns.startsWith("TypeSpec")) {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
const info = (target as any)[rustDeriveKey] as RustDeriveInfo | undefined;
|
|
36
|
+
if (info) {
|
|
37
|
+
if (!info.derives.includes(derive)) {
|
|
38
|
+
info.derives.push(derive);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
(target as any)[rustDeriveKey] = { derives: [derive] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function $rustDerives(
|
|
48
|
+
context: DecoratorContext,
|
|
49
|
+
target: Type,
|
|
50
|
+
...derives: string[]
|
|
51
|
+
) {
|
|
52
|
+
for (const derive of derives) {
|
|
53
|
+
$rustDerive(context, target, derive);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DecoratorContext,
|
|
3
|
+
getNamespaceFullName,
|
|
4
|
+
Type,
|
|
5
|
+
} from "@typespec/compiler";
|
|
6
|
+
import { rustImplKey } from "../models/keys.js";
|
|
7
|
+
|
|
8
|
+
export function $rustImpl(
|
|
9
|
+
context: DecoratorContext,
|
|
10
|
+
target: Type,
|
|
11
|
+
impl: string,
|
|
12
|
+
) {
|
|
13
|
+
if (target.kind !== "Model") {
|
|
14
|
+
context.program.reportDiagnostic({
|
|
15
|
+
code: "rust-impl-invalid-target",
|
|
16
|
+
message: `@rustImpl can only be applied to models`,
|
|
17
|
+
severity: "error",
|
|
18
|
+
target: context.decoratorTarget,
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ns = target.namespace ? getNamespaceFullName(target.namespace) : "";
|
|
24
|
+
|
|
25
|
+
if (!ns.startsWith("TypeSpec")) {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
(target as any)[rustImplKey] = { impl: impl };
|
|
28
|
+
}
|
|
29
|
+
}
|