typespec-rust-emitter 0.11.0 → 0.13.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/AGENTS.md +83 -79
- package/CHANGELOG.md +60 -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 -1252
- 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 +30 -0
- package/dist/src/generator/etag_router.js +123 -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 +227 -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 +62 -0
- package/dist/test/etag_cache.test.js.map +1 -0
- package/dist/test/hello.test.js +23 -0
- package/dist/test/hello.test.js.map +1 -1
- 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 +48 -1
- package/example/output-rust/Cargo.lock +75 -0
- package/example/output-rust/Cargo.toml +2 -1
- package/example/output-rust/src/generated/server.rs +163 -12
- package/example/output-rust/src/generated/types.rs +8 -0
- package/example/output-rust/src/main.rs +75 -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 -1623
- 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 +147 -0
- package/src/generator/index.ts +5 -0
- package/src/generator/response_enums.ts +76 -0
- package/src/generator/router.ts +280 -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 +69 -0
- package/test/golden/etag_cache/server.rs +109 -0
- package/test/golden/etag_cache/spec.tsp +20 -0
- package/test/golden/etag_cache/types.rs +13 -0
- package/test/hello.test.ts +24 -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,10 +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(&self) -> Result<ItemsListResponse>;
|
|
19
32
|
async fn items_get_item(&self, id: String) -> Result<ItemsGetItemResponse>;
|
|
20
33
|
async fn items_create_item(&mut self, body: Item) -> Result<ItemsCreateItemResponse>;
|
|
21
34
|
async fn items_update_item(&mut self, id: String, body: Item) -> Result<ItemsUpdateItemResponse>;
|
|
35
|
+
async fn articles_get_article<C: EtagCache + Send + Sync>(
|
|
36
|
+
&self, cache: &C, id: String
|
|
37
|
+
) -> Result<ArticlesGetArticleResponse>;
|
|
22
38
|
async fn consuming_consume_and_delete(self, id: String) -> Result<ConsumingConsumeAndDeleteResponse>;
|
|
39
|
+
async fn consuming_upload(&self, account_id: uuid::Uuid, body: Multipart) -> Result<ConsumingUploadResponse>;
|
|
23
40
|
}
|
|
24
41
|
#[allow(clippy::type_complexity)]
|
|
25
42
|
pub enum EventsAccountsEventsResponse {
|
|
@@ -49,6 +66,20 @@ impl IntoResponse for PetsListResponse {
|
|
|
49
66
|
}
|
|
50
67
|
}
|
|
51
68
|
|
|
69
|
+
#[allow(clippy::type_complexity)]
|
|
70
|
+
pub enum ItemsListResponse {
|
|
71
|
+
Ok(Json<Vec<Item>>),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
impl IntoResponse for ItemsListResponse {
|
|
75
|
+
fn into_response(self) -> axum::response::Response {
|
|
76
|
+
match self {
|
|
77
|
+
|
|
78
|
+
ItemsListResponse::Ok(body) => (StatusCode::OK, body).into_response(),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
#[allow(clippy::type_complexity)]
|
|
53
84
|
pub enum ItemsGetItemResponse {
|
|
54
85
|
Ok(Json<Item>),
|
|
@@ -91,6 +122,22 @@ impl IntoResponse for ItemsUpdateItemResponse {
|
|
|
91
122
|
}
|
|
92
123
|
}
|
|
93
124
|
|
|
125
|
+
#[allow(clippy::type_complexity)]
|
|
126
|
+
pub enum ArticlesGetArticleResponse {
|
|
127
|
+
Ok(Json<Article>),
|
|
128
|
+
NotModified,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
impl IntoResponse for ArticlesGetArticleResponse {
|
|
132
|
+
fn into_response(self) -> axum::response::Response {
|
|
133
|
+
match self {
|
|
134
|
+
|
|
135
|
+
ArticlesGetArticleResponse::Ok(body) => (StatusCode::OK, body).into_response(),
|
|
136
|
+
ArticlesGetArticleResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
94
141
|
#[allow(clippy::type_complexity)]
|
|
95
142
|
pub enum ConsumingConsumeAndDeleteResponse {
|
|
96
143
|
Ok,
|
|
@@ -105,7 +152,25 @@ impl IntoResponse for ConsumingConsumeAndDeleteResponse {
|
|
|
105
152
|
}
|
|
106
153
|
}
|
|
107
154
|
|
|
108
|
-
|
|
155
|
+
#[allow(clippy::type_complexity)]
|
|
156
|
+
pub enum ConsumingUploadResponse {
|
|
157
|
+
Created(Json<String>),
|
|
158
|
+
BadRequest(Json<String>),
|
|
159
|
+
Unauthorized(Json<String>),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
impl IntoResponse for ConsumingUploadResponse {
|
|
163
|
+
fn into_response(self) -> axum::response::Response {
|
|
164
|
+
match self {
|
|
165
|
+
|
|
166
|
+
ConsumingUploadResponse::Created(body) => (StatusCode::CREATED, body).into_response(),
|
|
167
|
+
ConsumingUploadResponse::BadRequest(body) => (StatusCode::BAD_REQUEST, body).into_response(),
|
|
168
|
+
ConsumingUploadResponse::Unauthorized(body) => (StatusCode::UNAUTHORIZED, body).into_response(),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
use axum::extract::{Query, Multipart};
|
|
109
174
|
use axum::routing::{delete, get, post, put};
|
|
110
175
|
use axum::Router;
|
|
111
176
|
|
|
@@ -118,12 +183,6 @@ pub struct PetsListQuery {
|
|
|
118
183
|
pub second_query: String
|
|
119
184
|
}
|
|
120
185
|
|
|
121
|
-
#[derive(Debug, Clone, serde::Deserialize)]
|
|
122
|
-
pub struct ItemsGetItemQuery {
|
|
123
|
-
#[serde(rename = "id")]
|
|
124
|
-
pub id: String
|
|
125
|
-
}
|
|
126
|
-
|
|
127
186
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
128
187
|
pub struct ItemsUpdateItemQuery {
|
|
129
188
|
#[serde(rename = "id")]
|
|
@@ -176,15 +235,41 @@ where
|
|
|
176
235
|
}
|
|
177
236
|
}
|
|
178
237
|
|
|
238
|
+
pub async fn items_list_handler<S>(
|
|
239
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
240
|
+
|
|
241
|
+
) -> impl axum::response::IntoResponse
|
|
242
|
+
where
|
|
243
|
+
S: Server+ Clone + Send + Sync + 'static,
|
|
244
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
245
|
+
{
|
|
246
|
+
let result = service.items_list().await;
|
|
247
|
+
match result {
|
|
248
|
+
Ok(response) => {
|
|
249
|
+
let mut res = response.into_response();
|
|
250
|
+
res.headers_mut().insert(
|
|
251
|
+
axum::http::header::CACHE_CONTROL,
|
|
252
|
+
axum::http::HeaderValue::from_static("no-cache"),
|
|
253
|
+
);
|
|
254
|
+
res
|
|
255
|
+
}
|
|
256
|
+
Err(e) => (
|
|
257
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
258
|
+
format!("Internal error: {e}"),
|
|
259
|
+
)
|
|
260
|
+
.into_response(),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
179
264
|
pub async fn items_get_item_handler<S>(
|
|
180
265
|
axum::extract::State(service): axum::extract::State<S>,
|
|
181
|
-
|
|
266
|
+
Path(id): Path<String>,
|
|
182
267
|
) -> impl axum::response::IntoResponse
|
|
183
268
|
where
|
|
184
269
|
S: Server+ Clone + Send + Sync + 'static,
|
|
185
270
|
S::Claims: Send + Sync + Clone + 'static,
|
|
186
271
|
{
|
|
187
|
-
let result = service.items_get_item(
|
|
272
|
+
let result = service.items_get_item(id).await;
|
|
188
273
|
match result {
|
|
189
274
|
Ok(response) => response.into_response(),
|
|
190
275
|
Err(e) => (
|
|
@@ -234,6 +319,48 @@ where
|
|
|
234
319
|
}
|
|
235
320
|
}
|
|
236
321
|
|
|
322
|
+
pub async fn articles_get_article_handler<S, C>(
|
|
323
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
324
|
+
axum::extract::State(cache): axum::extract::State<C>,
|
|
325
|
+
if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
|
|
326
|
+
Path(id): Path<String>,
|
|
327
|
+
) -> impl axum::response::IntoResponse
|
|
328
|
+
where
|
|
329
|
+
S: Server + Send + Sync + 'static,
|
|
330
|
+
C: EtagCache + Clone + Send + Sync + 'static,
|
|
331
|
+
{
|
|
332
|
+
// Check If-None-Match against the cache
|
|
333
|
+
let cache_key = "article-list";
|
|
334
|
+
if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
|
|
335
|
+
// If the client's ETag matches, respond 304 immediately
|
|
336
|
+
if stored_etag == format!("{:?}", inm).trim_matches('"') {
|
|
337
|
+
let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
|
|
338
|
+
res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
|
|
339
|
+
return res;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Forward to business logic
|
|
343
|
+
let result = service.articles_get_article(&cache, id).await;
|
|
344
|
+
match result {
|
|
345
|
+
Ok(response) => {
|
|
346
|
+
let mut res = response.into_response();
|
|
347
|
+
if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
|
|
348
|
+
res.headers_mut().insert(
|
|
349
|
+
axum::http::header::ETAG,
|
|
350
|
+
axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
res.headers_mut().insert(axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("public, max-age=3600"));
|
|
354
|
+
res
|
|
355
|
+
}
|
|
356
|
+
Err(e) => (
|
|
357
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
358
|
+
format!("Internal error: {e}"),
|
|
359
|
+
)
|
|
360
|
+
.into_response(),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
237
364
|
// NOTE: consuming_consume_and_delete takes self and cannot be used with the router pattern.
|
|
238
365
|
// It consumes the service, so you need to implement your own handler pattern.
|
|
239
366
|
pub async fn consuming_consume_and_delete_handler<S>(
|
|
@@ -255,19 +382,43 @@ where
|
|
|
255
382
|
}
|
|
256
383
|
}
|
|
257
384
|
|
|
258
|
-
pub fn
|
|
385
|
+
pub async fn consuming_upload_handler<S>(
|
|
386
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
387
|
+
Path(account_id): Path<uuid::Uuid>,
|
|
388
|
+
multipart: axum::extract::Multipart,
|
|
389
|
+
) -> impl axum::response::IntoResponse
|
|
390
|
+
where
|
|
391
|
+
S: Server+ Clone + Send + Sync + 'static,
|
|
392
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
393
|
+
{
|
|
394
|
+
let result = service.consuming_upload(account_id, multipart).await;
|
|
395
|
+
match result {
|
|
396
|
+
Ok(response) => response.into_response(),
|
|
397
|
+
Err(e) => (
|
|
398
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
399
|
+
format!("Internal error: {e}"),
|
|
400
|
+
)
|
|
401
|
+
.into_response(),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
pub fn create_router<S, C, M>(service: S, cache: C, middleware: M) -> Router
|
|
259
406
|
where
|
|
260
407
|
S: Server + Clone + Send + Sync + 'static,
|
|
261
408
|
S::Claims: Send + Sync + Clone + 'static,
|
|
409
|
+
C: EtagCache + Clone + Send + Sync + 'static + axum::extract::FromRef<S>,
|
|
262
410
|
M: FnOnce(Router<S>) -> Router<S> + Clone + Send + Sync + 'static,
|
|
263
411
|
{
|
|
264
|
-
let mut router = Router::new();
|
|
412
|
+
let mut router = Router::new().with_state(cache);
|
|
265
413
|
let public = Router::new()
|
|
266
414
|
.route("/events/{accountId}", get(events_accounts_events_handler::<S>))
|
|
267
415
|
.route("/pets", get(pets_list_handler::<S>))
|
|
268
|
-
.route("/items", get(
|
|
416
|
+
.route("/items", get(items_list_handler::<S>))
|
|
417
|
+
.route("/items/{id}", get(items_get_item_handler::<S>))
|
|
269
418
|
.route("/items", post(items_create_item_handler::<S>))
|
|
270
419
|
.route("/items", put(items_update_item_handler::<S>))
|
|
420
|
+
.route("/articles", get(articles_get_article_handler::<S, C>))
|
|
421
|
+
.route("/consuming", post(consuming_upload_handler::<S>))
|
|
271
422
|
;
|
|
272
423
|
router = router.merge(public);
|
|
273
424
|
router.with_state(service)
|
|
@@ -16,9 +16,17 @@ 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 {
|
|
22
28
|
Variant1(MyEventData),
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
pub type Uuid = uuid::Uuid;
|
|
32
|
+
|
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
mod generated;
|
|
2
2
|
|
|
3
3
|
use async_trait::async_trait;
|
|
4
|
+
use axum::Json;
|
|
5
|
+
use axum::extract::FromRef;
|
|
4
6
|
use axum::response::IntoResponse;
|
|
5
7
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
|
6
|
-
use
|
|
7
|
-
|
|
8
|
+
use generated::server::{
|
|
9
|
+
ArticlesGetArticleResponse, ConsumingConsumeAndDeleteResponse, ConsumingUploadResponse,
|
|
10
|
+
EtagCache, EventsAccountsEventsResponse, ItemsCreateItemResponse, ItemsGetItemResponse,
|
|
11
|
+
ItemsListResponse, ItemsUpdateItemResponse, PetsListResponse, Server,
|
|
12
|
+
};
|
|
13
|
+
use std::collections::HashMap;
|
|
8
14
|
use std::convert::Infallible;
|
|
15
|
+
use std::sync::{Arc, Mutex};
|
|
9
16
|
use std::time::Duration;
|
|
10
17
|
use tokio::time::interval;
|
|
11
|
-
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
|
+
}
|
|
12
31
|
|
|
13
32
|
#[derive(Clone)]
|
|
14
|
-
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
|
+
}
|
|
15
42
|
|
|
16
43
|
#[async_trait]
|
|
17
44
|
impl Server for AppState {
|
|
@@ -34,7 +61,17 @@ impl Server for AppState {
|
|
|
34
61
|
_first_query: String,
|
|
35
62
|
_second_query: String,
|
|
36
63
|
) -> eyre::Result<PetsListResponse> {
|
|
37
|
-
Ok(PetsListResponse::Ok(Json(vec![
|
|
64
|
+
Ok(PetsListResponse::Ok(Json(vec![
|
|
65
|
+
"pet1".to_string(),
|
|
66
|
+
"pet2".to_string(),
|
|
67
|
+
])))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async fn items_list(&self) -> eyre::Result<ItemsListResponse> {
|
|
71
|
+
Ok(ItemsListResponse::Ok(Json(vec![generated::types::Item {
|
|
72
|
+
name: "list-item".to_string(),
|
|
73
|
+
value: 123,
|
|
74
|
+
}])))
|
|
38
75
|
}
|
|
39
76
|
|
|
40
77
|
async fn items_get_item(&self, _id: String) -> eyre::Result<ItemsGetItemResponse> {
|
|
@@ -65,17 +102,43 @@ impl Server for AppState {
|
|
|
65
102
|
})))
|
|
66
103
|
}
|
|
67
104
|
|
|
105
|
+
async fn articles_get_article<C: EtagCache + Send + Sync>(
|
|
106
|
+
&self,
|
|
107
|
+
cache: &C,
|
|
108
|
+
id: String,
|
|
109
|
+
) -> eyre::Result<ArticlesGetArticleResponse> {
|
|
110
|
+
let etag = format!("\"article-etag-{}\"", id);
|
|
111
|
+
cache.set(&format!("articles_get_article:{}", id), &etag);
|
|
112
|
+
Ok(ArticlesGetArticleResponse::Ok(Json(
|
|
113
|
+
generated::types::Article {
|
|
114
|
+
id,
|
|
115
|
+
title: "Example Article".to_string(),
|
|
116
|
+
},
|
|
117
|
+
)))
|
|
118
|
+
}
|
|
119
|
+
|
|
68
120
|
async fn consuming_consume_and_delete(
|
|
69
121
|
self,
|
|
70
122
|
_id: String,
|
|
71
123
|
) -> eyre::Result<ConsumingConsumeAndDeleteResponse> {
|
|
72
124
|
Ok(ConsumingConsumeAndDeleteResponse::Ok)
|
|
73
125
|
}
|
|
126
|
+
|
|
127
|
+
async fn consuming_upload(
|
|
128
|
+
&self,
|
|
129
|
+
_account_id: uuid::Uuid,
|
|
130
|
+
_multipart: axum::extract::Multipart,
|
|
131
|
+
) -> eyre::Result<ConsumingUploadResponse> {
|
|
132
|
+
Ok(ConsumingUploadResponse::Created(Json("ok".to_string())))
|
|
133
|
+
}
|
|
74
134
|
}
|
|
75
135
|
|
|
76
136
|
#[tokio::main]
|
|
77
137
|
async fn main() {
|
|
78
|
-
let
|
|
138
|
+
let state = AppState {
|
|
139
|
+
cache: InMemoryCache::default(),
|
|
140
|
+
};
|
|
141
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
79
142
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
80
143
|
println!("Server running on {}", listener.local_addr().unwrap());
|
|
81
144
|
axum::serve(listener, app).await.unwrap();
|
|
@@ -90,26 +153,24 @@ mod tests {
|
|
|
90
153
|
|
|
91
154
|
#[tokio::test]
|
|
92
155
|
async fn test_sse_endpoint() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
let app = generated::server::create_router(
|
|
156
|
+
let state = AppState {
|
|
157
|
+
cache: InMemoryCache::default(),
|
|
158
|
+
};
|
|
159
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
97
160
|
|
|
98
161
|
let req = Request::builder()
|
|
99
|
-
.uri("/events/123")
|
|
100
|
-
// Wait, actually I should check if axum matches {accountId} or if it fails.
|
|
162
|
+
.uri("/events/123")
|
|
101
163
|
.body(axum::body::Body::empty())
|
|
102
164
|
.unwrap();
|
|
103
165
|
|
|
104
166
|
let response = app.oneshot(req).await.unwrap();
|
|
105
167
|
|
|
106
|
-
// If axum doesn't match, we will get 404. Let's handle both.
|
|
107
168
|
if response.status() == 404 {
|
|
108
169
|
let req2 = Request::builder()
|
|
109
170
|
.uri("/events/{accountId}")
|
|
110
171
|
.body(axum::body::Body::empty())
|
|
111
172
|
.unwrap();
|
|
112
|
-
let app = generated::server::create_router(
|
|
173
|
+
let app = generated::server::create_router(state.clone(), state.cache.clone(), |r| r);
|
|
113
174
|
let response2 = app.oneshot(req2).await.unwrap();
|
|
114
175
|
assert_eq!(response2.status(), 200);
|
|
115
176
|
return;
|
|
@@ -121,7 +182,6 @@ mod tests {
|
|
|
121
182
|
"text/event-stream"
|
|
122
183
|
);
|
|
123
184
|
|
|
124
|
-
// Read body frames sequentially
|
|
125
185
|
let mut body = response.into_body();
|
|
126
186
|
let mut timestamps = Vec::new();
|
|
127
187
|
|
|
@@ -134,18 +194,6 @@ mod tests {
|
|
|
134
194
|
}
|
|
135
195
|
}
|
|
136
196
|
|
|
137
|
-
// We should have received 3 events
|
|
138
197
|
assert_eq!(timestamps.len(), 3);
|
|
139
|
-
|
|
140
|
-
// Verify delay between events (approx 100ms)
|
|
141
|
-
for i in 1..timestamps.len() {
|
|
142
|
-
let diff = timestamps[i].duration_since(timestamps[i - 1]);
|
|
143
|
-
// Give it some slack for CI / slow execution, but ensure it's not instantaneous
|
|
144
|
-
assert!(
|
|
145
|
-
diff.as_millis() >= 80,
|
|
146
|
-
"Events were too fast! Diff: {}ms",
|
|
147
|
-
diff.as_millis()
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
198
|
}
|
|
151
199
|
}
|
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
|
+
}
|