Add the yaak.run playground API (#681)

This commit is contained in:
Gregory Schier
2026-09-15 13:55:36 -07:00
committed by GitHub
parent ae518dfb4c
commit cef569ee15
17 changed files with 1746 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "yaak-playground"
version = "0.1.0"
edition = "2024"
publish = false
description = "A small, disposable API for trying Yaak against (yaak.run)"
[[bin]]
name = "yaak-playground"
path = "src/main.rs"
[dependencies]
axum = "0.7"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net"] }
tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
+21
View File
@@ -0,0 +1,21 @@
# yaak-playground
The API behind [yaak.run](https://yaak.run), for trying Yaak without bringing your own.
- Users and todos are fixed sample data.
- Posts can be created, changed, and deleted. Each client IP gets its own copy, which goes back
to the sample data `YAAK_PLAYGROUND_RESET_AFTER_SECS` after its first write.
- `POST /auth/token` returns a bearer token for `GET /auth/me`. `POST /auth/session` sets a
session cookie for `GET /auth/session`. Any username with the password `yaak` logs in.
- `GET /` lists every endpoint and `GET /openapi.json` describes them.
Nothing is written to disk, so a restart resets everything.
```sh
cargo run -p yaak-playground # http://127.0.0.1:9228
```
Run `yaak-playground --help` for the limits. Behind a load balancer that sets
`X-Forwarded-For`, set `YAAK_PLAYGROUND_TRUST_FORWARDED_FOR=true`.
The image is `Dockerfile.playground` at the repo root.
+185
View File
@@ -0,0 +1,185 @@
use crate::AppState;
use crate::data::{self, User};
use crate::error::{ApiError, ApiResult, parse_json};
use axum::extract::State;
use axum::extract::rejection::BytesRejection;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const PASSWORD: &str = "yaak";
const SESSION_COOKIE: &str = "session";
const TTL: Duration = Duration::from_secs(60 * 60);
const MAX_CREDENTIALS: usize = 10_000;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Bearer,
Session,
}
/// Issued tokens and session IDs, kept apart so a token can't be sent as a cookie or the
/// other way around.
pub struct Credentials {
entries: Mutex<HashMap<String, Entry>>,
}
struct Entry {
user_id: u64,
kind: Kind,
expires: Instant,
}
impl Credentials {
pub fn new() -> Self {
Self { entries: Mutex::new(HashMap::new()) }
}
fn issue(&self, user_id: u64, kind: Kind) -> String {
let value = uuid::Uuid::new_v4().simple().to_string();
let now = Instant::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() >= MAX_CREDENTIALS {
entries.retain(|_, e| e.expires > now);
}
if entries.len() >= MAX_CREDENTIALS
&& let Some(oldest) =
entries.iter().min_by_key(|(_, e)| e.expires).map(|(k, _)| k.clone())
{
entries.remove(&oldest);
}
entries.insert(value.clone(), Entry { user_id, kind, expires: now + TTL });
value
}
fn lookup(&self, value: &str, kind: Kind) -> Option<&'static User> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let entry = entries.get(value)?;
if entry.kind != kind || entry.expires <= Instant::now() {
return None;
}
data::user(entry.user_id)
}
fn revoke(&self, value: &str) {
self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(value);
}
}
#[derive(Deserialize)]
struct Login {
username: String,
password: String,
}
fn log_in(body: Result<Bytes, BytesRejection>) -> ApiResult<&'static User> {
let login: Login = parse_json(body)?;
data::user_by_username(&login.username)
.filter(|_| login.password == PASSWORD)
.ok_or_else(|| ApiError::unauthorized("Invalid username or password", None))
}
pub async fn create_token(
State(state): State<AppState>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Value>> {
let user = log_in(body)?;
let token = state.credentials.issue(user.id, Kind::Bearer);
Ok(Json(json!({
"accessToken": token,
"tokenType": "Bearer",
"expiresIn": TTL.as_secs(),
})))
}
pub async fn me(
State(state): State<AppState>,
headers: HeaderMap,
) -> ApiResult<Json<&'static User>> {
let Some(authorization) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok())
else {
return Err(ApiError::unauthorized("Missing Authorization header", Some("Bearer")));
};
let token = match authorization.split_once(' ') {
Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim(),
_ => {
return Err(ApiError::unauthorized(
"Authorization header must be \"Bearer <token>\"",
Some("Bearer"),
));
}
};
state.credentials.lookup(token, Kind::Bearer).map(Json).ok_or_else(|| {
ApiError::unauthorized("Invalid or expired token", Some("Bearer error=\"invalid_token\""))
})
}
pub async fn create_session(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Response> {
let user = log_in(body)?;
let id = state.credentials.issue(user.id, Kind::Session);
let cookie = format!(
"{SESSION_COOKIE}={id}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}",
TTL.as_secs(),
secure_attribute(&headers)
);
let mut res = Json(user).into_response();
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
Ok(res)
}
pub async fn session(
State(state): State<AppState>,
headers: HeaderMap,
) -> ApiResult<Json<&'static User>> {
let Some(id) = session_cookie(&headers) else {
return Err(ApiError::unauthorized("Missing session cookie", None));
};
state
.credentials
.lookup(id, Kind::Session)
.map(Json)
.ok_or_else(|| ApiError::unauthorized("Invalid or expired session", None))
}
pub async fn delete_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(id) = session_cookie(&headers) {
state.credentials.revoke(id);
}
let cookie = format!(
"{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}",
secure_attribute(&headers)
);
let mut res = StatusCode::NO_CONTENT.into_response();
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
res
}
fn session_cookie(headers: &HeaderMap) -> Option<&str> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(name, _)| *name == SESSION_COOKIE)
.map(|(_, value)| value)
}
/// `Secure` only when the request reached the proxy over HTTPS, so the cookie still works
/// against a local instance on plain HTTP.
fn secure_attribute(headers: &HeaderMap) -> &'static str {
let https = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("https"));
if https { "; Secure" } else { "" }
}
@@ -0,0 +1,59 @@
use clap::Parser;
use std::net::{SocketAddr, ToSocketAddrs};
/// A small, disposable API for trying Yaak against.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-playground", version, about, long_about = None)]
pub struct Config {
/// Interface to listen on. 0.0.0.0 inside a container.
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
pub host: String,
#[arg(long, env = "PORT", default_value_t = 9228)]
pub port: u16,
/// Requests allowed per client IP per minute. 0 disables the limit.
#[arg(
long,
env = "YAAK_PLAYGROUND_RATE_LIMIT_PER_MINUTE",
default_value_t = 300
)]
pub rate_limit_per_minute: u32,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only behind
/// a load balancer that sets the header.
#[arg(
long,
env = "YAAK_PLAYGROUND_TRUST_FORWARDED_FOR",
default_value_t = false
)]
pub trust_forwarded_for: bool,
/// Seconds after a client's first write before its posts go back to the sample data.
#[arg(long, env = "YAAK_PLAYGROUND_RESET_AFTER_SECS", default_value_t = 3600)]
pub reset_after_secs: u64,
/// Clients whose writes are kept at once. The oldest is reset early to make room.
#[arg(long, env = "YAAK_PLAYGROUND_MAX_CLIENTS", default_value_t = 2000)]
pub max_clients: usize,
#[arg(
long,
env = "YAAK_PLAYGROUND_MAX_POSTS_PER_CLIENT",
default_value_t = 100
)]
pub max_posts_per_client: usize,
#[arg(long, env = "YAAK_PLAYGROUND_MAX_REQUEST_BYTES", default_value_t = 64 * 1024)]
pub max_request_bytes: usize,
}
impl Config {
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
(self.host.as_str(), self.port)
.to_socket_addrs()
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
.next()
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: u64,
pub name: String,
pub username: String,
pub email: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Post {
pub id: u64,
pub user_id: u64,
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
pub id: u64,
pub user_id: u64,
pub title: String,
pub completed: bool,
}
#[derive(Deserialize)]
struct Seed {
users: Vec<User>,
posts: Vec<Post>,
todos: Vec<Todo>,
}
static SEED: LazyLock<Seed> =
LazyLock::new(|| serde_json::from_str(include_str!("seed.json")).expect("valid seed.json"));
pub fn users() -> &'static [User] {
&SEED.users
}
pub fn posts() -> &'static [Post] {
&SEED.posts
}
pub fn todos() -> &'static [Todo] {
&SEED.todos
}
pub fn user(id: u64) -> Option<&'static User> {
users().iter().find(|u| u.id == id)
}
pub fn user_by_username(username: &str) -> Option<&'static User> {
users().iter().find(|u| u.username == username)
}
@@ -0,0 +1,57 @@
use axum::extract::rejection::BytesRejection;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::de::DeserializeOwned;
use serde_json::json;
pub type ApiResult<T> = Result<T, ApiError>;
pub struct ApiError {
status: StatusCode,
message: String,
challenge: Option<&'static str>,
}
impl ApiError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into(), challenge: None }
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
/// `challenge` becomes the `WWW-Authenticate` header.
pub fn unauthorized(message: impl Into<String>, challenge: Option<&'static str>) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: message.into(), challenge }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut res = (self.status, Json(json!({ "error": self.message }))).into_response();
if let Some(challenge) = self.challenge {
res.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static(challenge));
}
res
}
}
pub fn parse_id(raw: &str, noun: &str) -> ApiResult<u64> {
raw.parse().map_err(|_| ApiError::not_found(format!("No {noun} with ID {raw}")))
}
/// Taken as a `Result` so an oversized body gets a JSON error like everything else.
pub fn parse_json<T: DeserializeOwned>(body: Result<Bytes, BytesRejection>) -> ApiResult<T> {
let body = body.map_err(|e| ApiError::new(e.status(), e.body_text()))?;
if body.is_empty() {
return Err(ApiError::bad_request("Request body must be JSON"));
}
serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("Request body is not valid: {e}")))
}
+170
View File
@@ -0,0 +1,170 @@
//! yaak-playground: the API behind yaak.run, for trying Yaak without bringing your own.
//!
//! Reads come from fixed sample data. Writes are kept per client IP and reset after a while,
//! so nothing one caller sends is ever served to another. Nothing touches disk.
mod auth;
mod config;
mod data;
mod error;
mod limits;
mod resources;
mod store;
use axum::Router;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
pub use config::Config;
use error::ApiError;
use limits::RateLimiter;
use serde_json::{Value, json};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use store::PostStore;
use tower_http::cors::{AllowOrigin, CorsLayer};
const OPENAPI: &str = include_str!("openapi.json");
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
posts: Arc<PostStore>,
credentials: Arc<auth::Credentials>,
}
#[derive(Clone, Copy)]
struct ClientIp(IpAddr);
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()`; the client's address
/// keys both the rate limit and its private copy of the posts.
pub fn router(config: Config) -> Router {
let state = AppState {
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
posts: Arc::new(PostStore::new(
Duration::from_secs(config.reset_after_secs),
config.max_clients,
config.max_posts_per_client,
)),
credentials: Arc::new(auth::Credentials::new()),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::any())
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.expose_headers([
header::LOCATION,
header::RETRY_AFTER,
header::WWW_AUTHENTICATE,
]);
Router::new()
.route("/", get(index))
.route("/health", get(health))
.route("/openapi.json", get(openapi))
.route("/users", get(resources::list_users))
.route("/users/:id", get(resources::get_user))
.route("/users/:id/todos", get(resources::list_user_todos))
.route("/todos", get(resources::list_todos))
.route("/posts", get(resources::list_posts).post(resources::create_post))
.route(
"/posts/:id",
get(resources::get_post)
.put(resources::replace_post)
.patch(resources::update_post)
.delete(resources::delete_post),
)
.route("/auth/token", post(auth::create_token))
.route("/auth/me", get(auth::me))
.route(
"/auth/session",
post(auth::create_session).get(auth::session).delete(auth::delete_session),
)
.fallback(not_found)
.layer(middleware::from_fn_with_state(state.clone(), identify_client))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state)
}
async fn identify_client(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
mut req: Request,
next: Next,
) -> Response {
let ip = client_ip(&state.config, req.headers(), peer);
if let Err(wait) = state.rate_limiter.check(ip) {
let secs = wait.as_secs().max(1);
let mut res = ApiError::new(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached. Try again in {secs}s"),
)
.into_response();
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(secs));
return res;
}
req.extensions_mut().insert(ClientIp(ip));
next.run(req).await
}
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn index(State(state): State<AppState>) -> Json<Value> {
let spec: Value = serde_json::from_str(OPENAPI).expect("valid openapi.json");
Json(json!({
"name": "Yaak Playground",
"description": "A small API for trying out Yaak. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.",
"resetAfterSeconds": state.config.reset_after_secs,
"login": { "username": "ada", "password": auth::PASSWORD },
"openapi": "/openapi.json",
"endpoints": operations(&spec),
}))
}
/// Every `METHOD /path` the spec documents.
fn operations(spec: &Value) -> Vec<String> {
let mut ops = Vec::new();
for (path, item) in spec["paths"].as_object().into_iter().flatten() {
for method in ["get", "post", "put", "patch", "delete"] {
if item.get(method).is_some() {
ops.push(format!("{} {path}", method.to_uppercase()));
}
}
}
ops
}
async fn health() -> Json<Value> {
Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") }))
}
async fn openapi() -> Response {
([(header::CONTENT_TYPE, "application/json")], OPENAPI).into_response()
}
async fn not_found() -> ApiError {
ApiError::not_found("Not found. GET / lists every endpoint")
}
@@ -0,0 +1,50 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// One token bucket per client IP, refilled continuously.
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
+29
View File
@@ -0,0 +1,29 @@
use clap::Parser;
use log::info;
use std::net::SocketAddr;
use yaak_playground::{Config, router};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let bind = config.listen_addr().unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1);
});
let app = router(config);
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!("yaak-playground listening on http://{bind}");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}
@@ -0,0 +1,396 @@
{
"openapi": "3.1.0",
"info": {
"title": "Yaak Playground",
"version": "1.0.0",
"description": "A small API for trying out Yaak. Users and todos are fixed sample data. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.\n\nLog in with any username (like `ada`) and the password `yaak`."
},
"servers": [{ "url": "https://yaak.run" }],
"tags": [
{ "name": "Users" },
{ "name": "Posts" },
{ "name": "Todos" },
{ "name": "Auth", "description": "Two ways to stay logged in: a bearer token you send yourself, or a session cookie the cookie jar sends for you." }
],
"paths": {
"/users": {
"get": {
"tags": ["Users"],
"operationId": "listUsers",
"summary": "List users",
"responses": {
"200": {
"description": "Every user",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }
}
}
}
}
}
},
"/users/{id}": {
"get": {
"tags": ["Users"],
"operationId": "getUser",
"summary": "Get a user",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"200": {
"description": "The user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/users/{id}/todos": {
"get": {
"tags": ["Users", "Todos"],
"operationId": "listUserTodos",
"summary": "List a user's todos",
"parameters": [
{ "$ref": "#/components/parameters/Id" },
{ "$ref": "#/components/parameters/Completed" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "The user's todos",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/todos": {
"get": {
"tags": ["Todos"],
"operationId": "listTodos",
"summary": "List todos",
"parameters": [
{ "$ref": "#/components/parameters/UserId" },
{ "$ref": "#/components/parameters/Completed" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "Matching todos",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" }
}
}
},
"/posts": {
"get": {
"tags": ["Posts"],
"operationId": "listPosts",
"summary": "List posts",
"parameters": [
{ "$ref": "#/components/parameters/UserId" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "Matching posts",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" }
}
},
"post": {
"tags": ["Posts"],
"operationId": "createPost",
"summary": "Create a post",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
},
"responses": {
"201": {
"description": "The new post",
"headers": {
"Location": { "description": "Where to fetch the new post", "schema": { "type": "string" } }
},
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"409": {
"description": "You already have the most posts allowed",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
}
}
},
"/posts/{id}": {
"get": {
"tags": ["Posts"],
"operationId": "getPost",
"summary": "Get a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"200": {
"description": "The post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"put": {
"tags": ["Posts"],
"operationId": "replacePost",
"summary": "Replace a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
},
"responses": {
"200": {
"description": "The updated post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"patch": {
"tags": ["Posts"],
"operationId": "updatePost",
"summary": "Update part of a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostPatch" } } }
},
"responses": {
"200": {
"description": "The updated post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"delete": {
"tags": ["Posts"],
"operationId": "deletePost",
"summary": "Delete a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"204": { "description": "Deleted" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/auth/token": {
"post": {
"tags": ["Auth"],
"operationId": "createToken",
"summary": "Log in for a bearer token",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
},
"responses": {
"200": {
"description": "A token to send as `Authorization: Bearer <token>`",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Token" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"operationId": "getMe",
"summary": "Get the user a bearer token belongs to",
"security": [{ "bearerAuth": [] }],
"responses": {
"200": {
"description": "The logged-in user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/auth/session": {
"post": {
"tags": ["Auth"],
"operationId": "createSession",
"summary": "Log in with a session cookie",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
},
"responses": {
"200": {
"description": "The logged-in user. The session cookie comes back in `Set-Cookie`.",
"headers": {
"Set-Cookie": { "description": "The `session` cookie", "schema": { "type": "string" } }
},
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"get": {
"tags": ["Auth"],
"operationId": "getSession",
"summary": "Get the user a session cookie belongs to",
"security": [{ "cookieAuth": [] }],
"responses": {
"200": {
"description": "The logged-in user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"delete": {
"tags": ["Auth"],
"operationId": "deleteSession",
"summary": "Log out",
"responses": {
"204": { "description": "Logged out, and the cookie is cleared" }
}
}
}
},
"components": {
"parameters": {
"Id": {
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "integer", "minimum": 1 },
"example": 1
},
"UserId": {
"name": "userId",
"in": "query",
"description": "Only return items belonging to this user",
"schema": { "type": "integer", "minimum": 1 }
},
"Completed": {
"name": "completed",
"in": "query",
"schema": { "type": "boolean" }
},
"Limit": {
"name": "limit",
"in": "query",
"description": "Return at most this many items",
"schema": { "type": "integer", "minimum": 0 }
}
},
"responses": {
"BadRequest": {
"description": "Something in the request is invalid",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"NotFound": {
"description": "Nothing has that ID",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"Unauthorized": {
"description": "Missing or invalid credentials",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
},
"schemas": {
"User": {
"type": "object",
"required": ["id", "name", "username", "email"],
"properties": {
"id": { "type": "integer", "example": 1 },
"name": { "type": "string", "example": "Ada Lovelace" },
"username": { "type": "string", "example": "ada" },
"email": { "type": "string", "format": "email", "example": "ada@example.com" }
}
},
"Post": {
"type": "object",
"required": ["id", "userId", "title", "body"],
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string" },
"body": { "type": "string" }
}
},
"PostInput": {
"type": "object",
"required": ["userId", "title", "body"],
"properties": {
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string", "maxLength": 200, "example": "Hello from Yaak" },
"body": { "type": "string", "maxLength": 2000, "example": "My first post" }
}
},
"PostPatch": {
"type": "object",
"properties": {
"userId": { "type": "integer" },
"title": { "type": "string", "maxLength": 200 },
"body": { "type": "string", "maxLength": 2000 }
}
},
"Todo": {
"type": "object",
"required": ["id", "userId", "title", "completed"],
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string" },
"completed": { "type": "boolean" }
}
},
"Login": {
"type": "object",
"required": ["username", "password"],
"properties": {
"username": { "type": "string", "example": "ada" },
"password": { "type": "string", "example": "yaak" }
}
},
"Token": {
"type": "object",
"required": ["accessToken", "tokenType", "expiresIn"],
"properties": {
"accessToken": { "type": "string" },
"tokenType": { "type": "string", "const": "Bearer" },
"expiresIn": { "type": "integer", "description": "Seconds until the token expires", "example": 3600 }
}
},
"Error": {
"type": "object",
"required": ["error"],
"properties": { "error": { "type": "string" } }
}
},
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer" },
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "session" }
}
}
}
@@ -0,0 +1,229 @@
use crate::data::{self, Post, Todo, User};
use crate::error::{ApiError, ApiResult, parse_id, parse_json};
use crate::{AppState, ClientIp};
use axum::extract::rejection::BytesRejection;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::Deserialize;
use std::collections::HashMap;
const MAX_TITLE_CHARS: usize = 200;
const MAX_BODY_CHARS: usize = 2000;
type Params = Query<HashMap<String, String>>;
struct Filters {
user_id: Option<u64>,
completed: Option<bool>,
limit: Option<usize>,
}
fn filters(params: &HashMap<String, String>) -> ApiResult<Filters> {
fn parse<T: std::str::FromStr>(
params: &HashMap<String, String>,
name: &str,
expected: &str,
) -> ApiResult<Option<T>> {
params
.get(name)
.map(|v| {
v.parse().map_err(|_| {
ApiError::bad_request(format!("Query parameter `{name}` must be {expected}"))
})
})
.transpose()
}
Ok(Filters {
user_id: parse(params, "userId", "a user ID")?,
completed: parse(params, "completed", "true or false")?,
limit: parse(params, "limit", "a number")?,
})
}
pub async fn list_users() -> Json<&'static [User]> {
Json(data::users())
}
pub async fn get_user(Path(id): Path<String>) -> ApiResult<Json<&'static User>> {
let user_id = parse_id(&id, "user")?;
data::user(user_id)
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("No user with ID {id}")))
}
pub async fn list_todos(Query(params): Params) -> ApiResult<Json<Vec<&'static Todo>>> {
let f = filters(&params)?;
Ok(Json(select_todos(f.user_id, f.completed, f.limit)))
}
pub async fn list_user_todos(
Path(id): Path<String>,
Query(params): Params,
) -> ApiResult<Json<Vec<&'static Todo>>> {
let user = get_user(Path(id)).await?;
let f = filters(&params)?;
Ok(Json(select_todos(Some(user.id), f.completed, f.limit)))
}
fn select_todos(
user_id: Option<u64>,
completed: Option<bool>,
limit: Option<usize>,
) -> Vec<&'static Todo> {
data::todos()
.iter()
.filter(|t| user_id.is_none_or(|id| t.user_id == id))
.filter(|t| completed.is_none_or(|c| t.completed == c))
.take(limit.unwrap_or(usize::MAX))
.collect()
}
pub async fn list_posts(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Query(params): Params,
) -> ApiResult<Json<Vec<Post>>> {
let f = filters(&params)?;
let posts = state.posts.read(ip, |posts| {
posts
.iter()
.filter(|p| f.user_id.is_none_or(|id| p.user_id == id))
.take(f.limit.unwrap_or(usize::MAX))
.cloned()
.collect()
});
Ok(Json(posts))
}
pub async fn get_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
state
.posts
.read(ip, |posts| posts.iter().find(|p| p.id == post_id).cloned())
.map(Json)
.ok_or_else(|| post_not_found(&id))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NewPost {
user_id: u64,
title: String,
body: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PostPatch {
user_id: Option<u64>,
title: Option<String>,
body: Option<String>,
}
pub async fn create_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Response> {
let input: NewPost = parse_json(body)?;
validate(input.user_id, &input.title, &input.body)?;
let max = state.posts.max_posts;
let post = state.posts.write(ip, |client| {
if client.posts.len() >= max {
return Err(ApiError::new(
StatusCode::CONFLICT,
format!(
"You can have at most {max} posts. Delete one first, or wait for the reset"
),
));
}
Ok(client.insert(input.user_id, input.title, input.body))
})?;
let location = HeaderValue::from_str(&format!("/posts/{}", post.id)).expect("ascii");
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(post)).into_response())
}
pub async fn replace_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
let input: NewPost = parse_json(body)?;
validate(input.user_id, &input.title, &input.body)?;
state.posts.write(ip, |client| {
let post =
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
post.user_id = input.user_id;
post.title = input.title;
post.body = input.body;
Ok(Json(post.clone()))
})
}
pub async fn update_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
let patch: PostPatch = parse_json(body)?;
state.posts.write(ip, |client| {
let post =
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
let user_id = patch.user_id.unwrap_or(post.user_id);
let title = patch.title.unwrap_or_else(|| post.title.clone());
let body = patch.body.unwrap_or_else(|| post.body.clone());
validate(user_id, &title, &body)?;
post.user_id = user_id;
post.title = title;
post.body = body;
Ok(Json(post.clone()))
})
}
pub async fn delete_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
let post_id = parse_id(&id, "post")?;
state.posts.write(ip, |client| {
let index =
client.posts.iter().position(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
client.posts.remove(index);
Ok(StatusCode::NO_CONTENT)
})
}
fn post_not_found(id: &str) -> ApiError {
ApiError::not_found(format!("No post with ID {id}"))
}
fn validate(user_id: u64, title: &str, body: &str) -> ApiResult<()> {
if data::user(user_id).is_none() {
return Err(ApiError::bad_request(format!("No user with ID {user_id}")));
}
if title.trim().is_empty() {
return Err(ApiError::bad_request("`title` can't be empty"));
}
if title.chars().count() > MAX_TITLE_CHARS {
return Err(ApiError::bad_request(format!(
"`title` must be {MAX_TITLE_CHARS} characters or fewer"
)));
}
if body.chars().count() > MAX_BODY_CHARS {
return Err(ApiError::bad_request(format!(
"`body` must be {MAX_BODY_CHARS} characters or fewer"
)));
}
Ok(())
}
@@ -0,0 +1,88 @@
{
"users": [
{ "id": 1, "name": "Ada Lovelace", "username": "ada", "email": "ada@example.com" },
{ "id": 2, "name": "Grace Hopper", "username": "grace", "email": "grace@example.com" },
{ "id": 3, "name": "Alan Turing", "username": "alan", "email": "alan@example.com" },
{ "id": 4, "name": "Katherine Johnson", "username": "katherine", "email": "katherine@example.com" },
{ "id": 5, "name": "Margaret Hamilton", "username": "margaret", "email": "margaret@example.com" }
],
"posts": [
{
"id": 1,
"userId": 1,
"title": "Notes on the Analytical Engine",
"body": "Requests are just messages. The trick is knowing exactly what you sent."
},
{
"id": 2,
"userId": 1,
"title": "Why I keep my variables in environments",
"body": "Switching from staging to production should be one click, not a find and replace."
},
{
"id": 3,
"userId": 2,
"title": "Easier to ask forgiveness than permission",
"body": "Unless the API returns a 403. Then it's time to check your token."
},
{
"id": 4,
"userId": 2,
"title": "Debugging the first bug",
"body": "The Timeline tab shows every redirect, header, and byte. No moths required."
},
{
"id": 5,
"userId": 3,
"title": "Can machines send requests?",
"body": "This one just did. Look through the response headers to see what came back."
},
{
"id": 6,
"userId": 3,
"title": "On computable status codes",
"body": "2xx is good news, 4xx usually means the request needs fixing, and 5xx usually means the server does."
},
{
"id": 7,
"userId": 4,
"title": "Checking the math on pagination",
"body": "Add a limit query parameter to get fewer results back."
},
{
"id": 8,
"userId": 4,
"title": "Plotting a course between requests",
"body": "Pull a value out of one response and send it in the next. That's all chaining is."
},
{
"id": 9,
"userId": 5,
"title": "Error handling saved the landing",
"body": "An API that returns clear errors is an API you can build on."
},
{
"id": 10,
"userId": 5,
"title": "Logging in, then everything else",
"body": "Get a token from one request and use it for the rest. Set it once on a folder and every request inside inherits it."
}
],
"todos": [
{ "id": 1, "userId": 1, "title": "Send your first request", "completed": true },
{ "id": 2, "userId": 1, "title": "Create an environment", "completed": false },
{ "id": 3, "userId": 1, "title": "Switch to a sub-environment", "completed": false },
{ "id": 4, "userId": 2, "title": "Import an OpenAPI spec", "completed": true },
{ "id": 5, "userId": 2, "title": "Add a header to a folder", "completed": false },
{ "id": 6, "userId": 2, "title": "Read the response timeline", "completed": true },
{ "id": 7, "userId": 3, "title": "Chain a value from another response", "completed": false },
{ "id": 8, "userId": 3, "title": "Log in and use the token", "completed": false },
{ "id": 9, "userId": 3, "title": "Copy a request as curl", "completed": true },
{ "id": 10, "userId": 4, "title": "Sync a workspace to a folder", "completed": false },
{ "id": 11, "userId": 4, "title": "Commit the workspace to Git", "completed": false },
{ "id": 12, "userId": 4, "title": "Encrypt a secret", "completed": false },
{ "id": 13, "userId": 5, "title": "Create a post", "completed": false },
{ "id": 14, "userId": 5, "title": "Delete the post you created", "completed": false },
{ "id": 15, "userId": 5, "title": "Delete the example workspace", "completed": false }
]
}
@@ -0,0 +1,83 @@
use crate::data::{self, Post};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
/// Posts as each client sees them. A client that has never written reads the sample data; its
/// first write takes a private copy, which is thrown away `reset_after` later. Nothing one
/// client writes is ever served to another.
pub struct PostStore {
clients: Mutex<HashMap<IpAddr, ClientPosts>>,
reset_after: Duration,
max_clients: usize,
pub max_posts: usize,
}
pub struct ClientPosts {
pub posts: Vec<Post>,
next_id: u64,
created: Instant,
}
impl ClientPosts {
pub fn insert(&mut self, user_id: u64, title: String, body: String) -> Post {
let post = Post { id: self.next_id, user_id, title, body };
self.next_id += 1;
self.posts.push(post.clone());
post
}
}
impl PostStore {
pub fn new(reset_after: Duration, max_clients: usize, max_posts: usize) -> Self {
Self { clients: Mutex::new(HashMap::new()), reset_after, max_clients, max_posts }
}
pub fn read<R>(&self, client: IpAddr, f: impl FnOnce(&[Post]) -> R) -> R {
let mut clients = self.lock();
self.drop_if_expired(&mut clients, client, Instant::now());
match clients.get(&client) {
Some(c) => f(&c.posts),
None => f(data::posts()),
}
}
pub fn write<R>(&self, client: IpAddr, f: impl FnOnce(&mut ClientPosts) -> R) -> R {
let mut clients = self.lock();
let now = Instant::now();
self.drop_if_expired(&mut clients, client, now);
if !clients.contains_key(&client) {
if clients.len() >= self.max_clients {
clients.retain(|_, c| now.duration_since(c.created) < self.reset_after);
}
if clients.len() >= self.max_clients
&& let Some(oldest) =
clients.iter().min_by_key(|(_, c)| c.created).map(|(ip, _)| *ip)
{
clients.remove(&oldest);
}
let posts = data::posts().to_vec();
let next_id = posts.iter().map(|p| p.id).max().unwrap_or(0) + 1;
clients.insert(client, ClientPosts { posts, next_id, created: now });
}
f(clients.get_mut(&client).expect("inserted above"))
}
fn lock(&self) -> MutexGuard<'_, HashMap<IpAddr, ClientPosts>> {
self.clients.lock().unwrap_or_else(|e| e.into_inner())
}
fn drop_if_expired(
&self,
clients: &mut HashMap<IpAddr, ClientPosts>,
client: IpAddr,
now: Instant,
) {
if clients.get(&client).is_some_and(|c| now.duration_since(c.created) >= self.reset_after) {
clients.remove(&client);
}
}
}
+262
View File
@@ -0,0 +1,262 @@
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::extract::ConnectInfo;
use axum::http::{HeaderMap, Request, StatusCode, header};
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::net::SocketAddr;
use tower::ServiceExt;
use yaak_playground::{Config, router};
const ALICE: [u8; 4] = [203, 0, 113, 1];
const BOB: [u8; 4] = [203, 0, 113, 2];
fn config() -> Config {
Config {
host: "127.0.0.1".to_string(),
port: 0,
rate_limit_per_minute: 1000,
trust_forwarded_for: false,
reset_after_secs: 3600,
max_clients: 10,
max_posts_per_client: 20,
max_request_bytes: 4096,
}
}
struct Res {
status: StatusCode,
headers: HeaderMap,
json: Value,
}
async fn call(
app: &Router,
client: [u8; 4],
method: &str,
uri: &str,
headers: &[(&str, &str)],
body: Option<Value>,
) -> Res {
let mut req = Request::builder().method(method).uri(uri);
for (name, value) in headers {
req = req.header(*name, *value);
}
let body = match body {
Some(v) => Body::from(v.to_string()),
None => Body::empty(),
};
let mut req = req.body(body).unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::from((client, 40000))));
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status();
let headers = res.headers().clone();
let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
Res { status, headers, json }
}
#[tokio::test]
async fn users_and_todos() {
let app = router(config());
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
assert_eq!(res.status, StatusCode::OK);
assert_eq!(res.json[0]["username"], "ada");
let res = call(&app, ALICE, "GET", "/users/2", &[], None).await;
assert_eq!(res.json["name"], "Grace Hopper");
for uri in ["/users/99", "/users/abc", "/users/99/todos"] {
let res = call(&app, ALICE, "GET", uri, &[], None).await;
assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
assert!(res.json["error"].is_string());
}
let res = call(&app, ALICE, "GET", "/users/1/todos?completed=false&limit=1", &[], None).await;
assert_eq!(res.json.as_array().unwrap().len(), 1);
assert_eq!(res.json[0]["userId"], 1);
assert_eq!(res.json[0]["completed"], false);
let res = call(&app, ALICE, "GET", "/todos?userId=3", &[], None).await;
assert!(res.json.as_array().unwrap().iter().all(|t| t["userId"] == 3));
let res = call(&app, ALICE, "GET", "/todos?completed=maybe", &[], None).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn writes_are_private_to_the_client() {
let app = router(config());
let new_post = json!({ "userId": 1, "title": "Hello", "body": "From Yaak" });
let res = call(&app, ALICE, "POST", "/posts", &[], Some(new_post)).await;
assert_eq!(res.status, StatusCode::CREATED);
let id = res.json["id"].as_u64().unwrap();
assert_eq!(res.headers[header::LOCATION], format!("/posts/{id}"));
let uri = format!("/posts/{id}");
assert_eq!(call(&app, ALICE, "GET", &uri, &[], None).await.status, StatusCode::OK);
assert_eq!(call(&app, BOB, "GET", &uri, &[], None).await.status, StatusCode::NOT_FOUND);
let res = call(&app, ALICE, "PATCH", &uri, &[], Some(json!({ "title": "Edited" }))).await;
assert_eq!(res.json["title"], "Edited");
assert_eq!(res.json["body"], "From Yaak");
let replacement = json!({ "userId": 2, "title": "Replaced", "body": "" });
let res = call(&app, ALICE, "PUT", &uri, &[], Some(replacement)).await;
assert_eq!(res.json["userId"], 2);
let res = call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
assert_eq!(res.status, StatusCode::NO_CONTENT);
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::NOT_FOUND);
assert_eq!(call(&app, BOB, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
let res = call(&app, ALICE, "GET", "/posts?userId=2", &[], None).await;
assert!(res.json.as_array().unwrap().iter().any(|p| p["title"] == "Replaced"));
}
#[tokio::test]
async fn invalid_posts_are_rejected() {
let app = router(config());
let cases = [
json!({ "title": "No user", "body": "" }),
json!({ "userId": 99, "title": "Unknown user", "body": "" }),
json!({ "userId": 1, "title": " ", "body": "" }),
json!({ "userId": 1, "title": "x".repeat(201), "body": "" }),
];
for body in cases {
let res = call(&app, ALICE, "POST", "/posts", &[], Some(body.clone())).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST, "{body}");
assert!(res.json["error"].is_string());
}
let res = call(&app, ALICE, "POST", "/posts", &[], None).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST);
let huge = json!({ "userId": 1, "title": "Big", "body": "x".repeat(5000) });
let res = call(&app, ALICE, "POST", "/posts", &[], Some(huge)).await;
assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE);
assert!(res.json["error"].is_string());
}
#[tokio::test]
async fn post_cap_and_reset() {
let app = router(Config { max_posts_per_client: 11, ..config() });
let post = json!({ "userId": 1, "title": "One more", "body": "" });
assert_eq!(
call(&app, ALICE, "POST", "/posts", &[], Some(post.clone())).await.status,
StatusCode::CREATED
);
assert_eq!(
call(&app, ALICE, "POST", "/posts", &[], Some(post)).await.status,
StatusCode::CONFLICT
);
let app = router(Config { reset_after_secs: 0, ..config() });
call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
}
#[tokio::test]
async fn bearer_token_flow() {
let app = router(config());
let res = call(&app, ALICE, "GET", "/auth/me", &[], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
assert_eq!(res.headers[header::WWW_AUTHENTICATE], "Bearer");
let bad = json!({ "username": "ada", "password": "nope" });
assert_eq!(
call(&app, ALICE, "POST", "/auth/token", &[], Some(bad)).await.status,
StatusCode::UNAUTHORIZED
);
let good = json!({ "username": "grace", "password": "yaak" });
let res = call(&app, ALICE, "POST", "/auth/token", &[], Some(good)).await;
assert_eq!(res.status, StatusCode::OK);
let token = res.json["accessToken"].as_str().unwrap().to_string();
let auth = format!("Bearer {token}");
let res = call(&app, BOB, "GET", "/auth/me", &[("authorization", &auth)], None).await;
assert_eq!(res.json["username"], "grace");
let res =
call(&app, ALICE, "GET", "/auth/me", &[("authorization", "Bearer wrong")], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
let cookie = format!("session={token}");
let res = call(&app, ALICE, "GET", "/auth/session", &[("cookie", &cookie)], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn session_cookie_flow() {
let app = router(config());
let login = json!({ "username": "alan", "password": "yaak" });
let res =
call(&app, ALICE, "POST", "/auth/session", &[("x-forwarded-proto", "https")], Some(login))
.await;
assert_eq!(res.status, StatusCode::OK);
let set_cookie = res.headers[header::SET_COOKIE].to_str().unwrap();
assert!(set_cookie.contains("HttpOnly") && set_cookie.contains("; Secure"), "{set_cookie}");
let cookie = set_cookie.split(';').next().unwrap().to_string();
let headers = [("cookie", cookie.as_str())];
let res = call(&app, ALICE, "GET", "/auth/session", &headers, None).await;
assert_eq!(res.json["username"], "alan");
let id = cookie.trim_start_matches("session=");
let auth = format!("Bearer {id}");
let res = call(&app, ALICE, "GET", "/auth/me", &[("authorization", &auth)], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
let res = call(&app, ALICE, "DELETE", "/auth/session", &headers, None).await;
assert_eq!(res.status, StatusCode::NO_CONTENT);
assert!(res.headers[header::SET_COOKIE].to_str().unwrap().contains("Max-Age=0"));
assert_eq!(
call(&app, ALICE, "GET", "/auth/session", &headers, None).await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn rate_limit() {
let app = router(Config { rate_limit_per_minute: 2, ..config() });
call(&app, ALICE, "GET", "/users", &[], None).await;
call(&app, ALICE, "GET", "/users", &[], None).await;
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
assert_eq!(res.status, StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers.contains_key(header::RETRY_AFTER));
assert_eq!(call(&app, BOB, "GET", "/users", &[], None).await.status, StatusCode::OK);
}
#[tokio::test]
async fn openapi_matches_the_routes() {
let app = router(config());
let spec = call(&app, ALICE, "GET", "/openapi.json", &[], None).await.json;
let mut documented = BTreeSet::new();
for (path, item) in spec["paths"].as_object().unwrap() {
for (method, _) in item.as_object().unwrap() {
let method = method.to_uppercase();
let uri = path.replace("{id}", "1");
// A fresh client per call, so a DELETE can't hide the GET that follows it
let client = [198, 51, 100, documented.len() as u8];
let res = call(&app, client, &method, &uri, &[], None).await;
assert!(
res.status != StatusCode::NOT_FOUND && res.status != StatusCode::METHOD_NOT_ALLOWED,
"{method} {path} is documented but not routed ({})",
res.status
);
documented.insert(format!("{method} {path}"));
}
}
let index = call(&app, ALICE, "GET", "/", &[], None).await.json;
let listed: BTreeSet<String> = index["endpoints"]
.as_array()
.unwrap()
.iter()
.map(|e| e.as_str().unwrap().to_string())
.collect();
assert_eq!(listed, documented);
assert_eq!(call(&app, ALICE, "GET", "/nope", &[], None).await.status, StatusCode::NOT_FOUND);
}