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