kynos_macros/lib.rs
1//! Procedural macros for the Kynos REST API framework.
2//!
3//! Nothing here is meant to be used directly: every macro is re-exported from
4//! `kynos`, and the documentation lives next to the trait each one implements.
5//!
6//! # Why the route attributes exist
7//!
8//! Kynos deliberately has no attribute DSL restating a handler's signature.
9//! utoipa's `#[utoipa::path(responses(...))]` is written by hand beside the
10//! code it describes, and nothing keeps the two in step — which is the single
11//! most common way a generated OpenAPI document ends up wrong.
12//!
13//! The route attributes therefore carry only what the types cannot: the method,
14//! the path, and prose. Parameters come from the handler's arguments, responses
15//! from its return type, and neither is restated anywhere.
16//!
17//! What the attribute *does* add is compile-time checking the builder form
18//! cannot do — chiefly that a path template's variables match the handler's
19//! path parameters.
20//!
21//! # Why the examples here are `ignore`d
22//!
23//! Every expansion names `::kynos::…`, which this crate cannot depend on, so a
24//! doctest here would not compile whatever the derive emitted. The compiled
25//! demonstrations live in `crates/kynos/tests/derives.rs` and the framework's
26//! examples; `AGENTS.md` records the carve-out.
27
28#[cfg(feature = "assets")]
29mod assets;
30mod derive;
31mod route;
32
33use proc_macro::TokenStream;
34
35/// Declares a `GET` operation.
36///
37/// ```ignore
38/// /// Fetch a single user.
39/// ///
40/// /// The first line becomes the operation's summary, the rest its description.
41/// #[kynos::get("/users/{id}", catch_panics)]
42/// async fn get_user(Path(id): Path<UserId>) -> Result<Json<User>, ApiError> {
43/// todo!()
44/// }
45/// ```
46///
47/// `catch_panics` installs a compile-time-selected recovery boundary for this
48/// operation and contributes its 500 response. It is a compile-time error to
49/// use it when the final binary is built with `panic = "abort"`.
50///
51/// Accepts `operation_id = "..."` and `tag = SomeTag` after the path. The tag
52/// becomes `EndpointMeta::TAGS`, which is what puts it in the description; it
53/// may be named once, since `Router::tag`, `Group::tag` and
54/// `EndpointBuilder::tag` are how an operation acquires the rest.
55#[proc_macro_attribute]
56pub fn get(attribute: TokenStream, item: TokenStream) -> TokenStream {
57 route::expand("GET", attribute, item)
58}
59
60/// Declares a `POST` operation. See [`macro@get`] for the syntax.
61#[proc_macro_attribute]
62pub fn post(attribute: TokenStream, item: TokenStream) -> TokenStream {
63 route::expand("POST", attribute, item)
64}
65
66/// Declares a `PUT` operation. See [`macro@get`] for the syntax.
67#[proc_macro_attribute]
68pub fn put(attribute: TokenStream, item: TokenStream) -> TokenStream {
69 route::expand("PUT", attribute, item)
70}
71
72/// Declares a `PATCH` operation. See [`macro@get`] for the syntax.
73#[proc_macro_attribute]
74pub fn patch(attribute: TokenStream, item: TokenStream) -> TokenStream {
75 route::expand("PATCH", attribute, item)
76}
77
78/// Declares a `DELETE` operation. See [`macro@get`] for the syntax.
79#[proc_macro_attribute]
80pub fn delete(attribute: TokenStream, item: TokenStream) -> TokenStream {
81 route::expand("DELETE", attribute, item)
82}
83
84/// Declares a `HEAD` operation. See [`macro@get`] for the syntax.
85///
86/// Rarely needed: a `HEAD` is answered from the corresponding `GET` unless one
87/// is declared, per RFC 9110. Declare it only when the description should say
88/// so explicitly.
89#[proc_macro_attribute]
90pub fn head(attribute: TokenStream, item: TokenStream) -> TokenStream {
91 route::expand("HEAD", attribute, item)
92}
93
94/// Declares an `OPTIONS` operation. See [`macro@get`] for the syntax.
95///
96/// CORS preflight is handled without one: where a `Cors` interceptor covers a
97/// path, the router registers a preflight answer on it while the service is
98/// built. Declare this only for an `OPTIONS` that is part of the API's own
99/// contract.
100///
101/// Declaring one *suppresses* the synthesized preflight on that path — a
102/// hand-written operation wins, and it then owns answering preflights there too.
103#[proc_macro_attribute]
104pub fn options(attribute: TokenStream, item: TokenStream) -> TokenStream {
105 route::expand("OPTIONS", attribute, item)
106}
107
108/// Declares a `TRACE` operation. See [`macro@get`] for the syntax.
109#[proc_macro_attribute]
110pub fn trace(attribute: TokenStream, item: TokenStream) -> TokenStream {
111 route::expand("TRACE", attribute, item)
112}
113
114/// Declares a `QUERY` operation.
115///
116/// Requires the `openapi32` feature: `QUERY` has no Path Item field before
117/// OpenAPI 3.2.
118#[cfg(feature = "openapi32")]
119#[proc_macro_attribute]
120pub fn query(attribute: TokenStream, item: TokenStream) -> TokenStream {
121 route::expand("QUERY", attribute, item)
122}
123
124/// Declares an operation for a method with no dedicated attribute.
125///
126/// ```ignore
127/// #[kynos::operation(method = "PROPFIND", path = "/files/{id}")]
128/// async fn propfind(Path(id): Path<FileId>) -> Result<Json<Properties>, ApiError> {
129/// todo!()
130/// }
131/// ```
132///
133/// A method outside the eight OpenAPI 3.1 names emits into
134/// `additionalOperations`, which requires the `openapi32` feature.
135#[proc_macro_attribute]
136pub fn operation(attribute: TokenStream, item: TokenStream) -> TokenStream {
137 route::expand_generic(attribute, item)
138}
139
140/// Collects operations for mounting.
141///
142/// Operations sharing a path are merged into one Path Item, so `routes![list,
143/// create]` on `/users` produces a single entry with `get` and `post`.
144///
145/// ```ignore
146/// Router::new().mount(routes![users::list, users::create, users::get]);
147/// ```
148#[proc_macro]
149pub fn routes(input: TokenStream) -> TokenStream {
150 route::routes::expand_routes(input)
151}
152
153/// A path template validated at compile time.
154///
155/// ```ignore
156/// let template = path!("/users/{id}");
157/// ```
158///
159/// Rejects a template that does not start with `/`, has unbalanced braces,
160/// repeats a variable, or carries a query string — none of which are legal as
161/// a Paths key.
162#[proc_macro]
163pub fn path(input: TokenStream) -> TokenStream {
164 route::path::expand_path(input)
165}
166
167/// Compiles a directory into the binary as a described asset set.
168///
169/// ```ignore
170/// kynos::assets! {
171/// /// The built single-page app.
172/// pub struct Site;
173/// dir = "dist",
174/// exclude = [".map"],
175/// warn_over = "4MiB",
176/// }
177/// ```
178///
179/// `dir` resolves against `CARGO_MANIFEST_DIR`. Every file becomes an
180/// `include_bytes!`, so changing one rebuilds the crate; *adding* or *removing*
181/// one does not, which a `cargo::rerun-if-changed` line in `build.rs` closes.
182///
183/// A file whose name no path template can express is a compile error naming it,
184/// because a static asset Kynos cannot describe is one it will not serve.
185/// Dotfiles and symlinks are skipped: the first keeps `.git` out of a binary,
186/// the second keeps a set inside its own directory.
187///
188/// An embedded set past 2 MiB emits a compiler warning at the `dir` literal.
189/// Raise the threshold with `warn_over = "8MiB"`, or turn it off with
190/// `warn_over = "none"`. Under `-D warnings` it becomes an error, which is
191/// arguably right and is exactly why the override exists.
192#[cfg(feature = "assets")]
193#[proc_macro]
194pub fn assets(item: TokenStream) -> TokenStream {
195 assets::expand(item)
196}
197
198/// Describes a type as JSON Schema.
199///
200/// Reads the serde attributes already on the type — `rename_all`, `skip`,
201/// `flatten`, `tag`, `content` — so the schema and the wire form come from one
202/// declaration.
203///
204/// Constraints go on fields, and the grammar is exactly the keys of
205/// [`Constraints`](https://docs.rs/kynos/latest/kynos/schema/constraints/struct.Constraints.html)
206/// so that
207/// the attribute and the type it fills cannot drift: `minimum`, `maximum`,
208/// `exclusive_minimum`, `exclusive_maximum`, `multiple_of`, `min_length`,
209/// `max_length`, `pattern`, `min_items`, `max_items` and the `unique_items`
210/// flag. They become JSON Schema assertions *and* the parser's checks, which is
211/// what keeps the description honest without a JSON Schema interpreter on the
212/// hot path.
213///
214/// `format` is **not** among them. It states what a value *is*, which follows
215/// from the type or from nothing, so a `String` annotated as a UUID is a
216/// compile error naming the remedy — `uuid::Uuid`, one of the date, time or
217/// decimal types behind their features, or a newtype with its own `Schema`.
218/// A constraint on one field is `pattern`; a claim about a type is the type's.
219///
220/// # Rejected, because serde and the schema would disagree
221///
222/// - `#[serde(with = ...)]`, `serialize_with`, `deserialize_with` on a field.
223/// The wire form no longer follows from the Rust type, so a schema derived
224/// from the Rust type would be a lie. Supply `#[schema(...)]` explicitly.
225/// - `#[serde(untagged)]` enums. `anyOf` with no discriminator is ambiguous to
226/// decode, and the tie-break is inexpressible. Use an internally or
227/// adjacently tagged enum, which becomes a `discriminator`.
228/// - `#[serde(flatten)]` onto a map-typed field, which forces
229/// `additionalProperties: true` on the parent.
230/// - `#[serde(default)]` or `skip_serializing_if` on a non-`Option` field,
231/// which would make `required` a lie.
232/// - A `#[serde(other)]` catch-all variant under `openapi31` alone, which needs
233/// 3.2's `defaultMapping` to describe.
234#[proc_macro_derive(Schema, attributes(schema))]
235pub fn derive_schema(item: TokenStream) -> TokenStream {
236 derive::schema::expand(item)
237}
238
239/// Maps an error type to RFC 9457 problem details.
240///
241/// ```ignore
242/// #[derive(Debug, thiserror::Error, ApiError)]
243/// #[problem(base = "https://errors.example.com/")]
244/// enum StoreError {
245/// #[error("no user with id {id}")]
246/// #[problem(status = 404, title = "User not found")]
247/// NotFound {
248/// #[problem(extension)]
249/// id: UserId,
250/// trace: String,
251/// },
252///
253/// #[error("that email is already registered")]
254/// #[problem(status = 409)]
255/// EmailTaken,
256/// }
257/// ```
258///
259/// `status` is required on every variant and must be between 400 and 599. A
260/// struct declares its one status on the type instead; `base` always belongs on
261/// the type, since it is the prefix every variant's type URI shares.
262///
263/// The error's `Display` supplies each problem's `detail`, which is why
264/// `thiserror` is the expected companion — the `#[error("...")]` a Rust reader
265/// sees is the sentence an API consumer receives. A type without a `Display`
266/// is rejected at the derive rather than at the handler returning it.
267///
268/// A field is published as an extension member only when it says
269/// `#[problem(extension)]`, because a variant carries whatever the error site
270/// had to hand and the default must not be to put that on the wire.
271///
272/// Also emits the `IntoResponse` and `Responses` implementations, so the
273/// statuses the error can produce and the statuses the description advertises
274/// cannot diverge. It is the only supported way to implement
275/// `IntoProblem`.
276#[proc_macro_derive(ApiError, attributes(problem))]
277pub fn derive_api_error(item: TokenStream) -> TokenStream {
278 derive::api_error::expand(item)
279}
280
281/// Declares a closed set of responses, one variant per status.
282///
283/// ```ignore
284/// #[derive(Reply)]
285/// enum CreateReply {
286/// #[reply(status = 201, description = "the user as stored")]
287/// Created(User),
288///
289/// #[reply(status = 200, description = "an identical user already existed")]
290/// AlreadyExists(User),
291/// }
292/// ```
293///
294/// For an operation with more than one success shape. Modelled on
295/// poem-openapi's `ApiResponse`, which is the best existing treatment of this.
296///
297/// `status` is required on every variant and must be between 200 and 599: a 1xx
298/// is an interim response, and a handler returns the final one. No two variants
299/// may declare the same status, since the description keys a reply's variants
300/// by status alone — that is what "one variant per status" means, and it is the
301/// one place this derive is stricter than [`ApiError`](macro@ApiError), whose
302/// variants carry a `detail` that tells two occurrences of a status apart.
303///
304/// A variant's fields are its response body, so a variant holds either nothing,
305/// for the empty body, or exactly one type describing the body. An anonymous
306/// record has no name to register a component under.
307#[proc_macro_derive(Reply, attributes(reply))]
308pub fn derive_reply(item: TokenStream) -> TokenStream {
309 derive::reply::expand(item)
310}
311
312/// Declares a group of path parameters.
313///
314/// Field names must match the route template's variables; the route attribute
315/// emits a const assertion comparing the two sets.
316#[proc_macro_derive(PathParams, attributes(param))]
317pub fn derive_path_params(item: TokenStream) -> TokenStream {
318 derive::path_params::expand(item)
319}
320
321/// Declares a group of query parameters.
322///
323/// Rejects a nested object: `deepObject` is defined only for objects whose
324/// properties are scalars, so anything deeper has no legal serialization. The
325/// diagnostic points at `QueryString<T, M>`, which describes such a shape
326/// properly under `openapi32`.
327#[proc_macro_derive(QueryParams, attributes(param))]
328pub fn derive_query_params(item: TokenStream) -> TokenStream {
329 derive::query_params::expand(item)
330}
331
332/// Declares a group of request or response headers.
333///
334/// Rejects `Accept`, `Content-Type` and `Authorization`. The specification says
335/// a parameter definition for those is ignored; `Content-Type` is likewise
336/// derived from a response's content map. Repeated fields such as `Set-Cookie`
337/// remain separate header values rather than being comma joined. The
338/// diagnostic names the right tool for each reserved field.
339#[proc_macro_derive(HeaderParams, attributes(header))]
340pub fn derive_headers(item: TokenStream) -> TokenStream {
341 derive::headers::expand(item)
342}
343
344/// Declares a group of request cookies.
345#[proc_macro_derive(CookieParams, attributes(cookie))]
346pub fn derive_cookies(item: TokenStream) -> TokenStream {
347 derive::cookies::expand(item)
348}
349
350/// Declares the fields of a `multipart/form-data` body, in both directions.
351///
352/// ```ignore
353/// #[derive(Schema, MultipartForm)]
354/// struct Upload {
355/// name: String,
356/// caption: Option<String>,
357/// images: Vec<FilePart>,
358/// }
359/// ```
360///
361/// One declaration, two implementations: `FromMultipart` reads each field from
362/// the part carrying its name, and `IntoMultipart` writes it back under the
363/// same one — so a body `MultipartForm<T>` accepts is a body it can produce.
364///
365/// A field's type says how many parts carry it: `Vec<T>` is one part per
366/// element, `Option<T>` is a part that need not have been sent, and anything
367/// else is a part that must have been, whose second and later occurrences are
368/// ignored. The element type converts through `FromPart` and `IntoPart`, which
369/// Kynos implements for `FilePart`, `String` and `Bytes`.
370///
371/// A part naming no declared field is ignored, since a form may carry what the
372/// agent rendering it added.
373///
374/// There is no attribute of its own. Derive [`Schema`](macro@Schema) alongside:
375/// this derive says how the parts travel and `Schema` is what puts them in the
376/// description, and both read the part names from the same place — the field's
377/// identifier, or serde's `rename` and `rename_all` when the type carries them.
378#[proc_macro_derive(MultipartForm)]
379pub fn derive_multipart_form(item: TokenStream) -> TokenStream {
380 derive::multipart::expand(item)
381}
382
383/// Declares a tag.
384///
385/// ```ignore
386/// #[derive(Tag)]
387/// #[tag(name = "users", description = "Managing user accounts")]
388/// struct Users;
389/// ```
390///
391/// Tags are types rather than strings, so a typo is a compile error and
392/// uniqueness follows from the module system. `#[tag(parent = Admin)]` nests
393/// one tag under another, which requires `openapi32`.
394#[proc_macro_derive(Tag, attributes(tag))]
395pub fn derive_tag(item: TokenStream) -> TokenStream {
396 derive::tag::expand(item)
397}
398
399/// Declares a security scheme.
400///
401/// ```ignore
402/// #[derive(SecurityScheme)]
403/// #[security(http, scheme = "bearer", bearer_format = "JWT")]
404/// struct Bearer;
405/// ```
406#[proc_macro_derive(SecurityScheme, attributes(security))]
407pub fn derive_security_scheme(item: TokenStream) -> TokenStream {
408 derive::security_scheme::expand(item)
409}
410
411/// Declares an application context, emitting one `Provides` implementation per
412/// field.
413///
414/// ```ignore
415/// #[derive(Provider)]
416/// struct App {
417/// pool: Pool,
418/// cache: Cache,
419/// #[provide(skip)]
420/// started_at: Instant,
421/// }
422/// ```
423///
424/// Each provided field's type must be `Clone`, since a value is handed out per
425/// request; a handle is the intended shape. A handler asking for something no
426/// field supplies fails to typecheck, rather than panicking at runtime the way
427/// an erased state map does.
428///
429/// Two provided fields of the same type are rejected here, naming both, rather
430/// than being left to produce a coherence error about the derive's own output.
431#[proc_macro_derive(Provider, attributes(provide))]
432pub fn derive_provider(item: TokenStream) -> TokenStream {
433 derive::provider::expand(item)
434}