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.
Files changed (141) hide show
  1. package/AGENTS.md +83 -79
  2. package/CHANGELOG.md +60 -0
  3. package/dist/src/decorators/cache_control.d.ts +6 -0
  4. package/dist/src/decorators/cache_control.js +9 -0
  5. package/dist/src/decorators/cache_control.js.map +1 -0
  6. package/dist/src/decorators/etag_cache.d.ts +6 -0
  7. package/dist/src/decorators/etag_cache.js +9 -0
  8. package/dist/src/decorators/etag_cache.js.map +1 -0
  9. package/dist/src/decorators/index.d.ts +6 -0
  10. package/dist/src/decorators/index.js +7 -0
  11. package/dist/src/decorators/index.js.map +1 -0
  12. package/dist/src/decorators/rust_attr.d.ts +3 -0
  13. package/dist/src/decorators/rust_attr.js +45 -0
  14. package/dist/src/decorators/rust_attr.js.map +1 -0
  15. package/dist/src/decorators/rust_derive.d.ts +3 -0
  16. package/dist/src/decorators/rust_derive.js +39 -0
  17. package/dist/src/decorators/rust_derive.js.map +1 -0
  18. package/dist/src/decorators/rust_impl.d.ts +2 -0
  19. package/dist/src/decorators/rust_impl.js +19 -0
  20. package/dist/src/decorators/rust_impl.js.map +1 -0
  21. package/dist/src/decorators/rust_self.d.ts +3 -0
  22. package/dist/src/decorators/rust_self.js +35 -0
  23. package/dist/src/decorators/rust_self.js.map +1 -0
  24. package/dist/src/emitter.d.ts +2 -11
  25. package/dist/src/emitter.js +7 -1252
  26. package/dist/src/emitter.js.map +1 -1
  27. package/dist/src/formatter/index.d.ts +2 -0
  28. package/dist/src/formatter/index.js +3 -0
  29. package/dist/src/formatter/index.js.map +1 -0
  30. package/dist/src/formatter/mappings.d.ts +4 -0
  31. package/dist/src/formatter/mappings.js +68 -0
  32. package/dist/src/formatter/mappings.js.map +1 -0
  33. package/dist/src/formatter/strings.d.ts +4 -0
  34. package/dist/src/formatter/strings.js +32 -0
  35. package/dist/src/formatter/strings.js.map +1 -0
  36. package/dist/src/generator/etag_router.d.ts +30 -0
  37. package/dist/src/generator/etag_router.js +123 -0
  38. package/dist/src/generator/etag_router.js.map +1 -0
  39. package/dist/src/generator/index.d.ts +5 -0
  40. package/dist/src/generator/index.js +6 -0
  41. package/dist/src/generator/index.js.map +1 -0
  42. package/dist/src/generator/response_enums.d.ts +6 -0
  43. package/dist/src/generator/response_enums.js +58 -0
  44. package/dist/src/generator/response_enums.js.map +1 -0
  45. package/dist/src/generator/router.d.ts +7 -0
  46. package/dist/src/generator/router.js +227 -0
  47. package/dist/src/generator/router.js.map +1 -0
  48. package/dist/src/generator/server_trait.d.ts +6 -0
  49. package/dist/src/generator/server_trait.js +97 -0
  50. package/dist/src/generator/server_trait.js.map +1 -0
  51. package/dist/src/generator/types_file.d.ts +11 -0
  52. package/dist/src/generator/types_file.js +209 -0
  53. package/dist/src/generator/types_file.js.map +1 -0
  54. package/dist/src/index.d.ts +1 -1
  55. package/dist/src/index.js +1 -1
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/lib.js +1 -1
  58. package/dist/src/lib.js.map +1 -1
  59. package/dist/src/models/index.d.ts +2 -0
  60. package/dist/src/models/index.js +3 -0
  61. package/dist/src/models/index.js.map +1 -0
  62. package/dist/src/models/keys.d.ts +6 -0
  63. package/dist/src/models/keys.js +8 -0
  64. package/dist/src/models/keys.js.map +1 -0
  65. package/dist/src/models/types.d.ts +45 -0
  66. package/dist/src/models/types.js +2 -0
  67. package/dist/src/models/types.js.map +1 -0
  68. package/dist/src/parser/decorators.d.ts +18 -0
  69. package/dist/src/parser/decorators.js +28 -0
  70. package/dist/src/parser/decorators.js.map +1 -0
  71. package/dist/src/parser/index.d.ts +6 -0
  72. package/dist/src/parser/index.js +7 -0
  73. package/dist/src/parser/index.js.map +1 -0
  74. package/dist/src/parser/operations.d.ts +13 -0
  75. package/dist/src/parser/operations.js +127 -0
  76. package/dist/src/parser/operations.js.map +1 -0
  77. package/dist/src/parser/parameters.d.ts +5 -0
  78. package/dist/src/parser/parameters.js +98 -0
  79. package/dist/src/parser/parameters.js.map +1 -0
  80. package/dist/src/parser/responses.d.ts +13 -0
  81. package/dist/src/parser/responses.js +132 -0
  82. package/dist/src/parser/responses.js.map +1 -0
  83. package/dist/src/parser/routes.d.ts +4 -0
  84. package/dist/src/parser/routes.js +36 -0
  85. package/dist/src/parser/routes.js.map +1 -0
  86. package/dist/src/parser/types.d.ts +9 -0
  87. package/dist/src/parser/types.js +157 -0
  88. package/dist/src/parser/types.js.map +1 -0
  89. package/dist/test/etag_cache.test.d.ts +1 -0
  90. package/dist/test/etag_cache.test.js +62 -0
  91. package/dist/test/etag_cache.test.js.map +1 -0
  92. package/dist/test/hello.test.js +23 -0
  93. package/dist/test/hello.test.js.map +1 -1
  94. package/dist/test/test-host.d.ts +11 -0
  95. package/dist/test/test-host.js +28 -0
  96. package/dist/test/test-host.js.map +1 -1
  97. package/example/main.tsp +48 -1
  98. package/example/output-rust/Cargo.lock +75 -0
  99. package/example/output-rust/Cargo.toml +2 -1
  100. package/example/output-rust/src/generated/server.rs +163 -12
  101. package/example/output-rust/src/generated/types.rs +8 -0
  102. package/example/output-rust/src/main.rs +75 -27
  103. package/justfile +31 -2
  104. package/package.json +1 -1
  105. package/scripts/update-golden.js +36 -0
  106. package/src/decorators/cache_control.ts +14 -0
  107. package/src/decorators/etag_cache.ts +14 -0
  108. package/src/decorators/index.ts +6 -0
  109. package/src/decorators/rust_attr.ts +61 -0
  110. package/src/decorators/rust_derive.ts +55 -0
  111. package/src/decorators/rust_impl.ts +29 -0
  112. package/src/decorators/rust_self.ts +42 -0
  113. package/src/emitter.ts +18 -1623
  114. package/src/formatter/index.ts +2 -0
  115. package/src/formatter/mappings.ts +70 -0
  116. package/src/formatter/strings.ts +33 -0
  117. package/src/generator/etag_router.ts +147 -0
  118. package/src/generator/index.ts +5 -0
  119. package/src/generator/response_enums.ts +76 -0
  120. package/src/generator/router.ts +280 -0
  121. package/src/generator/server_trait.ts +134 -0
  122. package/src/generator/types_file.ts +297 -0
  123. package/src/index.ts +3 -1
  124. package/src/lib.ts +1 -1
  125. package/src/lib.tsp +3 -1
  126. package/src/models/index.ts +2 -0
  127. package/src/models/keys.ts +7 -0
  128. package/src/models/types.ts +54 -0
  129. package/src/parser/decorators.ts +34 -0
  130. package/src/parser/index.ts +6 -0
  131. package/src/parser/operations.ts +158 -0
  132. package/src/parser/parameters.ts +117 -0
  133. package/src/parser/responses.ts +170 -0
  134. package/src/parser/routes.ts +47 -0
  135. package/src/parser/types.ts +215 -0
  136. package/test/etag_cache.test.ts +69 -0
  137. package/test/golden/etag_cache/server.rs +109 -0
  138. package/test/golden/etag_cache/spec.tsp +20 -0
  139. package/test/golden/etag_cache/types.rs +13 -0
  140. package/test/hello.test.ts +24 -0
  141. 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
