From e5f24c4468ed98447fa065882afe5e1ec11d90ad Mon Sep 17 00:00:00 2001 From: Xiphoseer Date: Sat, 23 Oct 2021 10:57:32 +0200 Subject: [PATCH] More BoxedFilters & add openapi endpoint --- Cargo.toml | 7 ++ build.rs | 10 ++ res/api.html | 58 ++++++++++++ res/api.yaml | 225 ++++++++++++++++++++++++++++++++++++++++++++ src/api/docs.rs | 49 ++++++++++ src/api/mod.rs | 25 ++++- src/api/rev/mod.rs | 8 +- src/api/tables.rs | 8 +- src/auth.rs | 8 ++ src/config.rs | 1 + src/fallback.rs | 6 +- src/main.rs | 51 +++++++--- src/template/mod.rs | 9 +- 13 files changed, 436 insertions(+), 29 deletions(-) create mode 100644 build.rs create mode 100644 res/api.html create mode 100644 res/api.yaml create mode 100644 src/api/docs.rs diff --git a/Cargo.toml b/Cargo.toml index 9820f8b..27781a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,12 +10,15 @@ handlebars = "3.5" pretty_env_logger = "0.4.0" structopt = "0.3.21" color-eyre = "0.5.10" +#indexmap = "1" mapr = "0.8.0" notify = "5.0.0-pre.13" +openapiv3 = "0.5" paradox-typed-db = { git = "https://github.com/Xiphoseer/paradox-typed-db.git" } percent-encoding = "2.1.0" pin-project = "1.0" regex = "1.4" +serde_yaml = "0.8" toml = "0.5" tracing = "0.1" @@ -44,3 +47,7 @@ features = ["rt-multi-thread", "macros", "signal"] [dependencies.serde] version = "1" features = ["derive"] + +[build-dependencies] +openapiv3 = "0.5" +serde_yaml = "0.8" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..a14e2dd --- /dev/null +++ b/build.rs @@ -0,0 +1,10 @@ +use openapiv3::OpenAPI; +use serde_yaml::{from_str, Error}; + +fn main() { + let text = include_str!("res/api.yaml"); + let result: Result = from_str(text); + if let Err(e) = result { + panic!("{}", e); + } +} diff --git a/res/api.html b/res/api.html new file mode 100644 index 0000000..017fef2 --- /dev/null +++ b/res/api.html @@ -0,0 +1,58 @@ + + + + + API | LU-Explorer + + + + + + + +
+ + + + diff --git a/res/api.yaml b/res/api.yaml new file mode 100644 index 0000000..dfddbcd --- /dev/null +++ b/res/api.yaml @@ -0,0 +1,225 @@ +--- +openapi: 3.0.3 +info: + title: LU-Explorer API + version: "0.1" +tags: + - name: db + description: Queries on database tables + - name: locale + description: Queries the translations +components: + securitySchemes: + basic_auth: + type: http + scheme: basic + schemas: + Behavior: + type: object + properties: {} + Tables: + type: array + items: + type: string + TableDef: + type: object + properties: + name: + type: string + columns: + type: array + items: + type: object + properties: + name: + type: string + data_type: + type: string + LocaleNode: + type: object + properties: + value: + type: string + int_keys: + type: array + items: + type: number + str_keys: + type: array + items: + type: string + ErrorModel: + type: number +paths: + "/v0/tables": + get: + tags: + - db + description: List all database table names + responses: + "200": + description: The tables of the database + content: + application/json: + schema: + $ref: "#/components/schemas/Tables" + default: + description: Unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorModel" + "/v0/tables/{name}/def": + get: + tags: + - db + description: Show the definiton of a database table + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + $ref: "#/components/schemas/TableDef" + parameters: + - in: path + required: true + name: name + schema: + type: string + "/v0/tables/{name}/{key}": + get: + tags: + - db + description: Show data for a key in a table + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + type: array + items: + type: object + parameters: + - in: path + required: true + name: name + schema: + type: string + - in: path + required: true + name: key + schema: + type: string + "/v0/locale/{path}": + get: + tags: + - locale + description: Get a single locale node + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + $ref: "#/components/schemas/LocaleNode" + parameters: + - in: path + required: true + name: path + schema: + type: string + "/v0/locale/{path}/$all": + get: + tags: + - locale + description: Get a locale subtree + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + type: object + properties: {} + parameters: + - in: path + required: true + name: path + schema: + type: string + "/v0/rev": + get: + description: List all supported reverse lookup scopes + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + type: array + items: + type: string + "/v0/rev/component_types": + get: + description: List all component types in the database + responses: + "200": + description: The request was successfull + content: + application/json: + schema: { type: array, items: { type: string } } + "/v0/rev/component_types/{type}": + get: + description: List all component IDs and associated objects for a component type + This is a reverse lookup of the `ComponentsRegistry` table + responses: + "200": + description: The request was successfull + content: + application/json: + schema: { type: array, items: { type: string } } + parameters: + - in: path + required: true + name: type + schema: + type: number + "/v0/rev/component_types/{type}/{id}": + get: + description: List all component IDs and associated objects for a component type + This is a reverse lookup of the `ComponentsRegistry` table + responses: + "200": + description: The request was successfull + content: + application/json: + schema: { type: array, items: { type: string } } + parameters: + - in: path + required: true + name: type + schema: + type: number + - in: path + required: true + name: id + schema: + type: number + "/v0/rev/behaviors/{id}": + get: + description: Get all data for a specific behavior ID + responses: + "200": + description: The request was successfull + content: + application/json: + schema: + $ref: "#/components/schemas/Behavior" + parameters: + - in: path + required: true + name: id + schema: + type: number diff --git a/src/api/docs.rs b/src/api/docs.rs new file mode 100644 index 0000000..78d0e13 --- /dev/null +++ b/src/api/docs.rs @@ -0,0 +1,49 @@ +use openapiv3::{OpenAPI, SecurityRequirement, Server}; +use std::{convert::Infallible, future::Future, sync::Arc, task::Poll}; +use warp::{ + filters::BoxedFilter, + hyper::StatusCode, + reply::{json, with_status, Json, WithStatus}, + Filter, +}; + +use crate::auth::AuthKind; + +pub struct OpenApiFuture { + /// The openapi structure + inner: Arc, +} + +impl Future for OpenApiFuture { + type Output = Result, Infallible>; + + fn poll( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + Poll::Ready(Ok(with_status(json(self.inner.as_ref()), StatusCode::OK))) + } +} + +/// Build the openapi endpoint +pub fn openapi( + url: String, + auth_kind: AuthKind, +) -> Result,)>, serde_yaml::Error> { + let text = include_str!("../../res/api.yaml"); + let mut data: OpenAPI = serde_yaml::from_str(text)?; + data.servers.push(Server { + url, + description: Some(String::from("The current server")), + ..Default::default() + }); + if auth_kind == AuthKind::Basic { + let mut req = SecurityRequirement::new(); + req.insert("basic_auth".to_string(), vec![]); + data.security = Some(vec![req]); + } + let arc = Arc::new(data); + Ok(warp::path("openapi.json") + .and_then(move || OpenApiFuture { inner: arc.clone() }) + .boxed()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 48ea60c..7c1ddb6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,11 +10,14 @@ use assembly_data::{fdb::mem::Database, xml::localization::LocaleNode}; use paradox_typed_db::TypedDatabase; use percent_encoding::percent_decode_str; use warp::{ + filters::BoxedFilter, path::Tail, reply::{Json, WithStatus}, Filter, Reply, }; +use crate::auth::AuthKind; + use self::{ adapter::{LocaleAll, LocalePod}, rev::{make_api_rev, ReverseLookup}, @@ -22,6 +25,7 @@ use self::{ }; pub mod adapter; +mod docs; pub mod rev; pub mod tables; @@ -145,12 +149,14 @@ pub fn locale_api(lr: Arc) -> impl Fn(Tail) -> Option( - db: Database<'a>, +pub(crate) fn make_api( + url: String, + auth_kind: AuthKind, + db: Database<'static>, tydb: &'static TypedDatabase<'static>, rev: &'static ReverseLookup, lr: Arc, -) -> impl Filter,), Error = Infallible> + Clone + 'a { +) -> BoxedFilter<(WithStatus,)> { let v0_base = warp::path("v0"); let v0_tables = warp::path("tables").and(make_api_tables(db)); let v0_locale = warp::path("locale") @@ -159,7 +165,16 @@ pub(crate) fn make_api<'a>( .map(map_opt); let v0_rev = warp::path("rev").and(make_api_rev(tydb, rev)); - let v0 = v0_base.and(v0_tables.or(v0_locale).unify().or(v0_rev).unify()); + let v0_openapi = docs::openapi(url, auth_kind).unwrap(); + let v0 = v0_base.and( + v0_tables + .or(v0_locale) + .unify() + .or(v0_rev) + .unify() + .or(v0_openapi) + .unify(), + ); // v1 let dbf = db_filter(db); @@ -174,5 +189,5 @@ pub(crate) fn make_api<'a>( // catch all let catch_all = make_api_catch_all(); - v0.or(v1).unify().or(catch_all).unify() + v0.or(v1).unify().or(catch_all).unify().boxed() } diff --git a/src/api/rev/mod.rs b/src/api/rev/mod.rs index 0cd389b..8e850cb 100644 --- a/src/api/rev/mod.rs +++ b/src/api/rev/mod.rs @@ -35,7 +35,13 @@ pub struct Api { } fn rev_api(_db: &TypedDatabase, _rev: Rev) -> Result { - Ok(warp::reply::json(&["skill_ids"])) + Ok(warp::reply::json(&[ + "behaviors", + "component_types", + "mission_types", + "object_types", + "skill_ids", + ])) } #[derive(Debug, Copy, Clone)] diff --git a/src/api/tables.rs b/src/api/tables.rs index b9b9a79..2223fbb 100644 --- a/src/api/tables.rs +++ b/src/api/tables.rs @@ -9,8 +9,9 @@ use assembly_data::fdb::{ use linked_hash_map::LinkedHashMap; use serde::{ser::SerializeSeq, Serialize}; use warp::{ + filters::BoxedFilter, reply::{Json, WithStatus}, - Filter, Rejection, + Filter, }; use super::{db_filter, map_opt_res, map_res}; @@ -197,9 +198,7 @@ fn table_key_api(db: Database<'_>, name: String, key: String) -> Result, -) -> impl Filter,), Error = Rejection> + Clone + Send + '_ +pub(super) fn make_api_tables(db: Database<'static>) -> BoxedFilter<(WithStatus,)> //where //H: Filter,), Error = Infallible> + Clone + Send, { @@ -239,4 +238,5 @@ pub(super) fn make_api_tables( .unify() .or(table_get) .unify() + .boxed() } diff --git a/src/auth.rs b/src/auth.rs index 04516c0..d7c48b5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -12,6 +12,14 @@ use warp::{ use crate::config::AuthConfig; +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum AuthKind { + /// No authentication + None, + /// HTTP Basic Auth + Basic, +} + #[derive(Clone)] pub enum AuthImpl { None, diff --git a/src/config.rs b/src/config.rs index ede2f24..15318d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ fn default_lu_json_cache() -> PathBuf { #[derive(Deserialize)] pub struct CorsOptions { pub all: bool, + #[serde(default)] pub domains: Vec, } diff --git a/src/fallback.rs b/src/fallback.rs index a204b6a..28a3d8d 100644 --- a/src/fallback.rs +++ b/src/fallback.rs @@ -1,11 +1,9 @@ use std::path::PathBuf; use tracing::info; -use warp::{fs::File, Filter, Rejection}; +use warp::{filters::BoxedFilter, fs::File, Filter}; -pub fn make_fallback( - lu_json_path: PathBuf, -) -> impl Filter + Clone { +pub fn make_fallback(lu_json_path: PathBuf) -> BoxedFilter<(File,)> { let maps_dir = lu_json_path.join("maps"); info!("Maps on '{}'", maps_dir.display()); let maps = warp::path("maps").and(warp::fs::dir(maps_dir)); diff --git a/src/main.rs b/src/main.rs index a2e3330..f837757 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::{ fs::File, + str::FromStr, sync::{Arc, RwLock}, }; @@ -14,7 +15,7 @@ use paradox_typed_db::TypedDatabase; use structopt::StructOpt; use template::make_spa_dynamic; use tokio::runtime::Handle; -use warp::{filters::BoxedFilter, Filter}; +use warp::{filters::BoxedFilter, hyper::Uri, path::FullPath, Filter, Reply}; mod api; mod auth; @@ -26,6 +27,8 @@ mod template; use crate::{ api::rev::ReverseLookup, + auth::AuthKind, + config::AuthConfig, fallback::make_fallback, redirect::{add_host_filters, add_redirect_filters, base_filter}, template::{load_meta_template, FsEventHandler, TemplateUpdateTask}, @@ -71,13 +74,17 @@ async fn main() -> color_eyre::Result<()> { }; let cfg_g = &cfg.general; - let lu_res = cfg.data.lu_res_prefix.clone().unwrap_or_else(|| { - if let Some(b) = cfg_g.base.as_deref() { - format!("{}://{}/{}/lu-res", scheme, &cfg_g.domain, b) - } else { - format!("{}://{}/lu-res", scheme, &cfg_g.domain) - } - }); + let canonical_base_url = if let Some(b) = cfg_g.base.as_deref() { + format!("{}://{}/{}", scheme, &cfg_g.domain, b) + } else { + format!("{}://{}", scheme, &cfg_g.domain) + }; + + let lu_res = cfg + .data + .lu_res_prefix + .clone() + .unwrap_or_else(|| format!("{}/lu-res", canonical_base_url)); let lu_res_prefix = Box::leak(lu_res.clone().into_boxed_str()); let tydb = TypedDatabase::new(lr.clone(), lu_res_prefix, tables)?; @@ -88,8 +95,28 @@ async fn main() -> color_eyre::Result<()> { let lu_json_path = cfg.data.lu_json_cache.clone(); let fallback_routes = make_fallback(lu_json_path); - let api_routes = make_api(db, data, rev, lr.clone()); - let api = warp::path("api").and(fallback_routes.or(api_routes)); + let auth_kind = if matches!(cfg.auth, Some(AuthConfig { basic: Some(_) })) { + AuthKind::Basic + } else { + AuthKind::None + }; + let api_url = format!("{}/api/", canonical_base_url); + let api_uri = Uri::from_str(&api_url).unwrap(); + + let api_file = include_str!("../res/api.html"); + let api_swagger = warp::path::end() + .and(warp::path::full()) + .map(move |path: FullPath| { + if path.as_str().ends_with('/') { + warp::reply::html(api_file).into_response() + } else { + warp::redirect(api_uri.clone()).into_response() + } + }) + .boxed(); + + let api_routes = make_api(api_url, auth_kind, db, data, rev, lr.clone()); + let api = warp::path("api").and(fallback_routes.or(api_swagger).or(api_routes)); let spa_path = &cfg.data.explorer_spa; let spa_index = spa_path.join("index.html"); @@ -159,7 +186,9 @@ async fn main() -> color_eyre::Result<()> { cors = cors.allow_origin(key.as_ref()); } } - cors = cors.allow_methods(vec!["GET"]); + cors = cors + .allow_methods(vec!["OPTIONS", "GET"]) + .allow_headers(vec!["authorization"]); let to_serve = routes.with(cors); let server = warp::serve(to_serve); diff --git a/src/template/mod.rs b/src/template/mod.rs index 4cdea77..758c938 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -16,7 +16,7 @@ use regex::{Captures, Regex}; use serde::Serialize; use tokio::sync::mpsc::{Receiver, Sender}; use tracing::{debug, error, info}; -use warp::{path::FullPath, Filter}; +use warp::{filters::BoxedFilter, path::FullPath, Filter}; fn make_meta_template(text: &str) -> Cow { let re = Regex::new("").unwrap(); @@ -352,12 +352,12 @@ fn meta<'r>( } #[allow(clippy::needless_lifetimes)] // false positive? -pub(crate) fn make_spa_dynamic<'r>( +pub(crate) fn make_spa_dynamic( data: &'static TypedDatabase<'static>, - hb: Arc>>, + hb: Arc>>, domain: &str, // hnd: ArcHandle, -) -> impl Filter + Clone + 'r { +) -> BoxedFilter<(impl warp::Reply,)> { let dom = { let d = Box::leak(domain.to_string().into_boxed_str()) as &str; warp::any().map(move || d) @@ -393,4 +393,5 @@ pub(crate) fn make_spa_dynamic<'r>( }, ) .map(handlebars) + .boxed() }