stac_server/
routes.rs

1//! Routes for serving API endpoints.
2
3use crate::{Api, Backend};
4use axum::{
5    Json, Router,
6    extract::{Path, Query, State, rejection::JsonRejection},
7    http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
8    response::{Html, IntoResponse, Response},
9    routing::{get, post},
10};
11use bytes::{BufMut, BytesMut};
12use http::Method;
13use serde::Serialize;
14use stac::api::{Collections, GetItems, GetSearch, ItemCollection, Items, Root, Search};
15use stac::{
16    Collection, Item,
17    mime::{APPLICATION_GEOJSON, APPLICATION_OPENAPI_3_0},
18};
19use tower_http::{cors::CorsLayer, trace::TraceLayer};
20
21/// Errors for our axum routes.
22#[derive(Debug)]
23#[non_exhaustive]
24pub enum Error {
25    /// An server error.
26    Server(crate::Error),
27
28    /// An error raised when something is not found.
29    NotFound(String),
30
31    /// An error raised when it's a bad request from the client.
32    BadRequest(String),
33}
34
35type Result<T> = std::result::Result<T, Error>;
36
37/// A wrapper struct for any geojson response.
38// Taken from https://docs.rs/axum/latest/src/axum/json.rs.html#93
39#[derive(Debug)]
40pub struct GeoJson<T>(pub T);
41
42impl IntoResponse for Error {
43    fn into_response(self) -> Response {
44        match self {
45            Error::Server(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()),
46            Error::NotFound(message) => (StatusCode::NOT_FOUND, message),
47            Error::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
48        }
49        .into_response()
50    }
51}
52
53impl From<crate::Error> for Error {
54    fn from(error: crate::Error) -> Self {
55        Error::Server(error)
56    }
57}
58
59impl From<JsonRejection> for Error {
60    fn from(json_rejection: JsonRejection) -> Self {
61        Error::BadRequest(format!("bad request, json rejection: {json_rejection}"))
62    }
63}
64
65impl<T> IntoResponse for GeoJson<T>
66where
67    T: Serialize,
68{
69    fn into_response(self) -> Response {
70        // Use a small initial capacity of 128 bytes like serde_json::to_vec
71        // https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
72        let mut buf = BytesMut::with_capacity(128).writer();
73        match serde_json::to_writer(&mut buf, &self.0) {
74            Ok(()) => (
75                [(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_GEOJSON))],
76                buf.into_inner().freeze(),
77            )
78                .into_response(),
79            Err(err) => (
80                StatusCode::INTERNAL_SERVER_ERROR,
81                [(
82                    CONTENT_TYPE,
83                    HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
84                )],
85                err.to_string(),
86            )
87                .into_response(),
88        }
89    }
90}
91
92/// Creates an [axum::Router] from an [Api].
93///
94/// # Examples
95///
96/// ```
97/// use stac_server::{Api, MemoryBackend, routes};
98///
99/// let api = Api::new(MemoryBackend::new(), "http://stac.test").unwrap();
100/// let router = routes::from_api(api);
101/// ```
102pub fn from_api<B: Backend>(api: Api<B>) -> Router {
103    Router::new()
104        .route("/", get(root))
105        .route("/api", get(service_desc))
106        .route("/api.html", get(service_doc))
107        .route("/conformance", get(conformance))
108        .route("/queryables", get(queryables))
109        .route("/collections", get(collections))
110        .route("/collections/{collection_id}", get(collection))
111        .route("/collections/{collection_id}/items", get(items))
112        .route("/collections/{collection_id}/items/{item_id}", get(item))
113        .route("/search", get(get_search))
114        .route("/search", post(post_search))
115        .layer(CorsLayer::permissive()) // TODO make this configurable
116        .layer(TraceLayer::new_for_http())
117        .with_state(api)
118}
119
120/// Returns the `/` endpoint from the [core conformance
121/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
122pub async fn root<B: Backend>(State(api): State<Api<B>>) -> Result<Json<Root>> {
123    api.root().await.map(Json).map_err(Error::from)
124}
125
126/// Returns the `/api` endpoint from the [core conformance
127/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
128pub async fn service_desc() -> Response {
129    // The OpenAPI definition is completely stolen from [stac-server](https://github.com/stac-utils/stac-server/blob/dd7e3acbf47485425e2068fd7fbbceeafe4b4e8c/src/lambdas/api/openapi.yaml).
130    //
131    // TODO add a script to update the definition in this library.
132    (
133        [(CONTENT_TYPE, APPLICATION_OPENAPI_3_0)],
134        include_str!("openapi.yaml"),
135    )
136        .into_response()
137}
138
139/// Returns the `/api.html` endpoint from the [core conformance
140/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/core#endpoints).
141pub async fn service_doc() -> Response {
142    // The redoc file is completely stolen from [stac-server](https://github.com/stac-utils/stac-server/blob/dd7e3acbf47485425e2068fd7fbbceeafe4b4e8c/src/lambdas/api/redoc.html).
143    Html(include_str!("redoc.html")).into_response()
144}
145
146/// Returns the `/conformance` endpoint from the [ogcapi-features conformance
147/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
148pub async fn conformance<B: Backend>(State(api): State<Api<B>>) -> Response {
149    Json(api.conformance()).into_response()
150}
151
152/// Returns the `/queryables` endpoint.
153pub async fn queryables<B: Backend>(State(api): State<Api<B>>) -> Response {
154    (
155        [(CONTENT_TYPE, "application/schema+json")],
156        Json(api.queryables()),
157    )
158        .into_response()
159}
160
161/// Returns the `/collections` endpoint from the [ogcapi-features conformance
162/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
163pub async fn collections<B: Backend>(State(api): State<Api<B>>) -> Result<Json<Collections>> {
164    api.collections().await.map(Json).map_err(Error::from)
165}
166
167/// Returns the `/collections/{collectionId}` endpoint from the [ogcapi-features
168/// conformance
169/// class](https://github.com/radiantearth/stac-api-spec/blob/release/v1.0.0/ogcapi-features/README.md#endpoints).
170pub async fn collection<B: Backend>(
171    State(api): State<Api<B>>,
172    Path(collection_id): Path<String>,
173) -> Result<Json<Collection>> {
174    api.collection(&collection_id)
175        .await
176        .map_err(Error::from)
177        .and_then(|option| {
178            option
179                .ok_or_else(|| Error::NotFound(format!("no collection with id='{collection_id}'")))
180        })
181        .map(Json)
182}
183
184/// Returns the `/collections/{collectionId}/items` endpoint from the
185/// [ogcapi-features conformance
186/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/ogcapi-features#collection-items-collectionscollectioniditems)
187pub async fn items<B: Backend>(
188    State(api): State<Api<B>>,
189    Path(collection_id): Path<String>,
190    items: Query<GetItems>,
191) -> Result<GeoJson<ItemCollection>> {
192    let items = Items::try_from(items.0)
193        .and_then(Items::valid)
194        .map_err(|error| Error::BadRequest(format!("invalid query: {error}")))?;
195    api.items(&collection_id, items)
196        .await
197        .map_err(Error::from)
198        .and_then(|option| {
199            option
200                .ok_or_else(|| Error::NotFound(format!(" no collection with id='{collection_id}'")))
201        })
202        .map(GeoJson)
203}
204
205/// Returns the `/collections/{collectionId}/items/{itemId}` endpoint from the
206/// [ogcapi-features conformance
207/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/ogcapi-features#collection-items-collectionscollectioniditems)
208pub async fn item<B: Backend>(
209    State(api): State<Api<B>>,
210    Path((collection_id, item_id)): Path<(String, String)>,
211) -> Result<GeoJson<Item>> {
212    api.item(&collection_id, &item_id)
213        .await?
214        .ok_or_else(|| {
215            Error::NotFound(format!(
216                "no item with id='{item_id}' in collection='{collection_id}'"
217            ))
218        })
219        .map(GeoJson)
220}
221
222/// Returns the GET `/search` endpoint from the [item search conformance
223/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/item-search)
224pub async fn get_search<B: Backend>(
225    State(api): State<Api<B>>,
226    search: Query<GetSearch>,
227) -> Result<GeoJson<ItemCollection>> {
228    tracing::debug!("GET /search: {:?}", search.0);
229    let search = Search::try_from(search.0)
230        .and_then(Search::valid)
231        .map_err(|error| Error::BadRequest(error.to_string()))?;
232
233    Ok(GeoJson(api.search(search, Method::GET).await?))
234}
235
236/// Returns the POST `/search` endpoint from the [item search conformance
237/// class](https://github.com/radiantearth/stac-api-spec/tree/release/v1.0.0/item-search)
238pub async fn post_search<B: Backend>(
239    State(api): State<Api<B>>,
240    search: std::result::Result<Json<Search>, JsonRejection>,
241) -> Result<GeoJson<ItemCollection>> {
242    let search = search?
243        .0
244        .valid()
245        .map_err(|error| Error::BadRequest(error.to_string()))?;
246    Ok(GeoJson(api.search(search, Method::POST).await?))
247}
248
249#[cfg(test)]
250mod tests {
251    use crate::{Api, Backend, MemoryBackend};
252    use axum::{
253        body::Body,
254        http::{Request, Response, StatusCode, header::CONTENT_TYPE},
255    };
256    use stac::{Collection, Item};
257    use tower::util::ServiceExt;
258
259    async fn get(backend: MemoryBackend, uri: &str) -> Response<Body> {
260        let router = super::from_api(
261            Api::new(backend, "http://stac.test/")
262                .unwrap()
263                .id("an-id")
264                .description("a description"),
265        );
266        router
267            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
268            .await
269            .unwrap()
270    }
271
272    async fn post(backend: MemoryBackend, uri: &str) -> Response<Body> {
273        let router = super::from_api(
274            Api::new(backend, "http://stac.test/")
275                .unwrap()
276                .id("an-id")
277                .description("a description"),
278        );
279        router
280            .oneshot(
281                Request::builder()
282                    .uri(uri)
283                    .method("POST")
284                    .header("Content-Type", "application/json")
285                    .body("{}".to_string())
286                    .unwrap(),
287            )
288            .await
289            .unwrap()
290    }
291
292    #[tokio::test]
293    async fn root() {
294        let response = get(MemoryBackend::new(), "/").await;
295        assert_eq!(response.status(), StatusCode::OK);
296        assert_eq!(
297            response.headers().get(CONTENT_TYPE).unwrap(),
298            "application/json"
299        );
300    }
301
302    #[tokio::test]
303    async fn service_description() {
304        let response = get(MemoryBackend::new(), "/api").await;
305        assert_eq!(response.status(), StatusCode::OK);
306        assert_eq!(
307            response.headers().get(CONTENT_TYPE).unwrap(),
308            "application/vnd.oai.openapi+json;version=3.0"
309        );
310    }
311
312    #[tokio::test]
313    async fn service_doc() {
314        let response = get(MemoryBackend::new(), "/api.html").await;
315        assert_eq!(response.status(), StatusCode::OK);
316        assert_eq!(
317            response.headers().get(CONTENT_TYPE).unwrap(),
318            "text/html; charset=utf-8"
319        );
320    }
321
322    #[tokio::test]
323    async fn conformance() {
324        let response = get(MemoryBackend::new(), "/conformance").await;
325        assert_eq!(response.status(), StatusCode::OK);
326        assert_eq!(
327            response.headers().get(CONTENT_TYPE).unwrap(),
328            "application/json"
329        );
330    }
331
332    #[tokio::test]
333    async fn collections() {
334        let response = get(MemoryBackend::new(), "/collections").await;
335        assert_eq!(response.status(), StatusCode::OK);
336        assert_eq!(
337            response.headers().get(CONTENT_TYPE).unwrap(),
338            "application/json"
339        );
340    }
341
342    #[tokio::test]
343    async fn collection() {
344        let response = get(MemoryBackend::new(), "/collections/an-id").await;
345        assert_eq!(response.status(), StatusCode::NOT_FOUND);
346        let mut backend = MemoryBackend::new();
347        backend
348            .add_collection(Collection::new("an-id", "A description"))
349            .await
350            .unwrap();
351        let response = get(backend, "/collections/an-id").await;
352        assert_eq!(response.status(), StatusCode::OK);
353        assert_eq!(
354            response.headers().get(CONTENT_TYPE).unwrap(),
355            "application/json"
356        );
357    }
358
359    #[tokio::test]
360    async fn items() {
361        let response = get(MemoryBackend::new(), "/collections/collection-id/items").await;
362        assert_eq!(response.status(), StatusCode::NOT_FOUND);
363
364        let mut backend = MemoryBackend::new();
365        backend
366            .add_collection(Collection::new("collection-id", "A description"))
367            .await
368            .unwrap();
369        backend
370            .add_item(Item::new("item-id").collection("collection-id"))
371            .await
372            .unwrap();
373        let response = get(backend, "/collections/collection-id/items").await;
374        assert_eq!(response.status(), StatusCode::OK);
375        assert_eq!(
376            response.headers().get(CONTENT_TYPE).unwrap(),
377            "application/geo+json"
378        );
379    }
380
381    #[tokio::test]
382    async fn item() {
383        let response = get(
384            MemoryBackend::new(),
385            "/collections/collection-id/items/item-id",
386        )
387        .await;
388        assert_eq!(response.status(), StatusCode::NOT_FOUND);
389
390        let mut backend = MemoryBackend::new();
391        backend
392            .add_collection(Collection::new("collection-id", "A description"))
393            .await
394            .unwrap();
395        backend
396            .add_item(Item::new("item-id").collection("collection-id"))
397            .await
398            .unwrap();
399        let response = get(backend, "/collections/collection-id/items/item-id").await;
400        assert_eq!(response.status(), StatusCode::OK);
401        assert_eq!(
402            response.headers().get(CONTENT_TYPE).unwrap(),
403            "application/geo+json"
404        );
405    }
406
407    #[tokio::test]
408    async fn get_search() {
409        let response = get(MemoryBackend::new(), "/search").await;
410        assert_eq!(response.status(), StatusCode::OK);
411        assert_eq!(
412            response.headers().get(CONTENT_TYPE).unwrap(),
413            "application/geo+json"
414        );
415    }
416
417    #[tokio::test]
418    async fn post_search() {
419        let response = post(MemoryBackend::new(), "/search").await;
420        assert_eq!(response.status(), StatusCode::OK);
421        assert_eq!(
422            response.headers().get(CONTENT_TYPE).unwrap(),
423            "application/geo+json"
424        );
425    }
426}