feat: replace graphql playground to altair

This commit is contained in:
master 2025-01-12 03:46:28 +08:00
parent 97b7bfb7fb
commit c6677d414d
30 changed files with 1262 additions and 524 deletions

View File

@ -2,7 +2,7 @@ root = true
[*] [*]
indent_style = space indent_style = space
indent_size = 2 indent_size = 4
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true

7
Cargo.lock generated
View File

@ -3298,8 +3298,6 @@ dependencies = [
[[package]] [[package]]
name = "loco-gen" name = "loco-gen"
version = "0.14.0" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef868bd2df99c949018850b36fb700bba01b10001715f94390bcdb81f412f874"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -3318,8 +3316,6 @@ dependencies = [
[[package]] [[package]]
name = "loco-rs" name = "loco-rs"
version = "0.14.0" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2250c89f0f996c3493ec3d2588a2d63e2861a48df7b9585cb28fbf6faf15a1a0"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-trait", "async-trait",
@ -4621,12 +4617,15 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"serde_yaml",
"serial_test", "serial_test",
"tera",
"testcontainers", "testcontainers",
"testcontainers-modules", "testcontainers-modules",
"thiserror 2.0.10", "thiserror 2.0.10",
"tokio", "tokio",
"tower 0.5.2", "tower 0.5.2",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",

View File

@ -6,6 +6,7 @@ resolver = "2"
testcontainers = { git = "https://github.com/testcontainers/testcontainers-rs.git", rev = "af21727" } testcontainers = { git = "https://github.com/testcontainers/testcontainers-rs.git", rev = "af21727" }
# loco-rs = { git = "https://github.com/lonelyhentxi/loco.git", rev = "beb890e" } # loco-rs = { git = "https://github.com/lonelyhentxi/loco.git", rev = "beb890e" }
# loco-rs = { git = "https://github.com/loco-rs/loco.git" } # loco-rs = { git = "https://github.com/loco-rs/loco.git" }
loco-rs = { path = "./patches/loco" }
async-graphql = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" } async-graphql = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" }
async-graphql-axum = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" } async-graphql-axum = { git = "https://github.com/aumetra/async-graphql.git", rev = "690ece7" }
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" } jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }

View File