- use axum::extract::Query;
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
- Query(params): Query<ItemsGetItemQuery>,
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(params.id).await;
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 create_router<S, M>(service: S, middleware: M) -> Router
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(items_get_item_handler::<S>))
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 axum::Json;
7
- use generated::server::{EventsAccountsEventsResponse, ItemsCreateItemResponse, ItemsGetItemResponse, ItemsUpdateItemResponse, ConsumingConsumeAndDeleteResponse, PetsListResponse, Server};
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 _, wrappers::IntervalStream};
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!["pet1".to_string(), "pet2".to_string()])))
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 app = generated::server::create_router(AppState, |r| r);
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
- // Wait! The generated route in `example/output-rust/src/generated/server.rs` uses `/events/{accountId}` literally!
94
- // We might need to use `/events/{accountId}` in the request to match, or fix the emitter if it's broken.
95
- // Let's use whatever route is in the generated code to test the SSE handler logic first.
96
- let app = generated::server::create_router(AppState, |r| r);
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") // If the emitter generates {accountId}, axum won't match this if it requires `:accountId`.
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(AppState, |r| r);
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
- publish:
7
- npm run build && npm publish
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-rust-emitter",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "TypeSpec emitter that generates idiomatic Rust types and structs",
5
5
  "keywords": [
6
6
  "typespec",
@@ -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,6 @@
1
+ export * from "./rust_derive.js";
2
+ export * from "./rust_attr.js";
3
+ export * from "./rust_impl.js";
4
+ export * from "./rust_self.js";
5
+ export * from "./etag_cache.js";
6
+ export * from "./cache_control.js";
@@ -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
+ }