feat: add shared API operation metadata macro
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "api-macros"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
api-macros-impl = { path = "../api-macros-impl" }
|
||||
|
||||
[dev-dependencies]
|
||||
trybuild = "1"
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Shared API trait declarations for Yoi services.
|
||||
//!
|
||||
//! `#[api]` turns a public trait into a single, transport-neutral API definition. The
|
||||
//! original trait remains usable by Rust code; generated metadata and zero-sized operation
|
||||
//! markers give adapters and generators a stable view of the same declaration.
|
||||
//!
|
||||
//! # Declaration syntax
|
||||
//!
|
||||
//! ```
|
||||
//! # mod example {
|
||||
//! use api_macros::api;
|
||||
//!
|
||||
//! pub struct CreateWidget;
|
||||
//! pub struct Widget;
|
||||
//! pub struct PublicError;
|
||||
//!
|
||||
//! #[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 get(&self, widget_id: u64) -> Result<Widget, PublicError>;
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and
|
||||
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. A public error inferred from `Result<T, E>` defaults to status `400`.
|
||||
//!
|
||||
//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, or `#[path]`. An
|
||||
//! unannotated argument whose Rust name occurs in the route template is inferred as a path
|
||||
//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`.
|
||||
//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named
|
||||
//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected.
|
||||
//!
|
||||
//! # Generated names
|
||||
//!
|
||||
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
|
||||
//! plus a `widget_api_operations` module. That module contains one marker per Rust method
|
||||
//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete
|
||||
//! body types at compile time. The `ApiContract::OPERATIONS` inventory is sorted by operation
|
||||
//! ID, so its ordering is independent of source method order.
|
||||
//!
|
||||
//! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves
|
||||
//! explicit variants for future transport work; accepting one requires a deliberate macro and
|
||||
//! adapter change rather than silently treating it as JSON.
|
||||
|
||||
pub use api_macros_impl::api;
|
||||
|
||||
/// A transport-neutral HTTP method.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
}
|
||||
|
||||
/// Where an operation argument is encoded on the wire.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum ParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
Header,
|
||||
Body,
|
||||
}
|
||||
|
||||
/// Body encodings understood or explicitly reserved by the API contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[non_exhaustive]
|
||||
pub enum WireKind {
|
||||
Empty,
|
||||
Json,
|
||||
Streaming,
|
||||
Multipart,
|
||||
Binary,
|
||||
}
|
||||
|
||||
/// Stable metadata for one Rust method argument.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ParameterMetadata {
|
||||
pub rust_name: &'static str,
|
||||
pub wire_name: &'static str,
|
||||
pub location: ParameterLocation,
|
||||
}
|
||||
|
||||
/// Stable metadata for a request or response body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct BodyMetadata {
|
||||
pub wire_kind: WireKind,
|
||||
}
|
||||
|
||||
/// Stable metadata for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseMetadata {
|
||||
pub status: u16,
|
||||
pub body: BodyMetadata,
|
||||
}
|
||||
|
||||
/// Complete transport metadata for one operation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OperationMetadata {
|
||||
pub operation_id: &'static str,
|
||||
pub method: HttpMethod,
|
||||
pub path: &'static str,
|
||||
pub parameters: &'static [ParameterMetadata],
|
||||
pub request_body: BodyMetadata,
|
||||
pub response: ResponseMetadata,
|
||||
pub error_response: Option<ResponseMetadata>,
|
||||
}
|
||||
|
||||
/// Deterministic operation inventory emitted for an `#[api]` trait.
|
||||
pub trait ApiContract {
|
||||
const OPERATIONS: &'static [OperationMetadata];
|
||||
}
|
||||
|
||||
/// Compile-time connection between operation metadata and its concrete body types.
|
||||
pub trait Operation {
|
||||
/// Tuple of all non-receiver parameter types in declaration order.
|
||||
type Parameters;
|
||||
/// JSON request body type, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
const METADATA: OperationMetadata;
|
||||
}
|
||||
|
||||
/// Marker used when an operation has no request, response, or public error body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum NoBody {}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[test]
|
||||
fn invalid_api_declarations_have_stable_diagnostics() {
|
||||
let tests = trybuild::TestCases::new();
|
||||
tests.compile_fail("tests/ui/*.rs");
|
||||
}
|
||||
@@ -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;
|
||||
| ^^^^^^^^^
|
||||
Reference in New Issue
Block a user