feat: migrate Repository REST to generated Server API
This commit is contained in:
@@ -1629,11 +1629,31 @@ fn signed_request_target(uri: &Uri) -> &str {
|
||||
.unwrap_or_else(|| uri.path())
|
||||
}
|
||||
|
||||
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
|
||||
if path == "/api/repositories"
|
||||
|| path.starts_with("/api/repositories/")
|
||||
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
|
||||
{
|
||||
return (
|
||||
status,
|
||||
Json(server_api::RepositoryApiError::new(
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("error"),
|
||||
message,
|
||||
Vec::new(),
|
||||
)),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
status.into_response()
|
||||
}
|
||||
|
||||
async fn authorize_scoped_workspace_request(
|
||||
api: &WorkspaceServerApi,
|
||||
workspace_id: &str,
|
||||
request: &mut Request,
|
||||
) -> std::result::Result<(), Response> {
|
||||
let request_path = request.uri().path().to_owned();
|
||||
let proof = request
|
||||
.headers()
|
||||
.get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
@@ -1645,7 +1665,13 @@ async fn authorize_scoped_workspace_request(
|
||||
let body = std::mem::take(request.body_mut());
|
||||
let body = axum::body::to_bytes(body, 16 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?;
|
||||
.map_err(|_| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid request body",
|
||||
)
|
||||
})?;
|
||||
let digest = worker_runtime::auth::request_body_digest(&body);
|
||||
*request.body_mut() = axum::body::Body::from(body);
|
||||
let permission = if path
|
||||
@@ -1675,7 +1701,13 @@ async fn authorize_scoped_workspace_request(
|
||||
&digest,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
.map_err(|_| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid runtime request proof",
|
||||
)
|
||||
})?;
|
||||
request.extensions_mut().insert(source);
|
||||
if !matches!(
|
||||
*request.method(),
|
||||
@@ -1687,11 +1719,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.map_err(server_error_response)?
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return Err((
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1699,7 +1731,13 @@ async fn authorize_scoped_workspace_request(
|
||||
let actor = resolve_server_actor(api, request.headers())
|
||||
.await
|
||||
.map_err(server_error_response)?
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
.ok_or_else(|| {
|
||||
repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication required",
|
||||
)
|
||||
})?;
|
||||
|
||||
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
|
||||
let mutating = !matches!(
|
||||
@@ -1715,7 +1753,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
return Err(StatusCode::FORBIDDEN.into_response());
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-origin browser mutation denied",
|
||||
));
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
@@ -1727,11 +1769,11 @@ async fn authorize_scoped_workspace_request(
|
||||
.map_err(server_error_response)?
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return Err((
|
||||
return Err(repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response());
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1744,6 +1786,7 @@ async fn authorize_workspace_api_request(
|
||||
if !request.uri().path().starts_with("/api/") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let request_path = request.uri().path().to_owned();
|
||||
let public_server_api = is_server_global_forward(request.uri().path());
|
||||
let workspace_id = api.workspace_id().to_owned();
|
||||
let proof = request
|
||||
@@ -1756,7 +1799,11 @@ async fn authorize_workspace_api_request(
|
||||
let path = signed_request_target(request.uri()).to_owned();
|
||||
let body = std::mem::take(request.body_mut());
|
||||
let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid request body",
|
||||
);
|
||||
};
|
||||
let digest = worker_runtime::auth::request_body_digest(&body);
|
||||
*request.body_mut() = axum::body::Body::from(body);
|
||||
@@ -1787,7 +1834,11 @@ async fn authorize_workspace_api_request(
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid runtime request proof",
|
||||
);
|
||||
};
|
||||
request.extensions_mut().insert(source);
|
||||
if !matches!(
|
||||
@@ -1801,11 +1852,11 @@ async fn authorize_workspace_api_request(
|
||||
.flatten()
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return (
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response();
|
||||
);
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -1820,7 +1871,13 @@ async fn authorize_workspace_api_request(
|
||||
{
|
||||
Ok(Some(actor)) => actor,
|
||||
Ok(None) if public_server_api => return next.run(request).await,
|
||||
Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
Ok(None) | Err(_) => {
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication required",
|
||||
);
|
||||
}
|
||||
};
|
||||
let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession);
|
||||
let mutating = !matches!(
|
||||
@@ -1836,7 +1893,11 @@ async fn authorize_workspace_api_request(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::FORBIDDEN,
|
||||
"cross-origin browser mutation denied",
|
||||
);
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
@@ -1849,11 +1910,11 @@ async fn authorize_workspace_api_request(
|
||||
.flatten()
|
||||
.is_some_and(|workspace| workspace.state == "active")
|
||||
{
|
||||
return (
|
||||
return repository_api_rejection(
|
||||
&request_path,
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
)
|
||||
.into_response();
|
||||
);
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -1964,7 +2025,7 @@ async fn dispatch_workspace_request(
|
||||
}
|
||||
};
|
||||
let Some(router) = router else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found");
|
||||
};
|
||||
match router.oneshot(request).await {
|
||||
Ok(response) => response,
|
||||
@@ -3133,6 +3194,77 @@ impl server_api::ServerApi for WorkspaceApi {
|
||||
observation,
|
||||
})
|
||||
}
|
||||
|
||||
async fn repository_list(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_list_repositories(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_list_alias(
|
||||
&self,
|
||||
) -> std::result::Result<server_api::RepositoryListResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
list_repositories(State(self.clone()))
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_detail(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
repository_key: String,
|
||||
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_repository_detail(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedRepositoryPath {
|
||||
workspace_id,
|
||||
repository_key,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_detail_alias(
|
||||
&self,
|
||||
repository_key: String,
|
||||
) -> std::result::Result<server_api::RepositoryDetailResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
repository_detail(State(self.clone()), AxumPath(repository_key))
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn repository_create(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: CreateWorkspaceRepositoryRequest,
|
||||
) -> std::result::Result<CreateWorkspaceRepositoryResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_create_repository(
|
||||
State(self.clone()),
|
||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||
Extension(actor),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map(|(_status, Json(response))| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
@@ -3457,16 +3589,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}",
|
||||
delete(scoped_unlink_objective_ticket),
|
||||
)
|
||||
.route("/api/repositories", get(list_repositories))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories",
|
||||
get(scoped_list_repositories).post(scoped_create_repository),
|
||||
)
|
||||
.route("/api/repositories/{repository_key}", get(repository_detail))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}",
|
||||
get(scoped_repository_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test",
|
||||
post(scoped_probe_repository_ssh_connection)
|
||||
@@ -18894,127 +19016,142 @@ impl ApiError {
|
||||
fn with_diagnostics(error: Error, diagnostics: Vec<RuntimeDiagnostic>) -> Self {
|
||||
Self { error, diagnostics }
|
||||
}
|
||||
|
||||
fn into_repository_api_error(self) -> server_api::RepositoryApiError {
|
||||
let status = api_error_status(&self.error);
|
||||
let message = api_error_response_message(&self.error);
|
||||
server_api::RepositoryApiError::new(
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("error"),
|
||||
message,
|
||||
working_directory_diagnostics(self.diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn api_error_status(error: &Error) -> StatusCode {
|
||||
match error {
|
||||
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
Error::TicketAssignmentConflict(_)
|
||||
| Error::WorkdirAttachmentConflict(_)
|
||||
| Error::WorkspaceConfigConflict(_)
|
||||
| Error::RuntimeBindingConflict(_)
|
||||
| Error::RuntimeBindingRevisionConflict { .. }
|
||||
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::Ticket(ticket::TicketError::NotFound(_))
|
||||
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND,
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::MergeRequest(_) => StatusCode::CONFLICT,
|
||||
Error::Ticket(
|
||||
ticket::TicketError::Ambiguous { .. }
|
||||
| ticket::TicketError::Locked { .. }
|
||||
| ticket::TicketError::Conflict(_),
|
||||
) => StatusCode::CONFLICT,
|
||||
Error::Ticket(
|
||||
ticket::TicketError::InvalidPathComponent(_)
|
||||
| ticket::TicketError::PathEscapesRoot { .. },
|
||||
) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRecordId(_)
|
||||
| Error::MissingFrontmatter(_)
|
||||
| Error::UnknownHost(_)
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::RuntimeBindingNotFound { .. }
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "profile_registry_revision_conflict"
|
||||
|| code == "profile_source_revision_conflict"
|
||||
|| code == "workspace_metadata_revision_conflict"
|
||||
|| code == "workspace_cleanup_plan_stale"
|
||||
|| code == "workspace_cleanup_worker_blocked"
|
||||
|| code == "workspace_cleanup_workdir_blocked"
|
||||
|| code == "workspace_cleanup_worker_pinned" =>
|
||||
{
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "unknown_profile_source"
|
||||
|| code == "unknown_profile_selector"
|
||||
|| code == "unknown_objective" =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code.starts_with("workspace_settings_")
|
||||
|| code.starts_with("invalid_")
|
||||
|| code.starts_with("unsupported_worker_profile")
|
||||
|| code.starts_with("working_directory_")
|
||||
|| code.starts_with("workspace_cleanup_")
|
||||
|| code == "default_runtime_not_configured"
|
||||
|| code == "workspace_worker_workdir_required"
|
||||
|| code.ends_with("_already_exists")
|
||||
|| code.ends_with("_not_config_managed")
|
||||
|| code.ends_with("_unsupported") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "runtime_capacity_unavailable"
|
||||
|| code == "runtime_workdir_capacity_unavailable" =>
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn api_error_response_message(error: &Error) -> String {
|
||||
match error {
|
||||
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||
format!("{code}: {}", sanitize_backend_error(message))
|
||||
}
|
||||
_ => sanitize_backend_error(&error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self.error {
|
||||
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
Error::TicketAssignmentConflict(_)
|
||||
| Error::WorkdirAttachmentConflict(_)
|
||||
| Error::WorkspaceConfigConflict(_)
|
||||
| Error::RuntimeBindingConflict(_)
|
||||
| Error::RuntimeBindingRevisionConflict { .. }
|
||||
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::Ticket(ticket::TicketError::NotFound(_))
|
||||
| Error::MergeRequest(merge_request::MergeRequestError::NotFound) => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::MergeRequest(_) => StatusCode::CONFLICT,
|
||||
Error::Ticket(
|
||||
ticket::TicketError::Ambiguous { .. }
|
||||
| ticket::TicketError::Locked { .. }
|
||||
| ticket::TicketError::Conflict(_),
|
||||
) => StatusCode::CONFLICT,
|
||||
Error::Ticket(
|
||||
ticket::TicketError::InvalidPathComponent(_)
|
||||
| ticket::TicketError::PathEscapesRoot { .. },
|
||||
) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRecordId(_)
|
||||
| Error::MissingFrontmatter(_)
|
||||
| Error::UnknownHost(_)
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::RuntimeBindingNotFound { .. }
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => {
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "repository_provider_unsupported" =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "profile_registry_revision_conflict"
|
||||
|| code == "profile_source_revision_conflict"
|
||||
|| code == "workspace_metadata_revision_conflict"
|
||||
|| code == "workspace_cleanup_plan_stale"
|
||||
|| code == "workspace_cleanup_worker_blocked"
|
||||
|| code == "workspace_cleanup_workdir_blocked"
|
||||
|| code == "workspace_cleanup_worker_pinned" =>
|
||||
{
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "unknown_profile_source"
|
||||
|| code == "unknown_profile_selector"
|
||||
|| code == "unknown_objective" =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "workspace_display_name_invalid" || code.starts_with("profile_") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => {
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code.starts_with("workspace_settings_")
|
||||
|| code.starts_with("invalid_")
|
||||
|| code.starts_with("unsupported_worker_profile")
|
||||
|| code.starts_with("working_directory_")
|
||||
|| code.starts_with("workspace_cleanup_")
|
||||
|| code == "default_runtime_not_configured"
|
||||
|| code == "workspace_worker_workdir_required"
|
||||
|| code.ends_with("_already_exists")
|
||||
|| code.ends_with("_not_config_managed")
|
||||
|| code.ends_with("_unsupported") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "runtime_capacity_unavailable"
|
||||
|| code == "runtime_workdir_capacity_unavailable" =>
|
||||
{
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
|
||||
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let response_message = match &self.error {
|
||||
Error::RuntimeOperationFailed { code, message, .. } => {
|
||||
format!("{code}: {}", sanitize_backend_error(message))
|
||||
}
|
||||
_ => sanitize_backend_error(&self.error.to_string()),
|
||||
};
|
||||
let status = api_error_status(&self.error);
|
||||
let response_message = api_error_response_message(&self.error);
|
||||
let log = ApiErrorLog {
|
||||
kind: self
|
||||
.diagnostics
|
||||
@@ -19077,6 +19214,46 @@ mod tests {
|
||||
SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestBearerAuthorizer(&'static str);
|
||||
|
||||
impl server_api::client_support::RequestAuthorizer for TestBearerAuthorizer {
|
||||
fn authorize(
|
||||
&self,
|
||||
_request: server_api::client_support::AuthorizerRequest<'_>,
|
||||
) -> std::result::Result<
|
||||
axum::http::HeaderMap,
|
||||
server_api::client_support::AuthorizationError,
|
||||
> {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {}", self.0)
|
||||
.parse()
|
||||
.map_err(|_| server_api::client_support::AuthorizationError::new())?,
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_conflict_rejections_use_the_typed_error_contract() {
|
||||
let response = repository_api_rejection(
|
||||
"/api/w/workspace-test/repositories",
|
||||
StatusCode::CONFLICT,
|
||||
"Workspace is deleting and no longer accepts mutations",
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body: server_api::RepositoryApiError =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(body.error, "Conflict");
|
||||
assert_eq!(
|
||||
body.message,
|
||||
"Workspace is deleting and no longer accepts mutations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_resource_timeout_maps_to_gateway_timeout() {
|
||||
assert_eq!(
|
||||
@@ -22871,6 +23048,122 @@ mod tests {
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap()
|
||||
};
|
||||
let anonymous_repository = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&repositories_uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED);
|
||||
let anonymous_body: Value = serde_json::from_slice(
|
||||
&to_bytes(anonymous_repository.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(anonymous_body["error"], "Unauthorized");
|
||||
|
||||
let unknown_field = app
|
||||
.clone()
|
||||
.oneshot(create_repository(serde_json::json!({
|
||||
"repository_key": "unknown-field",
|
||||
"source": temp.path().join("unknown-field").display().to_string(),
|
||||
"unexpected": true
|
||||
})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let unknown_field_body: Value = serde_json::from_slice(
|
||||
&to_bytes(unknown_field.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unknown_field_body["error"], "Unprocessable Entity");
|
||||
|
||||
let malformed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.header(ORIGIN, &expected_origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(malformed.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(
|
||||
serde_json::from_slice::<server_api::RepositoryApiError>(
|
||||
&to_bytes(malformed.into_body(), usize::MAX).await.unwrap()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let unsupported_media_type = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.header(ORIGIN, &expected_origin)
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
unsupported_media_type.status(),
|
||||
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_slice::<server_api::RepositoryApiError>(
|
||||
&to_bytes(unsupported_media_type.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let oversized = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.header(ORIGIN, &expected_origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(vec![b' '; 5 * 1024 * 1024 + 1]))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert!(
|
||||
serde_json::from_slice::<server_api::RepositoryApiError>(
|
||||
&to_bytes(oversized.into_body(), usize::MAX).await.unwrap()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let created = app
|
||||
.clone()
|
||||
.oneshot(create_repository(repository_request.clone()))
|
||||
@@ -22960,6 +23253,93 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&listed_body).contains("documentation"));
|
||||
|
||||
let repository_detail_uri = format!("{repositories_uri}/documentation");
|
||||
let detailed = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(repository_detail_uri)
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detailed.status(), StatusCode::OK);
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("{repositories_uri}/missing"))
|
||||
.header(
|
||||
axum::http::header::COOKIE,
|
||||
"yoi_workspace_session=browser-session-auth",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing_body = to_bytes(missing.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
missing_status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&missing_body)
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let api_base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let network_app = app.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, network_app).await.unwrap();
|
||||
});
|
||||
let generated = server_api::ServerApiClient::builder(&api_base_url)
|
||||
.unwrap()
|
||||
.authorizer(TestBearerAuthorizer("api-token-auth"))
|
||||
.build()
|
||||
.unwrap();
|
||||
let generated_workspace_id = workspace.workspace.workspace_id.clone();
|
||||
let generated_list = generated
|
||||
.repository_list(generated_workspace_id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
generated_list
|
||||
.items
|
||||
.iter()
|
||||
.any(|repository| repository.repository_key == "documentation")
|
||||
);
|
||||
assert_eq!(
|
||||
generated
|
||||
.repository_detail(generated_workspace_id.clone(), "documentation".to_owned())
|
||||
.await
|
||||
.unwrap()
|
||||
.item
|
||||
.repository_key,
|
||||
"documentation"
|
||||
);
|
||||
let generated_request = CreateWorkspaceRepositoryRequest {
|
||||
repository_key: "generated-client".to_owned(),
|
||||
source: "/srv/repos/generated-client".to_owned(),
|
||||
default_ref: Some("main".to_owned()),
|
||||
};
|
||||
let created = generated
|
||||
.repository_create(generated_workspace_id.clone(), generated_request.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!created.replayed);
|
||||
let replayed = generated
|
||||
.repository_create(generated_workspace_id, generated_request)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(replayed.replayed);
|
||||
server.abort();
|
||||
|
||||
let identity_uri = format!(
|
||||
"/api/w/{}/settings/signing-identity",
|
||||
workspace.workspace.workspace_id
|
||||
@@ -23128,6 +23508,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let deleting_app = app.clone();
|
||||
let csrf_accepted = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -23145,6 +23526,48 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let preflight = store
|
||||
.workspace_deletion_preflight("account-auth", &workspace.workspace.workspace_id)
|
||||
.unwrap();
|
||||
store
|
||||
.reserve_workspace_deletion(
|
||||
"account-auth",
|
||||
&workspace.workspace.workspace_id,
|
||||
&server_api::WorkspaceDeletionRequest {
|
||||
operation_id: "repository-conflict-test".to_owned(),
|
||||
expected_revision: preflight.expected_revision,
|
||||
confirmation: preflight.display_name,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let deleting_conflict = deleting_app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(&repositories_uri)
|
||||
.header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"repository_key": "blocked-by-deletion",
|
||||
"source": temp.path().join("blocked-by-deletion").display().to_string()
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deleting_conflict.status(), StatusCode::CONFLICT);
|
||||
let deleting_body: server_api::RepositoryApiError = serde_json::from_slice(
|
||||
&to_bytes(deleting_conflict.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deleting_body.error, "Conflict");
|
||||
assert!(deleting_body.message.contains("deleting"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user