feat(hub-publish): add backend proxy routes for hub publishing

New workspaced router /api/w/:ws/hub/* forwarding to the Hub:
- POST /publish_draft → POST {HUB}/workspaces (slug/name/summary/readme)
- POST /scripts → POST {HUB}/scripts/add (workspace_slug + content)
- POST /flows | /apps | /raw_apps → corresponding hub endpoints
- POST /scripts/:ask_id/recording, /flows/:flow_id/recording → recording uploads

Auth uses HUB_DEV_TOKEN env var (dev shortcut). All bodies are
serde-typed; the helper forward_to_hub centralises the HTTP call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tristantr
2026-06-08 11:35:46 +02:00
co-authored by Claude Opus 4.7
parent cbc0b9d8cf
commit 5449288ffd
2 changed files with 190 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
use crate::HTTP_CLIENT;
use axum::{
extract::{Json, Path},
http::StatusCode,
response::IntoResponse,
routing::post,
Router,
};
use serde::{Deserialize, Serialize};
use windmill_common::{error::Error, HUB_BASE_URL};
pub fn workspaced_service() -> Router {
Router::new()
.route("/publish_draft", post(publish_draft))
.route("/scripts", post(publish_script))
.route("/flows", post(publish_flow))
.route("/apps", post(publish_app))
.route("/raw_apps", post(publish_raw_app))
.route(
"/scripts/{ask_id}/recording",
post(publish_script_recording),
)
.route("/flows/{flow_id}/recording", post(publish_flow_recording))
}
#[derive(Deserialize)]
struct PublishDraftBody {
slug: String,
name: String,
summary: String,
#[serde(default)]
readme: Option<String>,
}
#[derive(Serialize)]
struct HubWorkspaceBody {
slug: String,
name: String,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
readme: Option<String>,
}
async fn publish_draft(
Path(_workspace): Path<String>,
Json(body): Json<PublishDraftBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub(
"/workspaces",
&HubWorkspaceBody {
slug: body.slug,
name: body.name,
summary: body.summary,
readme: body.readme,
},
)
.await
}
#[derive(Deserialize, Serialize)]
struct PublishScriptBody {
summary: String,
app: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
content: String,
language: String,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
lockfile: Option<String>,
workspace_slug: String,
}
async fn publish_script(
Path(_workspace): Path<String>,
Json(body): Json<PublishScriptBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub("/scripts/add", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishFlowInner {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
value: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
}
#[derive(Deserialize, Serialize)]
struct PublishFlowBody {
flow: PublishFlowInner,
apps: Vec<String>,
workspace_slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
recording: Option<serde_json::Value>,
}
async fn publish_flow(
Path(_workspace): Path<String>,
Json(body): Json<PublishFlowBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub("/flows", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishAppBody {
app: serde_json::Value,
apps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
summary: String,
workspace_slug: String,
}
async fn publish_app(
Path(_workspace): Path<String>,
Json(body): Json<PublishAppBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub("/apps", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishRawAppBody {
raw: String,
apps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
external_embed_url: Option<String>,
workspace_slug: String,
}
async fn publish_raw_app(
Path(_workspace): Path<String>,
Json(body): Json<PublishRawAppBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub("/raw_apps", &body).await
}
#[derive(Deserialize, Serialize)]
struct RecordingBody {
#[serde(skip_serializing_if = "Option::is_none")]
recording: Option<serde_json::Value>,
workspace_slug: String,
}
async fn publish_script_recording(
Path((_workspace, ask_id)): Path<(String, i64)>,
Json(body): Json<RecordingBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub(&format!("/scripts/{}/recording", ask_id), &body).await
}
async fn publish_flow_recording(
Path((_workspace, flow_id)): Path<(String, i64)>,
Json(body): Json<RecordingBody>,
) -> Result<impl IntoResponse, Error> {
forward_to_hub(&format!("/flows/{}/recording", flow_id), &body).await
}
async fn forward_to_hub<T: Serialize>(path: &str, body: &T) -> Result<(StatusCode, String), Error> {
let hub_token = std::env::var("HUB_DEV_TOKEN")
.map_err(|_| Error::BadRequest("HUB_DEV_TOKEN not set".into()))?;
let url = format!("{}{}", **HUB_BASE_URL.load(), path);
let res = HTTP_CLIENT
.post(&url)
.bearer_auth(&hub_token)
.json(body)
.send()
.await
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let text = res
.text()
.await
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
Ok((status, text))
}
+2
View File
@@ -97,6 +97,7 @@ mod indexer_oss;
#[cfg(feature = "private")]
mod inkeep_ee;
mod inkeep_oss;
mod hub_publish;
mod integration;
mod internal_db;
mod live_migrations;
@@ -627,6 +628,7 @@ pub async fn run_server(
.nest("/volumes", volumes_oss::workspaced_service())
.nest("/workers", windmill_api_workers::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/hub", hub_publish::workspaced_service())
.nest(
"/deployment_request",
windmill_api_workspaces::deployment_requests::workspaced_service(),