Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion crates/hypercolor-daemon/src/api/openapi.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(clippy::needless_for_each)]

use axum::routing::MethodRouter;
use utoipa::openapi::path::{OperationBuilder, ParameterBuilder, ParameterIn, Paths};
use utoipa::openapi::path::{OperationBuilder, Parameter, ParameterBuilder, ParameterIn, Paths};
use utoipa::openapi::request_body::RequestBodyBuilder;
use utoipa::openapi::schema::{ObjectBuilder, Schema, Type};
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
Expand Down Expand Up @@ -123,6 +123,7 @@ pub(crate) struct OperationDoc {
request_schema: Option<SchemaRegistrar>,
additional_schemas: Vec<SchemaRegistrar>,
query: Option<ParameterProvider>,
headers: Vec<Parameter>,
}

impl OperationDoc {
Expand Down Expand Up @@ -207,6 +208,7 @@ impl OperationDoc {
request_schema: None,
additional_schemas: Vec::new(),
query: None,
headers: Vec::new(),
}
}

Expand Down Expand Up @@ -255,6 +257,19 @@ impl OperationDoc {
self
}

pub(crate) fn required_header(mut self, name: &'static str, description: &'static str) -> Self {
self.headers.push(
ParameterBuilder::new()
.name(name)
.parameter_in(ParameterIn::Header)
.required(Required::True)
.description(Some(description))
.schema(Some(ObjectBuilder::new().schema_type(Type::String)))
.build(),
);
self
}

pub(crate) fn component<T: utoipa::ToSchema>(mut self) -> Self {
self.additional_schemas.push(register_schema::<T>);
self
Expand Down Expand Up @@ -320,6 +335,9 @@ fn operation(path: &str, document: OperationDoc) -> utoipa::openapi::path::Opera
builder = builder.parameter(parameter);
}
}
for header in document.headers {
builder = builder.parameter(header);
}

if let Some((schema, required)) = document.request {
builder = builder.request_body(Some(
Expand Down
16 changes: 16 additions & 0 deletions crates/hypercolor-daemon/src/api/routes/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ pub(super) fn router() -> OpenApiRouter<Arc<AppState>> {
.body::<hypercolor_types::api::scene::ZoneLayoutRequest>(),
],
))
.routes(openapi::documented_route(
"/scene/members/edit",
axum::routing::post(scene::edit_members),
[
OperationDoc::post::<hypercolor_types::api::scene::EditMembersResponse>(
"edit_live_scene_members",
"scenes",
"Atomically edit live scene memberships",
)
.body::<hypercolor_types::api::scene::EditMembersRequest>()
.required_header(
"If-Match",
"Last observed scene revision, as a quoted or bare integer. The wildcard * is not accepted.",
),
],
))
.routes(openapi::documented_route(
"/scene/zones/{zone}/members",
axum::routing::post(scene::assign_members),
Expand Down
23 changes: 23 additions & 0 deletions crates/hypercolor-daemon/src/api/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,29 @@ pub async fn unassign_member(

// ── Layers ───────────────────────────────────────────────────────────────

/// `POST /api/v1/scene/members/edit` applies a reversible membership transaction.
pub async fn edit_members(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<hypercolor_types::api::scene::EditMembersRequest>,
) -> Response {
let expected = match parse_if_match(&headers) {
Ok(Some(expected)) => expected,
Ok(None) => {
return DomainError::validation("If-Match is required for membership edits")
.into_response();
}
Err(error) => return error.into_response(),
};
match scene_tree::edit_members(&state.domains.scene_tree, body, expected).await {
Ok(outcome) => {
let revision = outcome.document.revision;
with_revision(envelope::ok(outcome), revision)
}
Err(error) => error.into_response(),
}
}

/// `GET /api/v1/scene/zones/{zone}/layers` — the zone's stack.
pub async fn list_layers(State(state): State<Arc<AppState>>, Path(zone): Path<String>) -> Response {
let zone_id = match parse_zone_id(&zone) {
Expand Down
2 changes: 1 addition & 1 deletion crates/hypercolor-daemon/src/domain/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -996,7 +996,7 @@ pub(crate) fn validate_layout_sampling_radii(layout: &SpatialLayout) -> Result<(
Ok(())
}

fn validate_output_sampling_radii(output: &Output) -> Result<(), DomainError> {
pub(crate) fn validate_output_sampling_radii(output: &Output) -> Result<(), DomainError> {
validate_output_sampling_radii_text(output).map_err(DomainError::validation)
}

Expand Down
3 changes: 3 additions & 0 deletions crates/hypercolor-daemon/src/domain/scene_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ use crate::domain::output::OutputContext;
use crate::domain::scene::SceneMutation;
use crate::domain::{DomainError, DomainErrorDetails, MutationContext, ResourceKind};

mod member_edits;
pub use member_edits::edit_members;

/// Live scene-tree authority shared by REST and MCP adapters.
#[derive(Clone)]
pub struct SceneTreeContext {
Expand Down
Loading