@ -3,11 +3,11 @@ import {
noseconeConfig, noseconeConfig,
noseconeMiddleware, noseconeMiddleware,
} from '@konobangu/security/middleware'; } from '@konobangu/security/middleware';
import { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
const securityHeaders = noseconeMiddleware(noseconeConfig); const securityHeaders = noseconeMiddleware(noseconeConfig);
export async function middleware (_request: NextRequest) { export async function middleware(_request: NextRequest) {
const response = await securityHeaders(); const response = await securityHeaders();
return authMiddleware(response as any); return authMiddleware(response as any);
} }

View File

@ -15,3 +15,18 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information # MSVC Windows builds of rustc generate these, which store debugging information
*.pdb *.pdb
# Local
.DS_Store
*.local
*.log*
# Dist
node_modules
dist/
# IDE
.vscode/*
!.vscode/extensions.json
.idea

View File

@ -99,6 +99,9 @@ quirks_path = "0.1.0"
base64 = "0.22.1" base64 = "0.22.1"
tower = "0.5.2" tower = "0.5.2"
axum-extra = "0.10.0" axum-extra = "0.10.0"
tower-http = "0.6.2"
serde_yaml = "0.9.34"
tera = "1.20.0"
[dev-dependencies] [dev-dependencies]
serial_test = "3" serial_test = "3"

View File

View File

@ -0,0 +1,7 @@
<html>
<body>
not found :-(
</body>
</html>

View File

@ -45,6 +45,7 @@ server:
enable: false enable: false
# Duration time in milliseconds. # Duration time in milliseconds.
timeout: 5000 timeout: 5000
cors: cors:
enable: true enable: true
# Set the value of the [`Access-Control-Allow-Origin`][mdn] header # Set the value of the [`Access-Control-Allow-Origin`][mdn] header
@ -114,7 +115,6 @@ redis:
dangerously_flush: false dangerously_flush: false
settings: settings:
dal: dal:
data_dir: ./data data_dir: ./data

View File

@ -0,0 +1,7 @@
{
"name": "recorder",
"version": "1.0.0",
"dependencies": {
"altair-static": "^8.1.3"
}
}

View File

@ -1,130 +0,0 @@
use std::path::Path;
use async_trait::async_trait;
use loco_rs::{
app::{AppContext, Hooks},
boot::{create_app, BootResult, StartMode},
cache,
config::Config,
controller::AppRoutes,
db::truncate_table,
environment::Environment,
prelude::*,
task::Tasks,
Result,
};
use crate::{
auth::service::{AppAuthService, AppAuthServiceInitializer},
controllers::{self},
dal::{AppDalClient, AppDalInitalizer},
extract::mikan::{client::AppMikanClientInitializer, AppMikanClient},
graphql::service::{AppGraphQLService, AppGraphQLServiceInitializer},
migrations::Migrator,
models::subscribers,
workers::subscription_worker::SubscriptionWorker,
};
pub const CONFIG_FOLDER: &str = "LOCO_CONFIG_FOLDER";
pub trait AppContextExt {
fn get_dal_client(&self) -> &AppDalClient {
AppDalClient::app_instance()
}
fn get_mikan_client(&self) -> &AppMikanClient {
AppMikanClient::app_instance()
}
fn get_auth_service(&self) -> &AppAuthService {
AppAuthService::app_instance()
}
fn get_graphql_service(&self) -> &AppGraphQLService {
AppGraphQLService::app_instance()
}
}
impl AppContextExt for AppContext {}
pub struct App;
#[async_trait]
impl Hooks for App {
async fn load_config(env: &Environment) -> Result<Config> {
std::env::var(CONFIG_FOLDER).map_or_else(
|_| {
let monorepo_project_config_dir = Path::new("./apps/recorder/config");
if monorepo_project_config_dir.exists() && monorepo_project_config_dir.is_dir() {
return env.load_from_folder(monorepo_project_config_dir);
}
let current_config_dir = Path::new("./config");
env.load_from_folder(current_config_dir)
},
|config_folder| env.load_from_folder(Path::new(&config_folder)),
)
}
fn app_name() -> &'static str {
env!("CARGO_CRATE_NAME")
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
let initializers: Vec<Box<dyn Initializer>> = vec![
Box::new(AppDalInitalizer),
Box::new(AppMikanClientInitializer),
Box::new(AppGraphQLServiceInitializer),
Box::new(AppAuthServiceInitializer),
];
Ok(initializers)
}
fn app_version() -> String {
format!(
"{} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("BUILD_SHA")
.or(option_env!("GITHUB_SHA"))
.unwrap_or("dev")
)
}
async fn boot(
mode: StartMode,
environment: &Environment,
config: Config,
) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment, config).await
}
fn routes(ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.prefix("/api")
.add_route(controllers::auth::routes())
.add_route(controllers::graphql::routes(ctx.clone()))
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(SubscriptionWorker::build(ctx)).await?;
Ok(())
}
async fn after_context(ctx: AppContext) -> Result<AppContext> {
Ok(AppContext {
cache: cache::Cache::new(cache::drivers::inmem::new()).into(),
..ctx
})
}
fn register_tasks(_tasks: &mut Tasks) {}
async fn truncate(ctx: &AppContext) -> Result<()> {
truncate_table(&ctx.db, subscribers::Entity).await?;
Ok(())
}
async fn seed(_ctx: &AppContext, _base: &Path) -> Result<()> {
Ok(())
}
}

View File

@ -0,0 +1,26 @@
use loco_rs::app::AppContext;
use crate::{
auth::service::AppAuthService, dal::AppDalClient, extract::mikan::AppMikanClient,
graphql::service::AppGraphQLService,
};
pub trait AppContextExt {
fn get_dal_client(&self) -> &AppDalClient {
AppDalClient::app_instance()
}
fn get_mikan_client(&self) -> &AppMikanClient {
AppMikanClient::app_instance()
}
fn get_auth_service(&self) -> &AppAuthService {
AppAuthService::app_instance()
}
fn get_graphql_service(&self) -> &AppGraphQLService {
AppGraphQLService::app_instance()
}
}
impl AppContextExt for AppContext {}

View File

@ -0,0 +1,174 @@
pub mod ext;
use std::{
fs,
path::{self, Path, PathBuf},
};
use async_trait::async_trait;
pub use ext::AppContextExt;
use itertools::Itertools;
use loco_rs::{
app::{AppContext, Hooks},
boot::{create_app, BootResult, StartMode},
cache,
config::Config,
controller::{middleware, middleware::MiddlewareLayer, AppRoutes},
db::truncate_table,
environment::Environment,
prelude::*,
task::Tasks,
Result,
};
use once_cell::sync::OnceCell;
use crate::{
auth::service::AppAuthServiceInitializer,
controllers::{self},
dal::AppDalInitalizer,
extract::mikan::client::AppMikanClientInitializer,
graphql::service::AppGraphQLServiceInitializer,
migrations::Migrator,
models::subscribers,
workers::subscription_worker::SubscriptionWorker,
};
pub const WORKING_ROOT_VAR_NAME: &str = "WORKING_ROOT";
static APP_WORKING_ROOT: OnceCell<quirks_path::PathBuf> = OnceCell::new();
pub struct App;
impl App {
pub fn set_working_root(path: PathBuf) {
APP_WORKING_ROOT.get_or_init(|| {
quirks_path::PathBuf::from(path.as_os_str().to_string_lossy().to_string())
});
}
pub fn get_working_root() -> &'static quirks_path::Path {
APP_WORKING_ROOT
.get()
.map(|p| p.as_path())
.expect("working root not set")
}
}
#[async_trait]
impl Hooks for App {
fn app_version() -> String {
format!(
"{} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("BUILD_SHA")
.or(option_env!("GITHUB_SHA"))
.unwrap_or("dev")
)
}
fn app_name() -> &'static str {
env!("CARGO_CRATE_NAME")
}
async fn boot(
mode: StartMode,
environment: &Environment,
config: Config,
) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment, config).await
}
async fn load_config(env: &Environment) -> Result<Config> {
let working_roots_to_search = [
std::env::var(WORKING_ROOT_VAR_NAME).ok(),
Some(String::from("./apps/recorder")),
Some(String::from(".")),
]
.into_iter()
.flatten()
.collect_vec();
for working_root in working_roots_to_search.iter() {
let working_root = PathBuf::from(working_root);
let config_dir = working_root.as_path().join("config");
for config_file in [
config_dir.join(format!("{env}.local.yaml")),
config_dir.join(format!("{env}.yaml")),
] {
if config_file.exists() && config_file.is_file() {
tracing::info!(config_file =? config_file, "loading environment from");
let content = fs::read_to_string(config_file.clone())?;
let rendered = tera::Tera::one_off(
&content,
&tera::Context::from_serialize(serde_json::json!({}))?,
false,
)?;
App::set_working_root(working_root);
return serde_yaml::from_str(&rendered).map_err(|err| {
loco_rs::Error::YAMLFile(err, config_file.to_string_lossy().to_string())
});
}
}
}
Err(loco_rs::Error::Message(format!(
"no configuration file found in search paths: {}",
working_roots_to_search
.iter()
.map(|p| path::absolute(PathBuf::from(p)))
.flatten()
.map(|p| p.to_string_lossy().to_string())
.join(",")
)))
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
let initializers: Vec<Box<dyn Initializer>> = vec![
Box::new(AppDalInitalizer),
Box::new(AppMikanClientInitializer),
Box::new(AppGraphQLServiceInitializer),
Box::new(AppAuthServiceInitializer),
];
Ok(initializers)
}
fn routes(ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.prefix("/api")
.add_route(controllers::auth::routes())
.add_route(controllers::graphql::routes(ctx.clone()))
}
fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>> {
let mut middlewares = middleware::default_middleware_stack(ctx);
middlewares.extend(controllers::graphql::asset_middlewares());
middlewares
}
async fn after_context(ctx: AppContext) -> Result<AppContext> {
Ok(AppContext {
cache: cache::Cache::new(cache::drivers::inmem::new()).into(),
..ctx
})
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(SubscriptionWorker::build(ctx)).await?;
Ok(())
}
fn register_tasks(_tasks: &mut Tasks) {}
async fn truncate(ctx: &AppContext) -> Result<()> {
truncate_table(&ctx.db, subscribers::Entity).await?;
Ok(())
}
async fn seed(_ctx: &AppContext, _base: &Path) -> Result<()> {
Ok(())
}
}

View File

@ -1,5 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use axum::http::request::Parts; use axum::http::{request::Parts, HeaderValue};
use base64::{self, Engine}; use base64::{self, Engine};
use reqwest::header::AUTHORIZATION; use reqwest::header::AUTHORIZATION;
@ -76,4 +76,8 @@ impl AuthService for BasicAuthService {
} }
Err(AuthError::BasicInvalidCredentials) Err(AuthError::BasicInvalidCredentials)
} }
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
Some(HeaderValue::from_static(r#"Basic realm="konobangu""#))
}
} }

View File

@ -3,15 +3,16 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AuthError { pub enum AuthError {
#[error(transparent)]
OidcInitError(#[from] jwt_authorizer::error::InitError),
#[error("Invalid credentials")] #[error("Invalid credentials")]
BasicInvalidCredentials, BasicInvalidCredentials,
#[error(transparent)] #[error(transparent)]
OidcInitError(#[from] jwt_authorizer::error::InitError),
#[error(transparent)]
OidcJwtAuthError(#[from] jwt_authorizer::AuthError), OidcJwtAuthError(#[from] jwt_authorizer::AuthError),
#[error("Extra scopes {expected} do not match found scopes {found}")] #[error("Extra scopes {expected} do not match found scopes {found}")]
OidcExtraScopesMatchError { expected: String, found: String }, OidcExtraScopesMatchError { expected: String, found: String },
@ -29,8 +30,23 @@ pub enum AuthError {
OidcSubMissingError, OidcSubMissingError,
} }
impl IntoResponse for AuthError { #[derive(Clone, Debug, Serialize, Deserialize)]
fn into_response(self) -> Response { pub struct AuthErrorBody {
(StatusCode::UNAUTHORIZED, Json(self.to_string())).into_response() pub error_code: i32,
pub error_msg: String,
}
impl From<AuthError> for AuthErrorBody {
fn from(value: AuthError) -> Self {
AuthErrorBody {
error_code: StatusCode::UNAUTHORIZED.as_u16() as i32,
error_msg: value.to_string(),
}
}
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
(StatusCode::UNAUTHORIZED, Json(AuthErrorBody::from(self))).into_response()
} }
} }

View File

@ -0,0 +1,63 @@
use axum::{
extract::{Request, State},
http::header,
middleware::Next,
response::{IntoResponse, Response},
};
use loco_rs::prelude::AppContext;
use crate::{app::AppContextExt, auth::AuthService};
pub async fn api_auth_middleware(
State(ctx): State<AppContext>,
request: Request,
next: Next,
) -> Response {
let auth_service = ctx.get_auth_service();
let (mut parts, body) = request.into_parts();
let mut response = match auth_service.extract_user_info(&mut parts).await {
Ok(auth_user_info) => {
let mut request = Request::from_parts(parts, body);
request.extensions_mut().insert(auth_user_info);
next.run(request).await
}
Err(auth_error) => auth_error.into_response(),
};
if let Some(header_value) = auth_service.www_authenticate_header_value() {
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, header_value);
};
response
}
pub async fn webui_auth_middleware(
State(ctx): State<AppContext>,
request: Request,
next: Next,
) -> Response {
let auth_service = ctx.get_auth_service();
let (mut parts, body) = request.into_parts();
let mut response = match auth_service.extract_user_info(&mut parts).await {
Ok(auth_user_info) => {
let mut request = Request::from_parts(parts, body);
request.extensions_mut().insert(auth_user_info);
next.run(request).await
}
Err(auth_error) => auth_error.into_response(),
};
if let Some(header_value) = auth_service.www_authenticate_header_value() {
response
.headers_mut()
.insert(header::WWW_AUTHENTICATE, header_value);
};
response
}

View File

@ -1,9 +1,11 @@
pub mod basic; pub mod basic;
pub mod config; pub mod config;
pub mod errors; pub mod errors;
pub mod middleware;
pub mod oidc; pub mod oidc;
pub mod service; pub mod service;
pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig}; pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig};
pub use errors::AuthError; pub use errors::AuthError;
pub use middleware::{api_auth_middleware, webui_auth_middleware};
pub use service::{AppAuthService, AuthService, AuthUserInfo}; pub use service::{AppAuthService, AuthService, AuthUserInfo};

View File

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use async_trait::async_trait; use async_trait::async_trait;
use axum::http::request::Parts; use axum::http::{request::Parts, HeaderValue};
use itertools::Itertools; use itertools::Itertools;
use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray}; use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -135,4 +135,8 @@ impl AuthService for OidcAuthService {
auth_type: AuthType::Oidc, auth_type: AuthType::Oidc,
}) })
} }
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#))
}
} }

View File

@ -7,6 +7,7 @@ use axum::{
use jwt_authorizer::{JwtAuthorizer, Validation}; use jwt_authorizer::{JwtAuthorizer, Validation};
use loco_rs::app::{AppContext, Initializer}; use loco_rs::app::{AppContext, Initializer};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use reqwest::header::HeaderValue;
use super::{ use super::{
basic::BasicAuthService, basic::BasicAuthService,
@ -16,6 +17,7 @@ use super::{
}; };
use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType}; use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType};
#[derive(Clone, Debug)]
pub struct AuthUserInfo { pub struct AuthUserInfo {
pub user_pid: String, pub user_pid: String,
pub auth_type: AuthType, pub auth_type: AuthType,
@ -40,6 +42,7 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
#[async_trait] #[async_trait]
pub trait AuthService { pub trait AuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>; async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
fn www_authenticate_header_value(&self) -> Option<HeaderValue>;
} }
pub enum AppAuthService { pub enum AppAuthService {
@ -87,6 +90,13 @@ impl AuthService for AppAuthService {
AppAuthService::Oidc(service) => service.extract_user_info(request).await, AppAuthService::Oidc(service) => service.extract_user_info(request).await,
} }
} }
fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
match self {
AppAuthService::Basic(service) => service.www_authenticate_header_value(),
AppAuthService::Oidc(service) => service.www_authenticate_header_value(),
}
}
} }
pub struct AppAuthServiceInitializer; pub struct AppAuthServiceInitializer;

View File

@ -11,5 +11,6 @@ mikan:
base_url: "https://mikanani.me/" base_url: "https://mikanani.me/"
graphql: graphql:
playground_static: "./node_modules/altair-static/build/dist"
depth_limit: null depth_limit: null
complexity_limit: null complexity_limit: null

View File

@ -1,14 +0,0 @@
use loco_rs::prelude::*;
use crate::{models::subscribers, views::subscribers::CurrentResponse};
async fn current(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
let subscriber = subscribers::Model::find_root(&ctx).await?;
format::json(CurrentResponse::new(&subscriber))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("subscribers")
.add("/current", get(current))
}

View File

@ -0,0 +1,10 @@
use axum::response::IntoResponse;
use loco_rs::prelude::*;
async fn current() -> impl IntoResponse {
""
}
pub fn routes() -> Routes {
Routes::new().prefix("/auth").add("/current", get(current))
}

View File

@ -1,40 +0,0 @@
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
extract::State,
middleware::from_extractor_with_state,
response::{Html, IntoResponse},
routing::{get, post},
};
use loco_rs::{app::AppContext, prelude::Routes};
use crate::{app::AppContextExt, auth::AuthUserInfo};
async fn graphql_playground() -> impl IntoResponse {
Html(playground_source(GraphQLPlaygroundConfig::new(
"/api/graphql",
)))
}
async fn graphql_handler(
State(ctx): State<AppContext>,
auth_user_info: AuthUserInfo,
req: GraphQLRequest,
) -> GraphQLResponse {
let graphql_service = ctx.get_graphql_service();
let mut req = req.into_inner();
req = req.data(auth_user_info);
graphql_service.schema.execute(req).await.into()
}
pub fn routes(state: AppContext) -> Routes {
Routes::new()
.prefix("/graphql")
.add("/playground", get(graphql_playground))
.add(
"/",
post(graphql_handler)
.layer(from_extractor_with_state::<AuthUserInfo, AppContext>(state)),
)
}

View File

@ -0,0 +1,71 @@
pub mod playground;
use std::collections::HashMap;
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
extract::State,
http::HeaderMap,
middleware::from_fn_with_state,
response::Html,
routing::{get, post},
Extension,
};
use loco_rs::{app::AppContext, controller::middleware::MiddlewareLayer, prelude::Routes};
use playground::{altair_graphql_playground_asset_middleware, AltairGraphQLPlayground};
use reqwest::header;
use crate::{
app::AppContextExt,
auth::{api_auth_middleware, webui_auth_middleware, AuthUserInfo},
};
async fn graphql_playground(header_map: HeaderMap) -> loco_rs::Result<Html<String>> {
let mut playground_config = AltairGraphQLPlayground::new("/api/graphql");
if let Some(authorization) = header_map.get(header::AUTHORIZATION) {
if let Ok(authorization) = authorization.to_str() {
playground_config.initial_headers = {
let mut m = HashMap::new();
m.insert(header::AUTHORIZATION.to_string(), authorization.to_string());
Some(m)
}
}
}
let html = Html(playground_config.render("/api/graphql/playground/static/")?);
Ok(html)
}
async fn graphql_handler(
State(ctx): State<AppContext>,
Extension(auth_user_info): Extension<AuthUserInfo>,
req: GraphQLRequest,
) -> GraphQLResponse {
let graphql_service = ctx.get_graphql_service();
let mut req = req.into_inner();
req = req.data(auth_user_info);
graphql_service.schema.execute(req).await.into()
}
pub fn routes(state: AppContext) -> Routes {
Routes::new()
.prefix("/graphql")
.add(
"/playground",
get(graphql_playground).layer(from_fn_with_state(state.clone(), webui_auth_middleware)),
)
.add(
"/",
post(graphql_handler).layer(from_fn_with_state(state, api_auth_middleware)),
)
}
pub fn asset_middlewares() -> Vec<Box<dyn MiddlewareLayer>> {
vec![Box::new(altair_graphql_playground_asset_middleware(
"/api/graphql/playground/static/",
))]
}

View File

@ -0,0 +1,74 @@
use std::collections::HashMap;
use lazy_static::lazy_static;
use loco_rs::controller::middleware::static_assets::{FolderConfig, StaticAssets};
use regex::Regex;
use serde::Serialize;
use serde_json::Value;
use crate::app::App;
const ALTAIR_GRAPHQL_HTML: &'static str =
include_str!("../../../node_modules/altair-static/build/dist/index.html");
lazy_static! {
static ref ALTAIR_GRAPHQL_BASE_REGEX: Regex = Regex::new(r"<base.*>").unwrap();
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AltairGraphQLPlayground<'a> {
#[serde(rename = "endpointURL")]
pub endpoint_url: &'a str,
/**
* URL to set as the subscription endpoint. This can be relative or
* absolute.
*/
pub subscriptions_endpoint: Option<&'a str>,
pub initial_headers: Option<HashMap<String, String>>,
pub initial_settings: Option<HashMap<String, Value>>,
#[serde(flatten)]
pub other: Option<HashMap<String, Value>>,
}
impl<'a> AltairGraphQLPlayground<'a> {
/// Create a config for GraphQL playground.
pub fn new(endpoint_url: &'a str) -> Self {
Self {
endpoint_url,
subscriptions_endpoint: Default::default(),
initial_headers: Default::default(),
initial_settings: Default::default(),
other: Default::default(),
}
}
pub fn render(&self, base_url: &str) -> loco_rs::Result<String> {
let option = serde_json::to_string(self)?;
let render_str = ALTAIR_GRAPHQL_BASE_REGEX
.replace(ALTAIR_GRAPHQL_HTML, format!(r#"<base href="{base_url}">"#))
.replace(
"</body>",
&format!("<script>AltairGraphQL.init({});</script></body>", option),
);
Ok(render_str)
}
}
pub fn altair_graphql_playground_asset_middleware(base_url: &str) -> StaticAssets {
StaticAssets {
enable: true,
must_exist: true,
folder: FolderConfig {
uri: String::from(base_url),
path: App::get_working_root()
.join("node_modules/altair-static/build/dist")
.into(),
},
fallback: App::get_working_root()
.join("assets/static/404.html")
.into(),
precompressed: false,
}
}

View File

@ -11,7 +11,7 @@ use url::Url;
use uuid::Uuid; use uuid::Uuid;
use super::AppDalConfig; use super::AppDalConfig;
use crate::config::AppConfigExt; use crate::{app::App, config::AppConfigExt};
// TODO: wait app-context-trait to integrate // TODO: wait app-context-trait to integrate
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -52,12 +52,16 @@ impl fmt::Display for DalStoredUrl {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AppDalClient { pub struct AppDalClient {
pub config: AppDalConfig, pub data_dir: String,
} }
impl AppDalClient { impl AppDalClient {
pub fn new(config: AppDalConfig) -> Self { pub fn new(config: AppDalConfig) -> Self {
Self { config } Self {
data_dir: App::get_working_root()
.join(config.data_dir.as_deref().unwrap_or("./data"))
.to_string(),
}
} }
pub fn app_instance() -> &'static AppDalClient { pub fn app_instance() -> &'static AppDalClient {
@ -67,13 +71,7 @@ impl AppDalClient {
} }
pub fn get_fs(&self) -> Fs { pub fn get_fs(&self) -> Fs {
Fs::default().root( Fs::default().root(&self.data_dir)
self.config
.data_dir
.as_ref()
.map(|s| s as &str)
.unwrap_or("./data"),
)
} }
pub fn create_filename(extname: &str) -> String { pub fn create_filename(extname: &str) -> String {

View File

@ -31,7 +31,6 @@
"import-in-the-middle": "^1.11.3", "import-in-the-middle": "^1.11.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"mdx-bundler": "^10.0.3", "mdx-bundler": "^10.0.3",
"next": "^15.1.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-wrap-balancer": "^1.1.1", "react-wrap-balancer": "^1.1.1",

View File

@ -26,7 +26,7 @@
"node": ">=22" "node": ">=22"
}, },
"dependencies": { "dependencies": {
"chalk": "^5.3.0", "chalk": "^5.4.1",
"commander": "^12.1.0" "commander": "^12.1.0"
}, },
"devDependencies": { "devDependencies": {
@ -40,7 +40,7 @@
"shx": "^0.3.4", "shx": "^0.3.4",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"turbo": "^2.3.3", "turbo": "^2.3.3",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"ultracite": "^4.1.12" "ultracite": "^4.1.14"
} }
} }

View File

@ -8,14 +8,14 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"incremental": false, "incremental": false,
"isolatedModules": true, "isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"], "lib": ["es2024", "DOM", "DOM.Iterable"],
"module": "NodeNext", "module": "NodeNext",
"moduleDetection": "force", "moduleDetection": "force",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"target": "ES2022", "target": "ES2020",
"strictNullChecks": true "strictNullChecks": true
} }
} }

808
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff