Skip to main content

any2nix_api/
lib.rs

1// SPDX-FileCopyrightText: 2026 Gabriel Santos de Souza <gabriel.santosdesouza@dcomp.ufs.br>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5// TODO: Remove manual descriptions once utoipa supports docstring links.
6
7#![doc(html_favicon_url = "https://zipline.gs-101.dev/u/DF6up7.ico")]
8#![doc(html_logo_url = "https://zipline.gs-101.dev/u/DZWGB0.svg")]
9//! This crate provides an [HTTP](https://httpwg.org/specs/) server to be used as
10//! an [OpenAPI](https://www.openapis.org/)-compatible API for translating different
11//! formats to [Nix](https://nixos.org).
12//!
13//! It exposes its own router for integration with other [axum]-based programs.
14//!
15//! # Usage
16//!
17//! This crate is on [crates.io](https://crates.io/crates/any2nix) and can
18//! be added as a dependency of your project:
19//!
20//! ```bash
21//! cargo add any2nix-api
22//! ```
23//!
24//! # Supported Formats
25//!
26//! See [any2nix::Format] for an enumeration of supported formats.
27//!
28//! Each format is gated behind its own [feature](https://doc.rust-lang.org/cargo/reference/features.html).
29//!
30//! The default feature enables all formats.
31//!
32//! # Crate Features
33//!
34//! Besides the features for each format, this crate also exposes the following
35//! features:
36//!
37//! - `trace`: Enables tracing of incoming HTTP requests through [tower-http] and [tracing].
38//! - `utoipa`: Enables OpenAPI schema generation through [utoipa].
39//! - `utoipa-swagger-ui`: Enables the [Swagger UI](https://swagger.io/tools/swagger-ui/) for the API through [utoipa-swagger-ui].
40//!
41//! # Examples: Adding it as an API to another program
42//!
43//! 1. Add it as dependency of your program:
44//!
45//! ```bash
46//! cargo add any2nix-api
47//! ```
48//!
49//! 2. Add the router to your [axum] program through [nesting][axum::Router::nest]:
50//!
51//! ```rust,no_run
52//! # #[tokio::main]
53//! # async fn main() {
54//! use axum::{routing::get, Router};
55//! use tokio::net::TcpListener;
56//!
57//! let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
58//! let api_router = any2nix_api::app();
59//! let app = Router::new().route("/", get(|| async { "Hello, World!"})).nest("/api", api_router);
60//!
61//! axum::serve(listener, app).await.unwrap();
62//! # }
63//! ```
64//!
65//! # Examples: Sending TOML to the API
66//!
67//! ```bash
68//! curl \
69//! -d '{"input": "[package]\nname = \"any2nix\""}' \
70//! -X POST \
71//! -H "Content-Type: application/json" \
72//! http://localhost:3000/v1/toml
73//! # Output:
74//! #
75//! # {"nix":"{\n  package = {\n    name = \"any2nix\";\n  };\n}"}
76//! ```
77
78pub mod requests;
79pub mod responses;
80
81use crate::requests::ConvertRequest;
82use crate::responses::{ConvertResponse, ErrorResponse, RootResponse};
83use any2nix::Format;
84use axum::{
85    Json, Router,
86    extract::Path,
87    http::StatusCode,
88    response::{IntoResponse, Response},
89    routing::{get, post},
90};
91#[cfg(feature = "trace")]
92use tower_http::trace::TraceLayer;
93#[cfg(feature = "utoipa")]
94use utoipa::OpenApi;
95
96/// Error type of the API.
97#[derive(Debug)]
98pub struct Error(any2nix::Error);
99
100impl IntoResponse for Error {
101    fn into_response(self) -> Response {
102        let body = Json(ErrorResponse {
103            error: self.0.to_string(),
104        });
105        (StatusCode::BAD_REQUEST, body).into_response()
106    }
107}
108
109/// Root endpoint of the API. Returns crate information.
110#[cfg_attr(
111    feature = "utoipa",
112    utoipa::path(
113        get,
114        path = "/",
115        responses(
116            (body = RootResponse, description = "Root endpoint of the API", status = 200)
117        ),
118        summary = "Root endpoint of the API",
119    )
120)]
121pub async fn get_root() -> Json<RootResponse> {
122    Json(RootResponse {
123        description: env!("CARGO_PKG_DESCRIPTION").to_string(),
124        #[cfg(feature = "utoipa-swagger-ui")]
125        docs: "/docs".to_string(),
126        name: env!("CARGO_PKG_NAME").to_string(),
127        #[cfg(feature = "utoipa")]
128        openapi: "/api-docs/openapi.json".to_string(),
129        version: env!("CARGO_PKG_VERSION").to_string(),
130    })
131}
132
133/// Converts the [supported formats][crate#supported-formats] to [Nix](https://nixos.org).
134///
135/// See [ConvertRequest] and [ConvertResponse] for the request and response
136/// schema, respectively.
137///
138/// # Errors
139///
140/// Returns [Error::Conversion] in case something went wrong during the
141/// conversion processs.
142///
143/// [axum::extract::Path].
144#[cfg_attr(
145    feature = "utoipa",
146    utoipa::path(
147        description = "Converts input data from the supported formats to Nix",
148        path = "/v1/{format}",
149                params(
150            ("format" = Format, Path, description = "Source format to convert from")
151        ),
152        post,
153        request_body = ConvertRequest,
154        responses(
155            (body = ConvertResponse, description = "Conversion successful", status = 200),
156            (body = ErrorResponse, description = "Conversion error", status = 400),
157            (description = "Unsupported format", status = 404)
158        ),
159        summary = "Converts the supported formats to Nix",
160    )
161)]
162pub async fn post_format(
163    Path(format): Path<Format>,
164    Json(payload): Json<ConvertRequest>,
165) -> Result<Json<ConvertResponse>, Error> {
166    let nix = format.to_nix(&payload.input).map_err(Error)?;
167
168    Ok(Json(ConvertResponse { nix }))
169}
170
171/// Generator of the program's router. Exposed publicly for integration with other
172/// [axum]-based programs.
173///
174/// Exposes the "/v1/{format}" route, such that {format} is one of the
175/// [supported formats][crate#supported-formats].
176///
177/// # Crate Features
178///
179/// With `utoipa-swagger-ui` enabled, it also exposes the "/docs" route for the
180/// [Swagger UI](https://swagger.io/tools/swagger-ui/) of the API.
181///
182/// # Examples: Adding it as an API to another program through [nesting][axum::Router::nest]
183///
184///
185/// ```rust,no_run
186/// # #[tokio::main]
187/// # async fn main() {
188/// use axum::{routing::get, Router};
189/// use tokio::net::TcpListener;
190///
191/// let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
192/// let api_router = any2nix_api::app();
193/// let app = Router::new().route("/", get(|| async { "Hello, World!"})).nest("/api", api_router);
194///
195/// axum::serve(listener, app).await.unwrap();
196/// # }
197/// ```
198pub fn app() -> Router {
199    let router = Router::new()
200        .route("/", get(get_root))
201        .route("/v1/{format}", post(post_format));
202    #[cfg(feature = "utoipa-swagger-ui")]
203    let router = {
204        let config = utoipa_swagger_ui::Config::new(["/api-docs/openapi.json"]).use_base_layout();
205
206        router.merge(
207            utoipa_swagger_ui::SwaggerUi::new("/docs")
208                .config(config)
209                .url("/api-docs/openapi.json", ApiDoc::openapi()),
210        )
211    };
212
213    #[cfg(feature = "trace")]
214    let router = router.layer(TraceLayer::new_for_http());
215
216    router
217}
218
219#[cfg(feature = "utoipa")]
220#[derive(OpenApi)]
221#[openapi(
222    components(schemas(ConvertRequest, ConvertResponse, ErrorResponse, Format, RootResponse)),
223    info(contact(
224        name = "GitHub Issues",
225        url = "https://github.com/gbr-ufs/any2nix/issues"
226    )),
227    paths(get_root, post_format)
228)]
229struct ApiDoc;
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[tokio::test]
236    async fn returns_crate_information() {
237        let response = get_root().await;
238
239        assert_eq!(response.0.description, env!("CARGO_PKG_DESCRIPTION"));
240        assert_eq!(response.0.name, env!("CARGO_PKG_NAME"));
241        assert_eq!(response.0.version, env!("CARGO_PKG_VERSION"));
242    }
243
244    #[cfg(feature = "ini")]
245    #[tokio::test]
246    async fn errors_on_invalid_format() {
247        let request = ConvertRequest {
248            input: "[broken\nnonsense = \"".to_string(),
249        };
250        let response_status = post_format(Path(Format::Ini), Json(request))
251            .await
252            .into_response()
253            .status();
254        let expected = StatusCode::BAD_REQUEST;
255
256        assert_eq!(response_status, expected);
257    }
258
259    #[cfg(feature = "ini")]
260    #[tokio::test]
261    async fn converts_valid_format() {
262        let request = ConvertRequest {
263            input: "enable-mouse = no\n[dmenu]\nmode = index".to_string(),
264        };
265        let response_status = post_format(Path(Format::Ini), Json(request))
266            .await
267            .into_response()
268            .status();
269        let expected = StatusCode::OK;
270
271        assert_eq!(response_status, expected)
272    }
273
274    #[tokio::test]
275    async fn returns_functional_router() {
276        let app = app();
277
278        assert!(app.has_routes())
279    }
280}