Files
windmill/backend/windmill-api-openapi/src/lib.rs
T
hugocasa 37ebaf4d0a feat: add typed request body to OpenAPI spec generation (#8481)
* 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>
2026-03-24 09:29:06 +00:00

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)
}