feat: add shared API operation metadata macro

This commit is contained in:
2026-09-17 23:05:11 +09:00
parent 96592b1a24
commit 9590cf332d
28 changed files with 1384 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
#[test]
fn invalid_api_declarations_have_stable_diagnostics() {
let tests = trybuild::TestCases::new();
tests.compile_fail("tests/ui/*.rs");
}
+87
View File
@@ -0,0 +1,87 @@
#![allow(async_fn_in_trait)]
use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api};
pub struct CreateWidget;
pub struct Widget;
pub struct Lookup;
pub struct PublicError;
pub struct SearchResult<T>(std::marker::PhantomData<T>);
#[api]
pub trait WidgetApi {
#[post(
"/widgets",
operation_id = "widgets.create",
status = 201,
error_status = 422
)]
async fn create(
&self,
#[body] request: CreateWidget,
#[header("x-request-id")] request_id: String,
) -> Result<Widget, PublicError>;
#[get("/widgets/{widget_id}", operation_id = "widgets.get")]
async fn lookup(&self, widget_id: u64, #[query] query: Lookup) -> Result<Widget, PublicError>;
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete")]
async fn delete(&self, #[path] widget_id: u64) -> ();
}
#[api]
pub trait WrapperApi {
#[get("/search", operation_id = "search")]
async fn search(&self) -> SearchResult<Widget>;
}
fn assert_operation_types<O>()
where
O: Operation<
Parameters = (CreateWidget, String),
RequestBody = CreateWidget,
ResponseBody = Widget,
ErrorBody = PublicError,
>,
{
}
#[test]
fn expansion_exposes_deterministic_metadata_and_type_connections() {
assert_operation_types::<widget_api_operations::Create>();
fn assert_named_wrapper<O: Operation<ResponseBody = SearchResult<Widget>>>() {}
assert_named_wrapper::<wrapper_api_operations::Search>();
let operations = <WidgetApiMetadata as ApiContract>::OPERATIONS;
assert_eq!(operations.len(), 3);
assert_eq!(operations[0].operation_id, "widgets.create");
assert_eq!(operations[1].operation_id, "widgets.delete");
assert_eq!(operations[2].operation_id, "widgets.get");
let create = <widget_api_operations::Create as Operation>::METADATA;
assert_eq!(operations[0], create);
assert_eq!(create.method, HttpMethod::Post);
assert_eq!(create.path, "/widgets");
assert_eq!(create.request_body.wire_kind, WireKind::Json);
assert_eq!(create.response.status, 201);
assert_eq!(create.response.body.wire_kind, WireKind::Json);
assert_eq!(create.error_response.unwrap().status, 422);
assert_eq!(create.parameters[0].location, ParameterLocation::Body);
assert_eq!(create.parameters[1].location, ParameterLocation::Header);
assert_eq!(create.parameters[1].wire_name, "x-request-id");
let lookup = <widget_api_operations::Lookup as Operation>::METADATA;
assert_eq!(lookup.parameters[0].location, ParameterLocation::Path);
assert_eq!(lookup.parameters[1].location, ParameterLocation::Query);
let delete = <widget_api_operations::Delete as Operation>::METADATA;
assert_eq!(delete.response.status, 204);
assert_eq!(delete.response.body.wire_kind, WireKind::Empty);
assert_eq!(delete.error_response, None);
fn assert_empty_request<O: Operation<RequestBody = NoBody>>() {}
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
assert_empty_request::<widget_api_operations::Lookup>();
assert_empty_response::<widget_api_operations::Delete>();
}
@@ -0,0 +1,11 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait AnonymousBody {
#[post("/widgets")]
async fn create(&self, #[body] request: (u64, u64)) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: request bodies must use a named Rust type
--> tests/ui/anonymous_body.rs:8:45
|
8 | async fn create(&self, #[body] request: (u64, u64)) -> Response;
| ^^^^^^^^^^
@@ -0,0 +1,14 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait DuplicateOperationId {
#[get("/one", operation_id = "duplicate")]
async fn one(&self) -> Response;
#[get("/two", operation_id = "duplicate")]
async fn two(&self) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate operation ID `duplicate`
--> tests/ui/duplicate_operation_id.rs:11:14
|
11 | async fn two(&self) -> Response;
| ^^^
@@ -0,0 +1,14 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait DuplicateRoute {
#[get("/widgets", operation_id = "widgets.first")]
async fn first(&self) -> Response;
#[get("/widgets", operation_id = "widgets.second")]
async fn second(&self) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate route for GET `/widgets`
--> tests/ui/duplicate_route.rs:11:14
|
11 | async fn second(&self) -> Response;
| ^^^^^^
@@ -0,0 +1,12 @@
use api_macros::api;
pub struct Response;
pub struct PublicError;
#[api]
pub trait InvalidErrorStatus {
#[get("/widgets", error_status = 200)]
async fn list(&self) -> Result<Response, PublicError>;
}
fn main() {}
@@ -0,0 +1,5 @@
error: error_status must be between 400 and 599
--> tests/ui/invalid_error_status.rs:8:38
|
8 | #[get("/widgets", error_status = 200)]
| ^^^
@@ -0,0 +1,12 @@
use api_macros::api;
pub struct Request;
pub struct Response;
#[api]
pub trait InvalidMethodBody {
#[get("/widgets")]
async fn list(&self, #[body] request: Request) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: GET operations cannot declare a request body
--> tests/ui/invalid_method_body.rs:9:14
|
9 | async fn list(&self, #[body] request: Request) -> Response;
| ^^^^
@@ -0,0 +1,11 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait InvalidSuccessStatus {
#[get("/widgets", status = 404)]
async fn list(&self) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: status must be a success status between 200 and 299
--> tests/ui/invalid_success_status.rs:7:32
|
7 | #[get("/widgets", status = 404)]
| ^^^
@@ -0,0 +1,17 @@
use api_macros::api;
pub struct FirstBody;
pub struct SecondBody;
pub struct Response;
#[api]
pub trait MultipleBodies {
#[post("/widgets")]
async fn create(
&self,
#[body] first: FirstBody,
#[body] second: SecondBody,
) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: an API operation may declare only one request body
--> tests/ui/multiple_bodies.rs:13:17
|
13 | #[body] second: SecondBody,
| ^^^^^^
@@ -0,0 +1,11 @@
use api_macros::api;
pub struct Response;
#[api]
pub trait PathMismatch {
#[get("/widgets/{widget_id}")]
async fn get(&self, #[path] other_id: u64) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: path placeholder(s) have no matching path parameter: widget_id
--> tests/ui/path_mismatch.rs:7:11
|
7 | #[get("/widgets/{widget_id}")]
| ^^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,14 @@
#![allow(async_fn_in_trait, private_interfaces)]
use api_macros::api;
struct PrivateRequest;
pub struct Response;
#[api]
pub trait PrivateBody {
#[post("/widgets")]
async fn create(&self, #[body] request: PrivateRequest) -> Response;
}
fn main() {}
@@ -0,0 +1,10 @@
error[E0446]: private type `PrivateRequest` in public interface
--> tests/ui/private_body.rs:8:1
|
5 | struct PrivateRequest;
| --------------------- `PrivateRequest` declared as private
...
8 | #[api]
| ^^^^^^ can't leak private type
|
= note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info)
@@ -0,0 +1,12 @@
use api_macros::api;
pub struct StreamRequest;
pub struct Response;
#[api]
pub trait UnsupportedWireKind {
#[post("/widgets")]
async fn create(&self, #[stream] request: StreamRequest) -> Response;
}
fn main() {}
@@ -0,0 +1,5 @@
error: this wire kind is explicitly unsupported by the initial API contract
--> tests/ui/unsupported_wire_kind.rs:9:28
|
9 | async fn create(&self, #[stream] request: StreamRequest) -> Response;
| ^^^^^^^^^