mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
37ebaf4d0a
* feat: add typed request body schema to OpenAPI spec for runnables without preprocessor For HTTP routes and webhooks whose runnables (scripts/flows) don't have a preprocessor, generate a typed request body in the OpenAPI spec using the runnable's argument schema. Routes with preprocessors or wrap_body keep the existing generic default request body. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix sqlx * fix: add warning logs for schema fetch failures and strip non-OpenAPI keys - Log tracing::warn when DB queries for schema fail instead of silently swallowing errors with .ok() - Strip $schema and order keys from the JSON Schema before embedding in the OpenAPI spec for broader client compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add tracing dependency to windmill-api-openapi Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1088 lines
32 KiB
Rust
1088 lines
32 KiB
Rust
use std::{
|
|
collections::{HashMap, HashSet},
|
|
fmt::Display,
|
|
};
|
|
|
|
use anyhow::anyhow;
|
|
use axum::{
|
|
body::Body, extract::Path, http, response::Response, routing::post, Extension, Json, Router,
|
|
};
|
|
use http::{header, HeaderValue, Method, StatusCode};
|
|
use indexmap::IndexMap;
|
|
use itertools::Itertools;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{to_value, Map, Value};
|
|
use sqlx::PgConnection;
|
|
use url::Url;
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, Result},
|
|
utils::{deserialize_url, empty_as_none, is_empty, RunnableKind},
|
|
DB,
|
|
};
|
|
use windmill_store::resources::try_get_resource_from_db_as;
|
|
|
|
use windmill_api_auth::ApiAuthed;
|
|
use windmill_trigger_http::{
|
|
http_trigger_auth::ApiKeyAuthentication, AuthenticationMethod, HttpMethod, RequestType,
|
|
};
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref DEFAULT_OPENAPI_INFO_OBJECT: Info = Info {
|
|
title: "Windmill API".to_string(),
|
|
version: "1.0.0".to_string(),
|
|
..Default::default()
|
|
};
|
|
}
|
|
|
|
const DEFAULT_OPENAPI_GENERATED_VERSION: &'static str = "3.1.0";
|
|
const JWT_SECURITY_SCHEME: &'static str = "JwtAuth";
|
|
const BASIC_HTTP_AUTH_SCHEME: &'static str = "BasicHttp";
|
|
|
|
const DEFAULT_REQUEST_KEY: &'static str = "defaultRequest";
|
|
const DEFAULT_ASYNC_RESPONSE_KEY: &'static str = "AsyncResponse";
|
|
const DEFAULT_SYNC_RESPONSE_KEY: &'static str = "SyncResponse";
|
|
const DEFAULT_SYNC_SSE_RESPONSE_KEY: &'static str = "SyncSseResponse";
|
|
const DEFAULT_PAYLOAD_PARAM_KEY: &'static str = "PayloadParam";
|
|
|
|
pub fn openapi_service() -> Router {
|
|
Router::new()
|
|
.route("/generate", post(generate_openapi_spec))
|
|
.route("/download", post(download_spec))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone, Copy)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Format {
|
|
JSON,
|
|
YAML,
|
|
}
|
|
|
|
impl Display for Format {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let format = match self {
|
|
Format::JSON => "json",
|
|
Format::YAML => "yaml",
|
|
};
|
|
write!(f, "{}", format)
|
|
}
|
|
}
|
|
|
|
impl Default for Format {
|
|
fn default() -> Self {
|
|
Self::YAML
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
struct Contact {
|
|
#[serde(skip_serializing_if = "is_empty")]
|
|
name: Option<String>,
|
|
#[serde(
|
|
default,
|
|
deserialize_with = "deserialize_url",
|
|
skip_serializing_if = "Option::is_none"
|
|
)]
|
|
url: Option<Url>,
|
|
#[serde(skip_serializing_if = "is_empty")]
|
|
email: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
struct License {
|
|
name: String,
|
|
#[serde(skip_serializing_if = "is_empty")]
|
|
identifier: Option<String>,
|
|
#[serde(
|
|
default,
|
|
deserialize_with = "deserialize_url",
|
|
skip_serializing_if = "Option::is_none"
|
|
)]
|
|
url: Option<Url>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
|
pub struct Info {
|
|
title: String,
|
|
version: String,
|
|
#[serde(skip_serializing_if = "is_empty")]
|
|
description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
contact: Option<Contact>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
license: Option<License>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct Server {
|
|
url: String,
|
|
#[serde(skip_serializing_if = "is_empty")]
|
|
description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
variables: Option<HashMap<String, Value>>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum SecurityScheme {
|
|
BearerJwt,
|
|
BasicHttp,
|
|
ApiKey(String),
|
|
}
|
|
#[derive(Debug)]
|
|
pub struct WebhookConfig {
|
|
runnable_kind: RunnableKind,
|
|
}
|
|
|
|
impl WebhookConfig {
|
|
pub fn new(runnable_kind: RunnableKind) -> Self {
|
|
Self { runnable_kind }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct HttpRouteConfig {
|
|
method: Method,
|
|
}
|
|
|
|
impl HttpRouteConfig {
|
|
pub fn new(method: Method) -> Self {
|
|
Self { method }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum Kind {
|
|
Webhook(WebhookConfig),
|
|
HttpRoute(HttpRouteConfig),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct FuturePath {
|
|
route_path: String,
|
|
kind: Kind,
|
|
request_type: Option<RequestType>,
|
|
summary: Option<String>,
|
|
description: Option<String>,
|
|
security_scheme: Option<SecurityScheme>,
|
|
args_schema: Option<Value>,
|
|
}
|
|
|
|
impl FuturePath {
|
|
pub fn new(
|
|
route_path: String,
|
|
kind: Kind,
|
|
request_type: Option<RequestType>,
|
|
summary: Option<String>,
|
|
description: Option<String>,
|
|
security_scheme: Option<SecurityScheme>,
|
|
args_schema: Option<Value>,
|
|
) -> FuturePath {
|
|
FuturePath {
|
|
route_path,
|
|
kind,
|
|
request_type,
|
|
summary,
|
|
description,
|
|
security_scheme,
|
|
args_schema,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn from_route_path_to_openapi_path(
|
|
route_path: &str,
|
|
kind: &Kind,
|
|
) -> Result<(Vec<String>, Option<Value>)> {
|
|
let mut openapi_path = String::new();
|
|
let mut parameters = Vec::new();
|
|
|
|
for segment in route_path.split('/') {
|
|
if segment.starts_with(':') {
|
|
let param_name = &segment[1..];
|
|
|
|
if param_name.is_empty() {
|
|
return Err(anyhow!("Empty parameter name in path: {}", route_path).into());
|
|
}
|
|
|
|
openapi_path.push_str(&format!("/{{{}}}", param_name));
|
|
parameters.push(serde_json::json!({
|
|
"name": param_name,
|
|
"in": "path",
|
|
"required": true,
|
|
"schema": { "type": "string" }
|
|
}));
|
|
} else if !segment.is_empty() {
|
|
openapi_path.push('/');
|
|
openapi_path.push_str(segment);
|
|
} else {
|
|
openapi_path.push('/');
|
|
}
|
|
}
|
|
|
|
let parameters_json = if parameters.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Value::Array(parameters))
|
|
};
|
|
|
|
let prefix = match kind {
|
|
Kind::HttpRoute(_) => "",
|
|
Kind::Webhook(WebhookConfig { runnable_kind }) => match runnable_kind {
|
|
RunnableKind::Script => "p",
|
|
RunnableKind::Flow => "f",
|
|
},
|
|
};
|
|
|
|
let normalized_path = if openapi_path.starts_with('/') {
|
|
format!("{prefix}{openapi_path}")
|
|
} else {
|
|
format!("{}/{}", prefix, openapi_path)
|
|
};
|
|
|
|
let route_paths = if prefix.is_empty() {
|
|
vec![normalized_path]
|
|
} else {
|
|
vec![
|
|
format!("/run/{}", &normalized_path),
|
|
format!("/run_wait_result/{}", &normalized_path),
|
|
format!("/run_and_stream/{}", &normalized_path),
|
|
]
|
|
};
|
|
|
|
Ok((route_paths, parameters_json))
|
|
}
|
|
|
|
fn get_servers_component(url: &str, kind: &Kind) -> Server {
|
|
let url = url.trim_end_matches('/');
|
|
|
|
let server = match kind {
|
|
Kind::HttpRoute(_) => {
|
|
Server { url: format!("{}/api/r", url), description: None, variables: None }
|
|
}
|
|
Kind::Webhook(_) => Server {
|
|
url: format!("{}/api/w/{{workspace}}/jobs", url),
|
|
variables: Some(HashMap::from([(
|
|
"workspace".to_string(),
|
|
serde_json::json!({
|
|
"default": "test",
|
|
"description": "Workspace identifier"
|
|
}),
|
|
)])),
|
|
description: None,
|
|
},
|
|
};
|
|
|
|
server
|
|
}
|
|
|
|
fn generate_paths(
|
|
paths: Vec<FuturePath>,
|
|
url: Option<&Url>,
|
|
) -> Result<IndexMap<String, IndexMap<String, Value>>> {
|
|
let mut map: IndexMap<String, IndexMap<String, Value>> = IndexMap::new();
|
|
|
|
let generate_default_request = || {
|
|
serde_json::json!({
|
|
"$ref": format!("#/components/requestBodies/{DEFAULT_REQUEST_KEY}")
|
|
})
|
|
};
|
|
|
|
let generate_response = |request_type: RequestType| {
|
|
let responses = match request_type {
|
|
RequestType::Async => serde_json::json!({
|
|
"200": {
|
|
"$ref": format!("#/components/responses/{DEFAULT_ASYNC_RESPONSE_KEY}")
|
|
}
|
|
}),
|
|
RequestType::Sync => serde_json::json!({
|
|
"200": {
|
|
"$ref": format!("#/components/responses/{DEFAULT_SYNC_RESPONSE_KEY}")
|
|
}
|
|
}),
|
|
RequestType::SyncSse => serde_json::json!({
|
|
"200": {
|
|
"$ref": format!("#/components/responses/{DEFAULT_SYNC_SSE_RESPONSE_KEY}")
|
|
}
|
|
}),
|
|
};
|
|
|
|
responses
|
|
};
|
|
|
|
let get_security_scheme = |security_scheme: Option<&SecurityScheme>| -> Vec<Value> {
|
|
if let Some(security_scheme) = security_scheme {
|
|
let scheme = match security_scheme {
|
|
SecurityScheme::ApiKey(api_key) => header_to_pascal_case(&api_key),
|
|
SecurityScheme::BearerJwt => JWT_SECURITY_SCHEME.to_owned(),
|
|
SecurityScheme::BasicHttp => BASIC_HTTP_AUTH_SCHEME.to_owned(),
|
|
};
|
|
|
|
vec![serde_json::json!({
|
|
scheme: []
|
|
})]
|
|
} else {
|
|
vec![]
|
|
}
|
|
};
|
|
|
|
let mut webhooks = HashSet::new();
|
|
|
|
for path in paths {
|
|
if let Kind::Webhook(WebhookConfig { runnable_kind }) = &path.kind {
|
|
if !webhooks.insert((path.route_path.clone(), runnable_kind.to_owned())) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let (route_paths, parameters) =
|
|
from_route_path_to_openapi_path(&path.route_path, &path.kind)?;
|
|
|
|
for route_path in route_paths {
|
|
let path_object = map.entry(route_path.clone()).or_insert_with(|| {
|
|
let mut path_object = IndexMap::new();
|
|
|
|
if let Some(url) = url {
|
|
let servers = get_servers_component(url.as_str(), &path.kind);
|
|
path_object.insert("servers".to_string(), to_value(vec![servers]).unwrap());
|
|
}
|
|
|
|
if parameters.is_some() {
|
|
path_object.insert(
|
|
"parameters".to_string(),
|
|
to_value(parameters.clone()).unwrap(),
|
|
);
|
|
}
|
|
|
|
path_object
|
|
});
|
|
|
|
let request_type;
|
|
|
|
let (methods, is_webhook) = match &path.kind {
|
|
Kind::Webhook(_) => {
|
|
request_type = if route_path.starts_with("/run/") {
|
|
RequestType::Async
|
|
} else if route_path.starts_with("/run_and_stream/") {
|
|
RequestType::SyncSse
|
|
} else {
|
|
RequestType::Sync
|
|
};
|
|
|
|
let methods = if request_type == RequestType::Async {
|
|
vec![Method::POST]
|
|
} else {
|
|
vec![Method::GET, Method::POST]
|
|
};
|
|
|
|
(methods, true)
|
|
}
|
|
Kind::HttpRoute(HttpRouteConfig { method }) => {
|
|
if path_object.get(&method.to_string()).is_some() {
|
|
return Err(anyhow!(
|
|
"Found duplicate {} method, for route at path: {}",
|
|
method,
|
|
path.route_path
|
|
)
|
|
.into());
|
|
}
|
|
request_type = path.request_type.unwrap_or(RequestType::Sync);
|
|
(vec![method.to_owned()], false)
|
|
}
|
|
};
|
|
|
|
for method in methods {
|
|
let mut method_map = IndexMap::new();
|
|
|
|
if let Some(summary) = path.summary.as_ref().filter(|s| !s.is_empty()) {
|
|
method_map.insert("summary", Value::String(summary.to_owned()));
|
|
}
|
|
|
|
if let Some(description) = path.description.as_ref().filter(|s| !s.is_empty()) {
|
|
method_map.insert("description", Value::String(description.to_owned()));
|
|
}
|
|
|
|
method_map.insert(
|
|
"security",
|
|
to_value(get_security_scheme(path.security_scheme.as_ref()))?,
|
|
);
|
|
|
|
if method != Method::GET {
|
|
if let Some(schema) = &path.args_schema {
|
|
method_map.insert(
|
|
"requestBody",
|
|
serde_json::json!({
|
|
"required": true,
|
|
"content": {
|
|
"application/json": {
|
|
"schema": schema
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
} else {
|
|
method_map.insert("requestBody", generate_default_request());
|
|
}
|
|
} else if is_webhook {
|
|
method_map.insert(
|
|
"parameters",
|
|
Value::Array(vec![serde_json::json!({
|
|
"$ref": format!("#/components/parameters/{DEFAULT_PAYLOAD_PARAM_KEY}")
|
|
})]),
|
|
);
|
|
}
|
|
|
|
method_map.insert("responses", generate_response(request_type));
|
|
|
|
path_object.insert(method.to_string().to_lowercase(), to_value(&method_map)?);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Ok(map);
|
|
}
|
|
|
|
pub fn transform_to_minified_postgres_regex(glob: &str) -> String {
|
|
let mut regex = String::from("^");
|
|
for ch in glob.chars() {
|
|
match ch {
|
|
'*' => regex.push_str(".*"),
|
|
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => {
|
|
regex.push('\\');
|
|
regex.push(ch);
|
|
}
|
|
_ => regex.push(ch),
|
|
}
|
|
}
|
|
|
|
regex.push('$');
|
|
regex
|
|
}
|
|
|
|
fn header_to_pascal_case(header: &str) -> String {
|
|
header
|
|
.split(|c: char| c == '-' || c == '_' || c == ' ')
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| {
|
|
let mut chars = s.chars();
|
|
match chars.next() {
|
|
Some(first) => {
|
|
first.to_ascii_uppercase().to_string()
|
|
+ chars.as_str().to_ascii_lowercase().as_str()
|
|
}
|
|
None => String::new(),
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct SecuritySchemeToAdd {
|
|
basic_http: bool,
|
|
bearer_jwt: bool,
|
|
api_keys: Vec<(String, Value)>,
|
|
}
|
|
|
|
fn generate_all_security_schemes(future_paths: &[FuturePath]) -> SecuritySchemeToAdd {
|
|
let mut to_add = SecuritySchemeToAdd::default();
|
|
|
|
let mut set = HashSet::new();
|
|
for future_path in future_paths {
|
|
if !to_add.basic_http
|
|
&& matches!(future_path.security_scheme, Some(SecurityScheme::BasicHttp))
|
|
{
|
|
to_add.basic_http = true
|
|
} else if !to_add.bearer_jwt
|
|
&& matches!(future_path.security_scheme, Some(SecurityScheme::BearerJwt))
|
|
{
|
|
to_add.bearer_jwt = true
|
|
} else if let Some(SecurityScheme::ApiKey(api_key)) = future_path.security_scheme.as_ref() {
|
|
let pascal_case_header = header_to_pascal_case(&api_key);
|
|
|
|
if !set.insert(pascal_case_header.clone()) {
|
|
continue;
|
|
}
|
|
|
|
let scheme = serde_json::json!({
|
|
"type": "apiKey",
|
|
"in": "header",
|
|
"name": api_key
|
|
});
|
|
to_add.api_keys.push((pascal_case_header, scheme));
|
|
}
|
|
}
|
|
|
|
to_add
|
|
}
|
|
|
|
fn generate_components(future_paths: &[FuturePath]) -> Map<String, Value> {
|
|
let mut components = Map::new();
|
|
|
|
if future_paths
|
|
.iter()
|
|
.any(|path| matches!(path.kind, Kind::Webhook(_)))
|
|
{
|
|
components.insert(
|
|
"parameters".to_owned(),
|
|
serde_json::json!({
|
|
"PayloadParam": {
|
|
"name": "payload",
|
|
"in": "query",
|
|
"required": true,
|
|
"description": "A URL-safe base64-encoded JSON string payload.",
|
|
"schema": {
|
|
"type": "string"
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
{
|
|
let mut security_scheme = Map::new();
|
|
|
|
let SecuritySchemeToAdd { basic_http, bearer_jwt, api_keys } =
|
|
generate_all_security_schemes(future_paths);
|
|
|
|
if basic_http {
|
|
security_scheme.insert(
|
|
BASIC_HTTP_AUTH_SCHEME.to_owned(),
|
|
serde_json::json!({
|
|
"type": "http",
|
|
"scheme": "basic"
|
|
}),
|
|
);
|
|
}
|
|
|
|
if bearer_jwt {
|
|
security_scheme.insert(
|
|
JWT_SECURITY_SCHEME.to_owned(),
|
|
serde_json::json!({
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT"
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (key, value) in api_keys {
|
|
security_scheme.insert(key, value);
|
|
}
|
|
components.insert("securitySchemes".to_owned(), Value::Object(security_scheme));
|
|
}
|
|
|
|
components.insert("requestBodies".to_owned(), serde_json::json!({
|
|
DEFAULT_REQUEST_KEY: {
|
|
"description": "This route may or may not require a request body, but its structure and content type are unknown.",
|
|
"required": false,
|
|
"content": {
|
|
"application/json": {}
|
|
}
|
|
}
|
|
}));
|
|
|
|
components.insert("responses".to_owned(), serde_json::json!({
|
|
DEFAULT_ASYNC_RESPONSE_KEY: {
|
|
"description": "Returns a job ID as a UUID string.",
|
|
"content": {
|
|
"text/plain": {
|
|
"schema": {
|
|
"type": "string",
|
|
"format": "uuid",
|
|
"examples": [ "550e8400-e29b-41d4-a716-446655440000" ]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
DEFAULT_SYNC_RESPONSE_KEY: {
|
|
"description": "This route may return a response, but its structure and content type are unknown.",
|
|
"content": {
|
|
"application/octet-stream": {}
|
|
}
|
|
},
|
|
DEFAULT_SYNC_SSE_RESPONSE_KEY: {
|
|
"description": "Returns an SSE stream.",
|
|
"content": {
|
|
"text/event-stream": {
|
|
"schema": {
|
|
"type": "string",
|
|
"description": "Stream of SSE"
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
|
|
components
|
|
}
|
|
|
|
pub fn generate_openapi_document(
|
|
info: Option<&Info>,
|
|
url: Option<&Url>,
|
|
paths: Vec<FuturePath>,
|
|
format: Format,
|
|
) -> Result<String> {
|
|
let mut openapi_doc: IndexMap<&'static str, Value> = IndexMap::new();
|
|
|
|
openapi_doc.insert("openapi", to_value(&DEFAULT_OPENAPI_GENERATED_VERSION)?);
|
|
openapi_doc.insert(
|
|
"info",
|
|
to_value(info.unwrap_or(&DEFAULT_OPENAPI_INFO_OBJECT))?,
|
|
);
|
|
|
|
openapi_doc.insert("components", Value::Object(generate_components(&paths)));
|
|
|
|
openapi_doc.insert("paths", to_value(generate_paths(paths, url)?)?);
|
|
|
|
let openapi_document = match format {
|
|
Format::YAML => serde_yml::to_string(&openapi_doc).map_err(|err| {
|
|
anyhow!(
|
|
"Could not generate OpenAPI document in YAML format: {}",
|
|
err
|
|
)
|
|
})?,
|
|
Format::JSON => serde_json::to_string_pretty(&openapi_doc).map_err(|err| {
|
|
anyhow!(
|
|
"Could not generate OpenAPI document in JSON format: {}",
|
|
err
|
|
)
|
|
})?,
|
|
};
|
|
|
|
Ok(openapi_document)
|
|
}
|
|
|
|
#[allow(unused)]
|
|
#[derive(Debug, Deserialize)]
|
|
struct HttpRouteFilter {
|
|
folder_regex: String,
|
|
path_regex: String,
|
|
route_path_regex: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WebhookFilter {
|
|
user_or_folder_regex: String,
|
|
user_or_folder_regex_value: String,
|
|
path: String,
|
|
runnable_kind: RunnableKind,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct GenerateOpenAPI {
|
|
info: Option<Info>,
|
|
url: Option<Url>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
http_route_filters: Option<Vec<HttpRouteFilter>>,
|
|
#[serde(default, deserialize_with = "empty_as_none")]
|
|
webhook_filters: Option<Vec<WebhookFilter>>,
|
|
#[serde(default)]
|
|
openapi_spec_format: Format,
|
|
}
|
|
|
|
fn clean_schema_for_openapi(schema: Value) -> Value {
|
|
if let Value::Object(mut obj) = schema {
|
|
obj.remove("$schema");
|
|
obj.remove("order");
|
|
Value::Object(obj)
|
|
} else {
|
|
schema
|
|
}
|
|
}
|
|
|
|
async fn get_runnable_schema(
|
|
db: &DB,
|
|
w_id: &str,
|
|
script_path: &str,
|
|
is_flow: bool,
|
|
) -> Option<Value> {
|
|
if is_flow {
|
|
let row = sqlx::query!(
|
|
r#"SELECT
|
|
f.schema AS "schema: serde_json::Value",
|
|
fv.value->>'preprocessor_module' IS NOT NULL AS "has_preprocessor: bool"
|
|
FROM flow f
|
|
LEFT JOIN flow_version fv ON fv.id = f.versions[array_length(f.versions, 1)]
|
|
AND fv.workspace_id = f.workspace_id
|
|
WHERE f.path = $1 AND f.workspace_id = $2 AND NOT f.archived"#,
|
|
script_path,
|
|
w_id,
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!("Failed to fetch flow schema for {script_path}: {e}");
|
|
e
|
|
})
|
|
.ok()
|
|
.flatten()?;
|
|
|
|
if row.has_preprocessor.unwrap_or(false) {
|
|
return None;
|
|
}
|
|
row.schema.map(clean_schema_for_openapi)
|
|
} else {
|
|
let row = sqlx::query!(
|
|
r#"SELECT
|
|
schema AS "schema: serde_json::Value",
|
|
has_preprocessor
|
|
FROM script
|
|
WHERE path = $1 AND workspace_id = $2
|
|
AND NOT archived AND NOT deleted
|
|
ORDER BY created_at DESC
|
|
LIMIT 1"#,
|
|
script_path,
|
|
w_id,
|
|
)
|
|
.fetch_optional(db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!("Failed to fetch script schema for {script_path}: {e}");
|
|
e
|
|
})
|
|
.ok()
|
|
.flatten()?;
|
|
|
|
if row.has_preprocessor.unwrap_or(false) {
|
|
return None;
|
|
}
|
|
row.schema.map(clean_schema_for_openapi)
|
|
}
|
|
}
|
|
|
|
async fn http_routes_to_future_paths(
|
|
db: &DB,
|
|
user_db: UserDB,
|
|
authed: &ApiAuthed,
|
|
pg_pool: &mut PgConnection,
|
|
http_route_filters: Option<&[HttpRouteFilter]>,
|
|
w_id: &str,
|
|
) -> Result<Vec<FuturePath>> {
|
|
let mut http_routes = Vec::new();
|
|
|
|
if let Some(http_route_filters) = http_route_filters {
|
|
let path_regex = http_route_filters
|
|
.iter()
|
|
.map(|filter| {
|
|
transform_to_minified_postgres_regex(&format!(
|
|
"f/{}/{}",
|
|
filter.folder_regex, filter.path_regex
|
|
))
|
|
})
|
|
.collect_vec();
|
|
|
|
let route_path_regex = http_route_filters
|
|
.iter()
|
|
.map(|filter| transform_to_minified_postgres_regex(&filter.route_path_regex))
|
|
.collect_vec();
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MinifiedHttpTrigger {
|
|
route_path: String,
|
|
http_method: HttpMethod,
|
|
request_type: RequestType,
|
|
workspaced_route: bool,
|
|
summary: Option<String>,
|
|
description: Option<String>,
|
|
authentication_method: AuthenticationMethod,
|
|
authentication_resource_path: Option<String>,
|
|
script_path: String,
|
|
is_flow: bool,
|
|
wrap_body: bool,
|
|
}
|
|
|
|
http_routes = sqlx::query_as!(
|
|
MinifiedHttpTrigger,
|
|
r#"
|
|
SELECT
|
|
route_path,
|
|
http_method AS "http_method: _",
|
|
request_type AS "request_type: _",
|
|
workspaced_route,
|
|
summary,
|
|
description,
|
|
authentication_method AS "authentication_method: _",
|
|
authentication_resource_path,
|
|
script_path,
|
|
is_flow,
|
|
wrap_body
|
|
FROM
|
|
http_trigger
|
|
WHERE
|
|
path ~ ANY($1) AND
|
|
route_path ~ ANY($2) AND
|
|
workspace_id = $3
|
|
"#,
|
|
&path_regex,
|
|
&route_path_regex,
|
|
&w_id
|
|
)
|
|
.fetch_all(pg_pool)
|
|
.await?;
|
|
}
|
|
|
|
let mut openapi_future_paths = Vec::with_capacity(http_routes.len());
|
|
|
|
for http_route in http_routes {
|
|
let auth_method = match http_route.authentication_method {
|
|
AuthenticationMethod::BasicHttp => Some(SecurityScheme::BasicHttp),
|
|
AuthenticationMethod::Windmill => Some(SecurityScheme::BearerJwt),
|
|
AuthenticationMethod::ApiKey => {
|
|
let resource_path = match http_route.authentication_resource_path {
|
|
Some(resource_path) => resource_path,
|
|
None => {
|
|
return Err(Error::BadRequest(
|
|
"Missing authentication resource path".to_string(),
|
|
));
|
|
}
|
|
};
|
|
|
|
let api = try_get_resource_from_db_as::<ApiKeyAuthentication>(
|
|
authed,
|
|
Some(user_db.clone()),
|
|
db,
|
|
&resource_path,
|
|
w_id,
|
|
)
|
|
.await?;
|
|
|
|
Some(SecurityScheme::ApiKey(api.api_key_header))
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
let route_path = if http_route.workspaced_route {
|
|
format!("{}/{}", w_id, http_route.route_path.trim_start_matches('/'))
|
|
} else {
|
|
http_route.route_path.clone()
|
|
};
|
|
|
|
let method = match http_route.http_method {
|
|
HttpMethod::Get => Method::GET,
|
|
HttpMethod::Post => Method::POST,
|
|
HttpMethod::Put => Method::PUT,
|
|
HttpMethod::Patch => Method::PATCH,
|
|
HttpMethod::Delete => Method::DELETE,
|
|
};
|
|
|
|
let args_schema = if !http_route.wrap_body {
|
|
get_runnable_schema(db, w_id, &http_route.script_path, http_route.is_flow).await
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let future_path = FuturePath::new(
|
|
route_path,
|
|
Kind::HttpRoute(HttpRouteConfig::new(method)),
|
|
Some(http_route.request_type),
|
|
http_route.summary,
|
|
http_route.description,
|
|
auth_method,
|
|
args_schema,
|
|
);
|
|
|
|
openapi_future_paths.push(future_path);
|
|
}
|
|
|
|
Ok(openapi_future_paths)
|
|
}
|
|
|
|
async fn webhook_to_future_paths(
|
|
db: &DB,
|
|
pg_pool: &mut PgConnection,
|
|
webhook_filters: Option<&[WebhookFilter]>,
|
|
w_id: &str,
|
|
) -> Result<Vec<FuturePath>> {
|
|
let mut openapi_future_paths = Vec::new();
|
|
if let Some(webhook_filters) = webhook_filters {
|
|
let mut script_webhook_filter = Vec::new();
|
|
let mut flow_webhook_filter = Vec::new();
|
|
|
|
for webhook in webhook_filters {
|
|
let full_regex = transform_to_minified_postgres_regex(&format!(
|
|
"{}/{}/{}",
|
|
&webhook.user_or_folder_regex, &webhook.user_or_folder_regex_value, &webhook.path
|
|
));
|
|
|
|
match webhook.runnable_kind {
|
|
RunnableKind::Script => {
|
|
script_webhook_filter.push(full_regex);
|
|
}
|
|
RunnableKind::Flow => {
|
|
flow_webhook_filter.push(full_regex);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
struct MinifiedWebhook {
|
|
path: String,
|
|
description: Option<String>,
|
|
summary: Option<String>,
|
|
}
|
|
|
|
let webhook_scripts = sqlx::query_as!(
|
|
MinifiedWebhook,
|
|
r#"SELECT
|
|
path,
|
|
summary,
|
|
description
|
|
FROM
|
|
script
|
|
WHERE
|
|
path ~ ANY($1) AND
|
|
workspace_id = $2 AND
|
|
archived is FALSE
|
|
"#,
|
|
&script_webhook_filter,
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *pg_pool)
|
|
.await?;
|
|
|
|
let webhook_flows = sqlx::query_as!(
|
|
MinifiedWebhook,
|
|
r#"SELECT
|
|
path,
|
|
summary,
|
|
description
|
|
FROM
|
|
flow
|
|
WHERE
|
|
path ~ ANY($1) AND
|
|
workspace_id = $2 AND
|
|
archived is FALSE
|
|
"#,
|
|
&flow_webhook_filter,
|
|
&w_id
|
|
)
|
|
.fetch_all(&mut *pg_pool)
|
|
.await?;
|
|
|
|
openapi_future_paths.reserve_exact(webhook_scripts.len() + webhook_flows.len());
|
|
|
|
for webhook in webhook_scripts {
|
|
let args_schema = get_runnable_schema(db, w_id, &webhook.path, false).await;
|
|
openapi_future_paths.push(FuturePath::new(
|
|
webhook.path,
|
|
Kind::Webhook(WebhookConfig::new(RunnableKind::Script)),
|
|
None,
|
|
webhook.summary,
|
|
webhook.description,
|
|
Some(SecurityScheme::BearerJwt),
|
|
args_schema,
|
|
));
|
|
}
|
|
|
|
for webhook in webhook_flows {
|
|
let args_schema = get_runnable_schema(db, w_id, &webhook.path, true).await;
|
|
openapi_future_paths.push(FuturePath::new(
|
|
webhook.path,
|
|
Kind::Webhook(WebhookConfig::new(RunnableKind::Flow)),
|
|
None,
|
|
webhook.summary,
|
|
webhook.description,
|
|
Some(SecurityScheme::BearerJwt),
|
|
args_schema,
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(openapi_future_paths)
|
|
}
|
|
|
|
async fn generate_openapi_future_path(
|
|
db: &DB,
|
|
user_db: UserDB,
|
|
authed: &ApiAuthed,
|
|
http_route_filters: Option<&[HttpRouteFilter]>,
|
|
webhook_filters: Option<&[WebhookFilter]>,
|
|
w_id: &str,
|
|
) -> Result<Vec<FuturePath>> {
|
|
if http_route_filters.is_none() && webhook_filters.is_none() {
|
|
return Err(Error::BadRequest(
|
|
"Expected http route filter and/or webhook filters".to_string(),
|
|
));
|
|
}
|
|
|
|
let mut tx = user_db.clone().begin(authed).await?;
|
|
|
|
let mut openapi_future_paths =
|
|
http_routes_to_future_paths(db, user_db, authed, &mut tx, http_route_filters, w_id).await?;
|
|
|
|
openapi_future_paths
|
|
.append(&mut webhook_to_future_paths(db, &mut tx, webhook_filters, w_id).await?);
|
|
|
|
tx.commit().await?;
|
|
|
|
if openapi_future_paths.is_empty() {
|
|
return Err(Error::NotFound(
|
|
"No match for the current filter".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(openapi_future_paths)
|
|
}
|
|
|
|
async fn generate_openapi_spec(
|
|
Extension(authed): Extension<ApiAuthed>,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(w_id): Path<String>,
|
|
Json(generate_openapi): Json<GenerateOpenAPI>,
|
|
) -> Result<String> {
|
|
let openapi_future_paths = generate_openapi_future_path(
|
|
&db,
|
|
user_db,
|
|
&authed,
|
|
generate_openapi.http_route_filters.as_deref(),
|
|
generate_openapi.webhook_filters.as_deref(),
|
|
&w_id,
|
|
)
|
|
.await?;
|
|
|
|
let openapi_document = generate_openapi_document(
|
|
generate_openapi.info.as_ref(),
|
|
generate_openapi.url.as_ref(),
|
|
openapi_future_paths,
|
|
generate_openapi.openapi_spec_format,
|
|
);
|
|
|
|
openapi_document
|
|
}
|
|
|
|
async fn download_spec(
|
|
Extension(authed): Extension<ApiAuthed>,
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Path(w_id): Path<String>,
|
|
Json(generate_openapi): Json<GenerateOpenAPI>,
|
|
) -> Result<Response> {
|
|
let openapi_future_paths = generate_openapi_future_path(
|
|
&db,
|
|
user_db,
|
|
&authed,
|
|
generate_openapi.http_route_filters.as_deref(),
|
|
generate_openapi.webhook_filters.as_deref(),
|
|
&w_id,
|
|
)
|
|
.await?;
|
|
|
|
let openapi_document = generate_openapi_document(
|
|
generate_openapi.info.as_ref(),
|
|
generate_openapi.url.as_ref(),
|
|
openapi_future_paths,
|
|
generate_openapi.openapi_spec_format,
|
|
)?;
|
|
|
|
let response = Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/octet-stream"),
|
|
)
|
|
.body(Body::from(openapi_document))
|
|
.unwrap();
|
|
|
|
Ok(response)
|
|
}
|