//! JSON API for the Android app (and scripts). Everything except sign-in
//! sits behind `Authorization: Bearer <token>` (middleware::api_auth_middleware).
//!
//! Each endpoint returns the same data its web page renders, built by the
//! same function (`area_view`, `restaurant_view`, `panel_context`, …), and
//! goes through the same permission checks — members-only areas are 404s
//! here too. Errors are bare status codes.
//!
//! Tokens come from the Devices page's QR, or from `POST /api/auth/login`
//! with a username and password.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use chrono::{NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::error;
use uuid::Uuid;
use super::outings::Action;
use super::{areas, lunch, outings, restaurants};
use crate::auth::{self, api_tokens, LoginRequest};
use crate::entities::rankings::RankKind;
use crate::entities::users;
use crate::middleware::ApiUser;
use crate::AppState;
#[derive(Serialize)]
struct Me {
id: Uuid,
username: String,
email: String,
}
impl From<&users::Model> for Me {
fn from(u: &users::Model) -> Self {
Me { id: u.id, username: u.username.clone(), email: u.email.clone() }
}
}
type ApiResult = Result<Json<Value>, StatusCode>;
fn json_of(v: impl Serialize) -> ApiResult {
serde_json::to_value(v).map(Json).map_err(|e| {
error!(error = %e, "serialize api response failed");
StatusCode::INTERNAL_SERVER_ERROR
})
}
// ---------- sign-in ----------
#[derive(Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
/// Shown on the Devices page, e.g. "Pixel 8".
pub device: Option<String>,
}
/// The username/password flow: check the password, mint a token labelled
/// with the device, return it once. The QR flow mints the same kind of
/// token from the Devices page instead.
pub async fn login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> ApiResult {
let user = auth::authenticate_user(
&state.db,
LoginRequest { username: body.username.trim().to_string(), password: body.password },
)
.await
.map_err(|e| {
error!(error = %e, "api login failed");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
let label = body.device.map(|d| d.trim().chars().take(100).collect::<String>()).filter(|d| !d.is_empty());
let (_, token) = api_tokens::create(state.db.as_ref(), user.id, label.or(Some("Android app".into())), None)
.await
.map_err(|e| {
error!(error = %e, "mint api token failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
json_of(json!({ "token": token, "user": Me::from(&user) }))
}
/// Sign this device out: revoke the token it's using.
pub async fn logout(State(state): State<AppState>, api: ApiUser) -> Result<StatusCode, StatusCode> {
api_tokens::revoke_token(state.db.as_ref(), &api.token).await.map_err(|e| {
error!(error = %e, "revoke api token failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn profile(api: ApiUser) -> ApiResult {
json_of(json!({ "user": Me::from(&api.user) }))
}
// ---------- areas ----------
pub async fn list_areas(State(state): State<AppState>, api: ApiUser) -> ApiResult {
let rows = areas::area_rows(state.db.as_ref(), api.user.id).await?;
json_of(json!({ "areas": rows }))
}
pub async fn show_area(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
json_of(areas::area_view(state.db.as_ref(), id, api.user.id).await?)
}
/// "Who's going": you, friends and people you've eaten with here first.
pub async fn people(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
areas::authorize(state.db.as_ref(), id, api.user.id, crate::entities::area_members::Role::Voter).await?;
let (people, others) = lunch::picker(state.db.as_ref(), id, api.user.id).await?;
json_of(json!({ "people": people, "others": others }))
}
#[derive(Deserialize, Default)]
pub struct StartBody {
#[serde(default)]
pub attendees: Vec<Uuid>,
pub options: Option<i32>,
pub date: Option<NaiveDate>,
/// "12:30"
pub time: Option<String>,
}
pub async fn start_outing(
State(state): State<AppState>,
api: ApiUser,
Path(area_id): Path<Uuid>,
Json(body): Json<StartBody>,
) -> ApiResult {
let at = match body.time.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => Some(
NaiveTime::parse_from_str(t, "%H:%M")
.or_else(|_| NaiveTime::parse_from_str(t, "%H:%M:%S"))
.map_err(|_| StatusCode::BAD_REQUEST)?,
),
None => None,
};
let db = state.db.as_ref();
let id = outings::start(db, area_id, api.user.id, body.attendees, body.options, body.date, at).await?;
let outing = outings::load_outing(db, id, api.user.id).await?;
json_of(outings::panel_context(db, &outing, &api.user).await?)
}
// ---------- restaurants ----------
pub async fn show_restaurant(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
json_of(restaurants::restaurant_view(state.db.as_ref(), id, api.user.id).await?)
}
#[derive(Deserialize)]
pub struct RankBody {
pub rank: String,
/// Only for "obsessed": when it started.
pub since: Option<NaiveDate>,
}
pub async fn set_rank(
State(state): State<AppState>,
api: ApiUser,
Path(id): Path<Uuid>,
Json(body): Json<RankBody>,
) -> ApiResult {
let kind = RankKind::parse(&body.rank).ok_or(StatusCode::BAD_REQUEST)?;
let db = state.db.as_ref();
restaurants::load_restaurant(db, id, api.user.id, crate::entities::area_members::Role::Voter).await?;
let rank = restaurants::apply_rank(db, api.user.id, id, kind, body.since).await?;
json_of(json!({ "rank": rank, "since": rank.since() }))
}
#[derive(Deserialize, Default)]
pub struct VisitBody {
pub went_on: Option<NaiveDate>,
#[serde(default)]
pub attendees: Vec<Uuid>,
}
/// "I went": log a lunch here (today unless `went_on` says otherwise).
pub async fn log_visit(
State(state): State<AppState>,
api: ApiUser,
Path(id): Path<Uuid>,
Json(body): Json<VisitBody>,
) -> Result<StatusCode, StatusCode> {
lunch::log_visit(state.db.as_ref(), api.user.id, id, body.went_on, body.attendees).await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------- outings ----------
pub async fn show_outing(State(state): State<AppState>, api: ApiUser, Path(id): Path<Uuid>) -> ApiResult {
let db = state.db.as_ref();
let outing = outings::load_outing(db, id, api.user.id).await?;
json_of(outings::panel_context(db, &outing, &api.user).await?)
}
#[derive(Deserialize, Default)]
pub struct ActBody {
pub restaurant_id: Option<Uuid>,
}
/// `join`, `leave`, `vote`, `reroll`, `decide` or `cancel`; `vote` and
/// `decide` take a `restaurant_id`. Returns the outing as it now stands.
pub async fn act(
State(state): State<AppState>,
api: ApiUser,
Path((id, action)): Path<(Uuid, String)>,
body: Option<Json<ActBody>>,
) -> Result<impl IntoResponse, StatusCode> {
let option = || body.as_ref().and_then(|b| b.restaurant_id).ok_or(StatusCode::BAD_REQUEST);
let action = match action.as_str() {
"join" => Action::Join,
"leave" => Action::Leave,
"vote" => Action::Vote(option()?),
"reroll" => Action::Reroll,
"decide" => Action::Decide(option()?),
"cancel" => Action::Cancel,
_ => return Err(StatusCode::NOT_FOUND),
};
let db = state.db.as_ref();
outings::act(db, id, api.user.id, action).await?;
let outing = outings::load_outing(db, id, api.user.id).await?;
json_of(outings::panel_context(db, &outing, &api.user).await?)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
7.8 KiB · text
History
7a00c02