use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use axum::Router;
use axum::extract::{FromRef, Path, State};
use axum::response::IntoResponse;
use axum::routing::get;
use http::{StatusCode, Uri};
use jsonapi_axum::{
ApiErrorExt, BaseUrl, ClientIdPolicy, DocumentBuilder, Field, IntoJsonApiError, JsonApi,
JsonApiError, JsonApiLayer, JsonApiQuery, JsonApiResponse, JsonApiToMany, NegotiatedMediaType,
NormalizeErrorsLayer, RelationshipResponse, RequestIdLayer, ResultExt, pagination_links,
with_status,
};
use jsonapi_core::{
Link, PageNumberPage, PageStrategy, RelationshipData, ResourceIdentifier, links,
};
use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer};
#[derive(Debug, Clone, jsonapi_core::JsonApi)]
#[jsonapi(type = "articles")]
struct Article {
#[jsonapi(id)]
id: String,
title: String,
body: String,
summary: Option<String>,
}
#[derive(Debug, Clone, jsonapi_core::JsonApi)]
#[jsonapi(type = "articles")]
struct NewArticle {
#[jsonapi(id)]
id: Option<String>,
title: String,
body: String,
summary: Option<String>,
}
#[derive(Debug, Clone, jsonapi_core::JsonApi)]
#[jsonapi(type = "articles")]
struct ArticlePatch {
#[jsonapi(id)]
id: String,
title: Field<String>,
body: Field<String>,
summary: Field<String>,
}
#[derive(Clone)]
struct AppState {
base_url: BaseUrl,
articles: Arc<Mutex<BTreeMap<String, Article>>>,
tags: Arc<Mutex<BTreeMap<String, Vec<ResourceIdentifier>>>>,
next_id: Arc<Mutex<u64>>,
}
impl Default for AppState {
fn default() -> Self {
Self {
base_url: BaseUrl("http://localhost:3000".to_string()),
articles: Arc::default(),
tags: Arc::default(),
next_id: Arc::default(),
}
}
}
impl FromRef<AppState> for BaseUrl {
fn from_ref(state: &AppState) -> BaseUrl {
state.base_url.clone()
}
}
impl AppState {
fn allocate_id(&self) -> String {
let mut next = self.next_id.lock().unwrap();
*next += 1;
next.to_string()
}
}
fn not_found(id: &str) -> JsonApiError {
JsonApiError::from_api_error(
with_status(StatusCode::NOT_FOUND).detail(format!("article `{id}` does not exist")),
)
}
struct BlankTitle;
impl IntoJsonApiError for BlankTitle {
fn into_json_api_error(self) -> JsonApiError {
JsonApiError::from_api_error(
with_status(StatusCode::UNPROCESSABLE_ENTITY)
.pointer("/data/attributes/title")
.detail("title must not be empty"),
)
}
}
fn validate_title(title: &str) -> Result<(), BlankTitle> {
if title.trim().is_empty() {
Err(BlankTitle)
} else {
Ok(())
}
}
async fn list_articles(
State(state): State<AppState>,
NegotiatedMediaType(media): NegotiatedMediaType,
uri: Uri,
JsonApiQuery(query): JsonApiQuery,
) -> Result<impl IntoResponse, JsonApiError> {
let page = PageNumberPage::from_query(&query)?;
let window = page.resolve(2, 100);
let all: Vec<Article> = state.articles.lock().unwrap().values().cloned().collect();
let total = all.len() as u64;
let items: Vec<Article> = all
.into_iter()
.skip(window.offset as usize)
.take(window.limit as usize)
.collect();
let strategy = PageStrategy::PageNumber {
number: window.number,
size: window.limit,
};
let links = pagination_links(&uri, strategy, Some(total));
Ok(
JsonApiResponse::new(DocumentBuilder::collection(items).links(links).build())
.fields(query.fields)
.media_type(media),
)
}
async fn get_article(
State(state): State<AppState>,
BaseUrl(base): BaseUrl,
NegotiatedMediaType(media): NegotiatedMediaType,
Path(id): Path<String>,
JsonApiQuery(query): JsonApiQuery,
) -> Result<JsonApiResponse<Article>, JsonApiError> {
let article = state.articles.lock().unwrap().get(&id).cloned();
match article {
Some(article) => {
let self_link = links::resource_self(&base, "articles", &id);
Ok(JsonApiResponse::new(
DocumentBuilder::single(article)
.link("self", Link::String(self_link))
.build(),
)
.fields(query.fields)
.media_type(media))
}
None => Err(not_found(&id)),
}
}
async fn create_article(
State(state): State<AppState>,
BaseUrl(base): BaseUrl,
NegotiatedMediaType(media): NegotiatedMediaType,
document: JsonApi<NewArticle>,
) -> Result<impl IntoResponse, JsonApiError> {
document.check_client_id(ClientIdPolicy::Forbid)?;
let new = document
.0
.into_single()
.map_err(|err| JsonApiError::from_core(&err))?;
validate_title(&new.title).or_json_api()?;
let article = Article {
id: state.allocate_id(),
title: new.title,
body: new.body,
summary: new.summary,
};
state
.articles
.lock()
.unwrap()
.insert(article.id.clone(), article.clone());
let self_link = links::resource_self(&base, "articles", &article.id);
Ok(JsonApiResponse::new(
DocumentBuilder::single(article)
.link("self", Link::String(self_link.clone()))
.build(),
)
.created(self_link)
.media_type(media))
}
async fn update_article(
State(state): State<AppState>,
BaseUrl(base): BaseUrl,
NegotiatedMediaType(media): NegotiatedMediaType,
Path(id): Path<String>,
document: JsonApi<ArticlePatch>,
) -> Result<JsonApiResponse<Article>, JsonApiError> {
document.require_id(&id)?; let patch = document
.0
.into_single()
.map_err(|err| JsonApiError::from_core(&err))?;
let mut store = state.articles.lock().unwrap();
let Some(article) = store.get_mut(&id) else {
return Err(not_found(&id));
};
if let Some(title) = patch.title.into_set() {
article.title = title;
}
if let Some(body) = patch.body.into_set() {
article.body = body;
}
patch.summary.apply(&mut article.summary);
let updated = article.clone();
drop(store);
let self_link = links::resource_self(&base, "articles", &id);
Ok(JsonApiResponse::new(
DocumentBuilder::single(updated)
.link("self", Link::String(self_link))
.build(),
)
.media_type(media))
}
async fn delete_article(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, JsonApiError> {
if state.articles.lock().unwrap().remove(&id).is_some() {
state.tags.lock().unwrap().remove(&id);
Ok(StatusCode::NO_CONTENT)
} else {
Err(not_found(&id))
}
}
fn tags_response(state: &AppState, id: &str) -> RelationshipResponse {
let current = state
.tags
.lock()
.unwrap()
.get(id)
.cloned()
.unwrap_or_default();
let links = links::relationship_links(&state.base_url.0, "articles", id, "tags");
RelationshipResponse::new(RelationshipData::ToMany(current)).links(links)
}
async fn get_tags(State(state): State<AppState>, Path(id): Path<String>) -> RelationshipResponse {
tags_response(&state, &id)
}
async fn append_tags(
State(state): State<AppState>,
Path(id): Path<String>,
JsonApiToMany(incoming): JsonApiToMany,
) -> RelationshipResponse {
{
let mut store = state.tags.lock().unwrap();
let set = store.entry(id.clone()).or_default();
for rid in incoming {
if !set.contains(&rid) {
set.push(rid);
}
}
}
tags_response(&state, &id)
}
async fn replace_tags(
State(state): State<AppState>,
Path(id): Path<String>,
JsonApiToMany(incoming): JsonApiToMany,
) -> RelationshipResponse {
state.tags.lock().unwrap().insert(id.clone(), incoming);
tags_response(&state, &id)
}
async fn remove_tags(
State(state): State<AppState>,
Path(id): Path<String>,
JsonApiToMany(remove): JsonApiToMany,
) -> RelationshipResponse {
{
let mut store = state.tags.lock().unwrap();
if let Some(set) = store.get_mut(&id) {
set.retain(|rid| !remove.contains(rid));
}
}
tags_response(&state, &id)
}
fn app() -> Router {
Router::new()
.route("/articles", get(list_articles).post(create_article))
.route(
"/articles/{id}",
get(get_article)
.patch(update_article)
.delete(delete_article),
)
.route(
"/articles/{id}/relationships/tags",
get(get_tags)
.post(append_tags)
.patch(replace_tags)
.delete(remove_tags),
)
.fallback(jsonapi_axum::not_found)
.layer(JsonApiLayer::new())
.layer(NormalizeErrorsLayer::new())
.layer(RequestIdLayer::new())
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.with_state(AppState::default())
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.expect("bind 127.0.0.1:3000");
println!(
"JSON:API CRUD server listening on http://{}",
listener.local_addr().unwrap()
);
axum::serve(listener, app()).await.expect("server error");
}