use std::sync::Arc;
use lino_objects_codec::LinoValue;
use crate::app::LinoApp;
use crate::codec::encode_for;
use crate::collection::pagination_link_header;
use crate::etag::{compute_etag, evaluate_preconditions};
use crate::problem::LinoHttpError;
use crate::request::{LinoRequest, percent_encode};
use crate::response::{Body, LinoResult, created, no_content, ok};
use crate::store::Store;
use crate::value::{get, to_query_text, with};
pub const RESOURCE_OPERATIONS: [&str; 6] = ["list", "create", "get", "update", "patch", "remove"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceOptions {
pub id_param: String,
pub id_field: Option<String>,
pub name: Option<String>,
pub operations: Vec<String>,
pub require_precondition: bool,
pub upsert: bool,
}
impl Default for ResourceOptions {
fn default() -> Self {
Self {
id_param: "id".to_string(),
id_field: None,
name: None,
operations: RESOURCE_OPERATIONS.iter().map(|s| s.to_string()).collect(),
require_precondition: false,
upsert: false,
}
}
}
impl ResourceOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
self.name = Some(name.into());
self
}
pub fn with_id_param<S: Into<String>>(mut self, id_param: S) -> Self {
self.id_param = id_param.into();
self
}
pub fn with_id_field<S: Into<String>>(mut self, id_field: S) -> Self {
self.id_field = Some(id_field.into());
self
}
pub fn with_operations<I, S>(mut self, operations: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.operations = operations.into_iter().map(Into::into).collect();
self
}
pub fn requiring_precondition(mut self, require: bool) -> Self {
self.require_precondition = require;
self
}
pub fn with_upsert(mut self, upsert: bool) -> Self {
self.upsert = upsert;
self
}
fn exposes(&self, operation: &str) -> bool {
self.operations.iter().any(|name| name == operation)
}
}
pub fn representation_etag(
request: &LinoRequest,
value: &LinoValue,
) -> Result<String, LinoHttpError> {
let encoded = encode_for(value, &request.media_type)
.map_err(|error| LinoHttpError::new(500, Some(&error.to_string())))?;
Ok(compute_etag(&encoded))
}
fn not_found(name: &str, identifier: &str) -> LinoHttpError {
LinoHttpError::new(404, Some(&format!("{name} {identifier} does not exist")))
}
fn page_number(envelope: &LinoValue, key: &str) -> usize {
match get(envelope, "page").and_then(|page| get(page, key)) {
Some(LinoValue::Int(number)) if *number >= 0 => *number as usize,
_ => 0,
}
}
fn item_location(base_path: &str, identifier: &LinoValue) -> String {
format!("{base_path}/{}", percent_encode(&to_query_text(identifier)))
}
async fn load_for_write(
store: &dyn Store,
request: &LinoRequest,
identifier: &str,
require_precondition: bool,
) -> Result<Option<LinoValue>, LinoHttpError> {
let Some(existing) = store.get(identifier).await? else {
return Ok(None);
};
evaluate_preconditions(
&request.headers,
&request.method,
&representation_etag(request, &existing)?,
require_precondition,
)?;
Ok(Some(existing))
}
pub fn register_resource<S: Store + 'static>(
app: &mut LinoApp,
base_path: &str,
store: Arc<S>,
options: ResourceOptions,
) {
let field = options
.id_field
.clone()
.unwrap_or_else(|| store.id_field().to_string());
let name = options
.name
.clone()
.unwrap_or_else(|| base_path.trim_matches('/').to_string());
let item_path = format!("{base_path}/:{}", options.id_param);
let base_path = base_path.to_string();
if options.exposes("list") {
let store = store.clone();
app.get(
&base_path,
&format!("List {name}"),
move |request: LinoRequest| {
let store = store.clone();
async move {
let query = request.collection_query()?;
let envelope = store.list(&query).await?;
let link = pagination_link_header(
&request.path,
&request.query(),
page_number(&envelope, "limit"),
page_number(&envelope, "offset"),
page_number(&envelope, "total"),
);
let result = ok(envelope);
Ok(if link.is_empty() {
result
} else {
result.with_header("Link", link)
})
}
},
);
}
if options.exposes("create") {
let store = store.clone();
let field = field.clone();
let base = base_path.clone();
app.post(
&base_path,
&format!("Create {name}"),
move |request: LinoRequest| {
let store = store.clone();
let field = field.clone();
let base = base.clone();
async move {
let body = request.require_body()?.clone();
let item = store.create(&body).await?;
let location = match get(&item, &field) {
Some(LinoValue::Null) | None => None,
Some(identifier) => Some(item_location(&base, identifier)),
};
Ok(match location {
Some(location) => created(item, &location),
None => LinoResult::new(201, Body::Value(item)),
})
}
},
);
}
if options.exposes("get") {
let store = store.clone();
let name = name.clone();
let id_param = options.id_param.clone();
app.get(
&item_path,
&format!("Read one {name}"),
move |request: LinoRequest| {
let store = store.clone();
let name = name.clone();
let id_param = id_param.clone();
async move {
let identifier = request.param(&id_param).unwrap_or_default();
store
.get(&identifier)
.await?
.ok_or_else(|| not_found(&name, &identifier))
}
},
);
}
if options.exposes("update") {
let store = store.clone();
let name = name.clone();
let field = field.clone();
let base = base_path.clone();
let id_param = options.id_param.clone();
let require_precondition = options.require_precondition;
let upsert = options.upsert;
app.put(
&item_path,
&format!("Replace one {name}"),
move |request: LinoRequest| {
let store = store.clone();
let name = name.clone();
let field = field.clone();
let base = base.clone();
let id_param = id_param.clone();
async move {
let body = request.require_body()?.clone();
let identifier = request.param(&id_param).unwrap_or_default();
let existing =
load_for_write(&*store, &request, &identifier, require_precondition)
.await?;
if existing.is_none() {
if !upsert {
return Err(not_found(&name, &identifier));
}
let seeded = with(
&body,
field.clone(),
crate::store::normalize_id(&identifier),
);
let item = store.create(&seeded).await?;
let location = match get(&item, &field) {
Some(LinoValue::Null) | None => {
item_location(&base, &crate::value::string(identifier))
}
Some(value) => item_location(&base, value),
};
return Ok(created(item, &location));
}
store
.update(&identifier, &body)
.await?
.map(ok)
.ok_or_else(|| not_found(&name, &identifier))
}
},
);
}
if options.exposes("patch") {
let store = store.clone();
let name = name.clone();
let id_param = options.id_param.clone();
let require_precondition = options.require_precondition;
app.patch(
&item_path,
&format!("Merge changes into one {name}"),
move |request: LinoRequest| {
let store = store.clone();
let name = name.clone();
let id_param = id_param.clone();
async move {
let body = request.require_body()?.clone();
let identifier = request.param(&id_param).unwrap_or_default();
if load_for_write(&*store, &request, &identifier, require_precondition)
.await?
.is_none()
{
return Err(not_found(&name, &identifier));
}
store
.patch(&identifier, &body)
.await?
.map(ok)
.ok_or_else(|| not_found(&name, &identifier))
}
},
);
}
if options.exposes("remove") {
let store = store.clone();
let name = name.clone();
let id_param = options.id_param.clone();
let require_precondition = options.require_precondition;
app.delete(
&item_path,
&format!("Delete one {name}"),
move |request: LinoRequest| {
let store = store.clone();
let name = name.clone();
let id_param = id_param.clone();
async move {
let identifier = request.param(&id_param).unwrap_or_default();
if load_for_write(&*store, &request, &identifier, require_precondition)
.await?
.is_none()
{
return Err(not_found(&name, &identifier));
}
store.remove(&identifier).await?;
Ok(no_content())
}
},
);
}
}