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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { strictEqual } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { emit, compareWithGolden } from "./test-host.js";
|
|
4
|
+
|
|
5
|
+
const ETAG_SPEC = `
|
|
6
|
+
import "@typespec/http";
|
|
7
|
+
import "typespec-rust-emitter";
|
|
8
|
+
using TypeSpec.Http;
|
|
9
|
+
|
|
10
|
+
model Article { id: string; title: string; }
|
|
11
|
+
|
|
12
|
+
@route("/articles")
|
|
13
|
+
namespace Articles {
|
|
14
|
+
@etagCache
|
|
15
|
+
@get
|
|
16
|
+
op getArticle(@path id: string): {
|
|
17
|
+
@statusCode statusCode: 200;
|
|
18
|
+
@body body: Article;
|
|
19
|
+
} | {
|
|
20
|
+
@statusCode statusCode: 304;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
describe("@etagCache decorator", () => {
|
|
26
|
+
it("generates EtagCache trait in server.rs", async () => {
|
|
27
|
+
const results = await emit(ETAG_SPEC);
|
|
28
|
+
const server = results["server.rs"];
|
|
29
|
+
strictEqual(server.includes("pub trait EtagCache"), true);
|
|
30
|
+
strictEqual(
|
|
31
|
+
server.includes("fn get(&self, key: &str) -> Option<String>"),
|
|
32
|
+
true,
|
|
33
|
+
);
|
|
34
|
+
strictEqual(server.includes("fn set(&self, key: &str, etag: &str)"), true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("generates cache-aware handler with 304 short-circuit", async () => {
|
|
38
|
+
const results = await emit(ETAG_SPEC);
|
|
39
|
+
const server = results["server.rs"];
|
|
40
|
+
strictEqual(server.includes("axum::http::StatusCode::NOT_MODIFIED"), true);
|
|
41
|
+
strictEqual(server.includes("cache.get(cache_key.as_ref())"), true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("supports custom etagKey and @cacheControl", async () => {
|
|
45
|
+
const spec = `
|
|
46
|
+
import "@typespec/http";
|
|
47
|
+
import "typespec-rust-emitter";
|
|
48
|
+
using TypeSpec.Http;
|
|
49
|
+
|
|
50
|
+
@route("/test")
|
|
51
|
+
namespace Test {
|
|
52
|
+
@etagCache("my-key")
|
|
53
|
+
@cacheControl("public")
|
|
54
|
+
@get
|
|
55
|
+
op run(): { @statusCode statusCode: 200; };
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
const results = await emit(spec);
|
|
59
|
+
const server = results["server.rs"];
|
|
60
|
+
strictEqual(server.includes('let cache_key = "my-key";'), true);
|
|
61
|
+
strictEqual(server.includes('axum::http::header::CACHE_CONTROL'), true);
|
|
62
|
+
strictEqual(server.includes('"public"'), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("orders cache after claims when combined with @useAuth", async () => {
|
|
66
|
+
const spec = `
|
|
67
|
+
import "@typespec/http";
|
|
68
|
+
import "typespec-rust-emitter";
|
|
69
|
+
using TypeSpec.Http;
|
|
70
|
+
|
|
71
|
+
model Article { id: string; title: string; }
|
|
72
|
+
|
|
73
|
+
@route("/articles")
|
|
74
|
+
namespace Articles {
|
|
75
|
+
@etagCache("article-list")
|
|
76
|
+
@useAuth(BearerAuth)
|
|
77
|
+
@get
|
|
78
|
+
op getArticle(@path id: string): {
|
|
79
|
+
@statusCode statusCode: 200;
|
|
80
|
+
@body body: Article;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
const results = await emit(spec);
|
|
85
|
+
const server = results["server.rs"];
|
|
86
|
+
strictEqual(
|
|
87
|
+
server.includes(
|
|
88
|
+
"async fn articles_get_article<C: EtagCache + Send + Sync>(\n &self, claims: Self::Claims, cache: &C, id: String",
|
|
89
|
+
),
|
|
90
|
+
true,
|
|
91
|
+
);
|
|
92
|
+
strictEqual(
|
|
93
|
+
server.includes(
|
|
94
|
+
"let result = service.articles_get_article(claims, &cache, id).await;",
|
|
95
|
+
),
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("matches golden file", async () => {
|
|
101
|
+
const results = await emit(ETAG_SPEC);
|
|
102
|
+
compareWithGolden(results, "etag_cache", "server.rs");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#![allow(unused)]
|
|
2
|
+
|
|
3
|
+
use super::types::*;
|
|
4
|
+
use async_trait::async_trait;
|
|
5
|
+
use axum::extract::Path;
|
|
6
|
+
use axum::Extension;
|
|
7
|
+
use axum::http::StatusCode;
|
|
8
|
+
use axum::response::IntoResponse;
|
|
9
|
+
use axum::Json;
|
|
10
|
+
use eyre::Result;
|
|
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
|
+
|
|
24
|
+
#[async_trait]
|
|
25
|
+
pub trait Server: Send + Sync {
|
|
26
|
+
type Claims: Send + Sync + 'static;
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async fn articles_get_article<C: EtagCache + Send + Sync>(
|
|
30
|
+
&self, cache: &C, id: String
|
|
31
|
+
) -> Result<ArticlesGetArticleResponse>;
|
|
32
|
+
}
|
|
33
|
+
#[allow(clippy::type_complexity)]
|
|
34
|
+
pub enum ArticlesGetArticleResponse {
|
|
35
|
+
Ok(Json<Article>),
|
|
36
|
+
NotModified,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
impl IntoResponse for ArticlesGetArticleResponse {
|
|
40
|
+
fn into_response(self) -> axum::response::Response {
|
|
41
|
+
match self {
|
|
42
|
+
|
|
43
|
+
ArticlesGetArticleResponse::Ok(body) => (StatusCode::OK, body).into_response(),
|
|
44
|
+
ArticlesGetArticleResponse::NotModified => StatusCode::NOT_MODIFIED.into_response(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
use axum::extract::{Query, Multipart};
|
|
50
|
+
use axum::routing::{get};
|
|
51
|
+
use axum::Router;
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
pub async fn articles_get_article_handler<S, C>(
|
|
55
|
+
axum::extract::State(service): axum::extract::State<S>,
|
|
56
|
+
axum::extract::State(cache): axum::extract::State<C>,
|
|
57
|
+
if_none_match: Option<axum_extra::TypedHeader<axum_extra::headers::IfNoneMatch>>,
|
|
58
|
+
Path(id): Path<String>,
|
|
59
|
+
) -> impl axum::response::IntoResponse
|
|
60
|
+
where
|
|
61
|
+
S: Server + Send + Sync + 'static,
|
|
62
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
63
|
+
C: EtagCache + Clone + Send + Sync + 'static,
|
|
64
|
+
{
|
|
65
|
+
// Check If-None-Match against the cache
|
|
66
|
+
let cache_key = format!("articles_get_article:{id}");
|
|
67
|
+
if let (Some(stored_etag), Some(axum_extra::TypedHeader(inm))) = (cache.get(cache_key.as_ref()), if_none_match) {
|
|
68
|
+
// If the client's ETag matches, respond 304 immediately
|
|
69
|
+
if stored_etag == format!("{:?}", inm).trim_matches('"') {
|
|
70
|
+
let mut res = axum::http::StatusCode::NOT_MODIFIED.into_response();
|
|
71
|
+
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Forward to business logic
|
|
76
|
+
let result = service.articles_get_article(&cache, id).await;
|
|
77
|
+
match result {
|
|
78
|
+
Ok(response) => {
|
|
79
|
+
let mut res = response.into_response();
|
|
80
|
+
if let Some(stored_etag) = cache.get(cache_key.as_ref()) {
|
|
81
|
+
res.headers_mut().insert(
|
|
82
|
+
axum::http::header::ETAG,
|
|
83
|
+
axum::http::HeaderValue::from_str(&stored_etag).unwrap(),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
res
|
|
88
|
+
}
|
|
89
|
+
Err(e) => (
|
|
90
|
+
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
91
|
+
format!("Internal error: {e}"),
|
|
92
|
+
)
|
|
93
|
+
.into_response(),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pub fn create_router<S, C, M>(service: S, cache: C, middleware: M) -> Router
|
|
98
|
+
where
|
|
99
|
+
S: Server + Clone + Send + Sync + 'static,
|
|
100
|
+
S::Claims: Send + Sync + Clone + 'static,
|
|
101
|
+
C: EtagCache + Clone + Send + Sync + 'static + axum::extract::FromRef<S>,
|
|
102
|
+
M: FnOnce(Router<S>) -> Router<S> + Clone + Send + Sync + 'static,
|
|
103
|
+
{
|
|
104
|
+
let mut router = Router::new().with_state(cache);
|
|
105
|
+
let public = Router::new()
|
|
106
|
+
.route("/articles", get(articles_get_article_handler::<S, C>))
|
|
107
|
+
;
|
|
108
|
+
router = router.merge(public);
|
|
109
|
+
router.with_state(service)
|
|
110
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import "@typespec/http";
|
|
2
|
+
import "typespec-rust-emitter";
|
|
3
|
+
using TypeSpec.Http;
|
|
4
|
+
|
|
5
|
+
model Article {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@route("/articles")
|
|
11
|
+
namespace Articles {
|
|
12
|
+
@etagCache
|
|
13
|
+
@get
|
|
14
|
+
op getArticle(@path id: string): {
|
|
15
|
+
@statusCode statusCode: 200;
|
|
16
|
+
@body body: Article;
|
|
17
|
+
} | {
|
|
18
|
+
@statusCode statusCode: 304;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#![allow(unused)]
|
|
2
|
+
|
|
3
|
+
use std::str::FromStr;
|
|
4
|
+
use axum::http::StatusCode;
|
|
5
|
+
use axum::response::IntoResponse;
|
|
6
|
+
use axum::Json;
|
|
7
|
+
|
|
8
|
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
9
|
+
pub struct Article {
|
|
10
|
+
pub id: String,
|
|
11
|
+
pub title: String,
|
|
12
|
+
}
|
|
13
|
+
|
package/test/test-host.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Diagnostic, resolvePath } from "@typespec/compiler";
|
|
2
2
|
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
|
|
3
3
|
import { createTester } from "@typespec/compiler/testing";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
|
|
5
6
|
export const Tester = createTester(resolvePath(import.meta.dirname, "../.."), {
|
|
6
7
|
libraries: [
|
|
@@ -23,3 +24,45 @@ export async function emit(code: string): Promise<Record<string, string>> {
|
|
|
23
24
|
expectDiagnosticEmpty(diagnostics);
|
|
24
25
|
return result;
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* compareWithGolden
|
|
30
|
+
*
|
|
31
|
+
* Reads an expected file from test/golden/<goldenDir>/<fileName>
|
|
32
|
+
* and asserts that the emitted output matches it exactly.
|
|
33
|
+
*
|
|
34
|
+
* @param emitted - Record produced by emit() e.g. { "types.rs": "...", "server.rs": "..." }
|
|
35
|
+
* @param goldenDir - Subdirectory name inside test/golden/
|
|
36
|
+
* @param fileName - File key to check, e.g. "server.rs"
|
|
37
|
+
*/
|
|
38
|
+
export function compareWithGolden(
|
|
39
|
+
emitted: Record<string, string>,
|
|
40
|
+
goldenDir: string,
|
|
41
|
+
fileName: string,
|
|
42
|
+
): void {
|
|
43
|
+
const goldenPath = resolvePath(
|
|
44
|
+
import.meta.dirname,
|
|
45
|
+
"../../test",
|
|
46
|
+
"golden",
|
|
47
|
+
goldenDir,
|
|
48
|
+
fileName,
|
|
49
|
+
);
|
|
50
|
+
const expected = readFileSync(goldenPath, "utf8");
|
|
51
|
+
const actual = emitted[fileName];
|
|
52
|
+
if (actual === undefined) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Emitter did not produce "${fileName}". Keys: ${Object.keys(emitted).join(", ")}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (actual !== expected) {
|
|
58
|
+
// Show a useful diff-style message.
|
|
59
|
+
const expectedLines = expected.split("\n");
|
|
60
|
+
const actualLines = actual.split("\n");
|
|
61
|
+
const firstDiff = expectedLines.findIndex((l, i) => l !== actualLines[i]);
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Golden mismatch in ${goldenDir}/${fileName} at line ${firstDiff + 1}.\n` +
|
|
64
|
+
`Expected: ${expectedLines[firstDiff]}\n` +
|
|
65
|
+
`Actual: ${actualLines[firstDiff]}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|