From f8c6200dae17f5b1e802ea4f76510023f46c50cd Mon Sep 17 00:00:00 2001 From: Cam Reeves Date: Mon, 8 Sep 2025 12:55:12 +1000 Subject: [PATCH 1/2] Add tag filtering support to metadata children endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tags parameter to children_metadata method with array converter - Filter child zones by tags using Set intersection logic - Support combining tag and name filters (zones must match both) - Update OpenAPI documentation with tags parameter specification - Add comprehensive test coverage for all tag filtering scenarios - Fix test script to use docker-compose syntax for compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- OPENAPI_DOC.yml | 12 +- spec/controllers/metadata_spec.cr | 170 +++++++++++++++++++ src/placeos-rest-api/controllers/metadata.cr | 34 +++- test | 10 +- 4 files changed, 217 insertions(+), 9 deletions(-) diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 7683a6d8..06fe9222 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -10757,7 +10757,9 @@ paths: Filter for a specific metadata by name via `name` param. - Includes the parent metadata by default via `include_parent` param.' + Includes the parent metadata by default via `include_parent` param. + + Filter zones by tags via `tags` param.' tags: - Metadata operationId: PlaceOS::Api::Metadata_children_metadata @@ -10781,6 +10783,14 @@ paths: schema: type: string nullable: true + - name: tags + in: query + description: return zones with particular tags + example: building,level + schema: + type: array + items: + type: string responses: 200: description: OK diff --git a/spec/controllers/metadata_spec.cr b/spec/controllers/metadata_spec.cr index 618914f2..8458c6d6 100644 --- a/spec/controllers/metadata_spec.cr +++ b/spec/controllers/metadata_spec.cr @@ -56,6 +56,176 @@ module PlaceOS::Api parent.destroy end + + it "filters zone children by single tag" do + parent = Model::Generator.zone.save! + parent_id = parent.id.as(String) + + # Create children with different tags + child1 = Model::Generator.zone + child1.parent_id = parent_id + child1.tags = Set{"building", "level1"} + child1.save! + Model::Generator.metadata(parent: child1.id).save! + + child2 = Model::Generator.zone + child2.parent_id = parent_id + child2.tags = Set{"room", "level2"} + child2.save! + Model::Generator.metadata(parent: child2.id).save! + + child3 = Model::Generator.zone + child3.parent_id = parent_id + child3.tags = Set{"building", "lobby"} + child3.save! + Model::Generator.metadata(parent: child3.id).save! + + # Test filtering by "building" tag - should return child1 and child3 + result = client.get( + path: "#{Metadata.base_route}/#{parent_id}/children?tags=building", + headers: Spec::Authentication.headers, + ) + + children_result = Array(NamedTuple(zone: JSON::Any, metadata: Hash(String, Model::Metadata::Interface))) + .from_json(result.body) + children_result.size.should eq 2 + + # Verify the correct zones are returned + zone_ids = children_result.map(&.[:zone]["id"].as_s) + zone_ids.should contain(child1.id) + zone_ids.should contain(child3.id) + + parent.destroy + end + + it "filters zone children by multiple tags" do + parent = Model::Generator.zone.save! + parent_id = parent.id.as(String) + + # Create children with different tags + child1 = Model::Generator.zone + child1.parent_id = parent_id + child1.tags = Set{"building", "level1"} + child1.save! + Model::Generator.metadata(parent: child1.id).save! + + child2 = Model::Generator.zone + child2.parent_id = parent_id + child2.tags = Set{"room", "level2"} + child2.save! + Model::Generator.metadata(parent: child2.id).save! + + child3 = Model::Generator.zone + child3.parent_id = parent_id + child3.tags = Set{"building", "lobby"} + child3.save! + Model::Generator.metadata(parent: child3.id).save! + + # Test filtering by multiple tags - should return zones with either tag + result = client.get( + path: "#{Metadata.base_route}/#{parent_id}/children?tags=building,room", + headers: Spec::Authentication.headers, + ) + + children_result = Array(NamedTuple(zone: JSON::Any, metadata: Hash(String, Model::Metadata::Interface))) + .from_json(result.body) + children_result.size.should eq 3 + + parent.destroy + end + + it "returns empty result when no zones match tag filter" do + parent = Model::Generator.zone.save! + parent_id = parent.id.as(String) + + # Create children with tags that don't match the filter + child1 = Model::Generator.zone + child1.parent_id = parent_id + child1.tags = Set{"building", "level1"} + child1.save! + Model::Generator.metadata(parent: child1.id).save! + + child2 = Model::Generator.zone + child2.parent_id = parent_id + child2.tags = Set{"room", "level2"} + child2.save! + Model::Generator.metadata(parent: child2.id).save! + + # Test filtering by non-existent tag + result = client.get( + path: "#{Metadata.base_route}/#{parent_id}/children?tags=nonexistent", + headers: Spec::Authentication.headers, + ) + + children_result = Array(NamedTuple(zone: JSON::Any, metadata: Hash(String, Model::Metadata::Interface))) + .from_json(result.body) + children_result.size.should eq 0 + + parent.destroy + end + + it "returns all zones when no tag filter is provided" do + parent = Model::Generator.zone.save! + parent_id = parent.id.as(String) + + # Create children with tags + 3.times do |i| + child = Model::Generator.zone + child.parent_id = parent_id + child.tags = Set{"tag#{i}"} + child.save! + Model::Generator.metadata(parent: child.id).save! + end + + # Test without tag filter - should return all zones + result = client.get( + path: "#{Metadata.base_route}/#{parent_id}/children", + headers: Spec::Authentication.headers, + ) + + children_result = Array(NamedTuple(zone: JSON::Any, metadata: Hash(String, Model::Metadata::Interface))) + .from_json(result.body) + children_result.size.should eq 3 + + parent.destroy + end + + it "combines tag filter with name filter" do + parent = Model::Generator.zone.save! + parent_id = parent.id.as(String) + + # Create children with tags + child1 = Model::Generator.zone + child1.parent_id = parent_id + child1.tags = Set{"building"} + child1.save! + Model::Generator.metadata(name: "special", parent: child1.id).save! + + child2 = Model::Generator.zone + child2.parent_id = parent_id + child2.tags = Set{"building"} + child2.save! + Model::Generator.metadata(name: "regular", parent: child2.id).save! + + child3 = Model::Generator.zone + child3.parent_id = parent_id + child3.tags = Set{"room"} + child3.save! + Model::Generator.metadata(name: "special", parent: child3.id).save! + + # Test combining tag and name filters - should return only child1 + result = client.get( + path: "#{Metadata.base_route}/#{parent_id}/children?tags=building&name=special", + headers: Spec::Authentication.headers, + ) + + children_result = Array(NamedTuple(zone: JSON::Any, metadata: Hash(String, Model::Metadata::Interface))) + .from_json(result.body) + children_result.size.should eq 1 + children_result.first[:zone]["id"].as_s.should eq child1.id + + parent.destroy + end end describe "PUT /metadata" do diff --git a/src/placeos-rest-api/controllers/metadata.cr b/src/placeos-rest-api/controllers/metadata.cr index 0164617b..a45656e8 100644 --- a/src/placeos-rest-api/controllers/metadata.cr +++ b/src/placeos-rest-api/controllers/metadata.cr @@ -60,7 +60,8 @@ module PlaceOS::Api # # Filter for a specific metadata by name via `name` param. # Includes the parent metadata by default via `include_parent` param. - @[AC::Route::GET("/:id/children")] + # Filter zones by tags via `tags` param. + @[AC::Route::GET("/:id/children", converters: {tags: ConvertStringArray})] def children_metadata( @[AC::Param::Info(name: "id", description: "the parent id of the metadata to be returned")] parent_id : String, @@ -68,6 +69,8 @@ module PlaceOS::Api include_parent : Bool = true, @[AC::Param::Info(description: "filter for a particular metadata key", example: "config")] name : String? = nil, + @[AC::Param::Info(description: "return zones with particular tags", example: "building,level")] + tags : Array(String)? = nil, ) : Array(Children) # Guest JWTs include the control system id that they have access to if user_token.guest_scope? @@ -76,8 +79,33 @@ module PlaceOS::Api Log.context.set(zone_id: parent_id) current_zone = ::PlaceOS::Model::Zone.find!(parent_id) - current_zone.children.all.compact_map do |zone| - Children.new(zone, name) if include_parent || zone.id != parent_id + + # Get children zones with optional tag filtering + children_zones = if (filter_tags = tags) && !filter_tags.empty? + # Start with all children zones + zones = current_zone.children.to_a + + # Filter zones that contain any of the filter tags + zones.select do |zone| + zone_tags = zone.tags || Set(String).new + filter_tags.any? { |tag| zone_tags.includes?(tag) } + end + else + current_zone.children.all.to_a + end + + children_zones.compact_map do |zone| + next unless include_parent || zone.id != parent_id + + children_obj = Children.new(zone, name) + + # If name filter is provided and tags filter is also provided, + # only include zones that have metadata matching the name filter + if name && tags && !tags.empty? + next if children_obj.metadata.empty? + end + + children_obj end end diff --git a/test b/test index 815052cd..9655a8d8 100755 --- a/test +++ b/test @@ -5,7 +5,7 @@ set -eu # this function is called when Ctrl-C is sent function trap_ctrlc () { - docker compose down &> /dev/null + docker-compose down &> /dev/null exit 2 } @@ -13,17 +13,17 @@ function trap_ctrlc () # when signal 2 (SIGINT) is received trap "trap_ctrlc" 2 -docker compose pull +docker-compose pull -docker compose build +docker-compose build exit_code="0" -docker compose run \ +docker-compose run \ --rm \ test "$@" \ || exit_code="$?" -docker compose down &> /dev/null +docker-compose down &> /dev/null exit ${exit_code} From 443bede13d18eda9a010ca1b365e09dbb3d12e64 Mon Sep 17 00:00:00 2001 From: Cam Reeves Date: Mon, 8 Sep 2025 13:10:48 +1000 Subject: [PATCH 2/2] Revert changes to test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test script changes were unintended and should not be included in this feature branch. Reverting to original docker compose syntax. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- test | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test b/test index 9655a8d8..815052cd 100755 --- a/test +++ b/test @@ -5,7 +5,7 @@ set -eu # this function is called when Ctrl-C is sent function trap_ctrlc () { - docker-compose down &> /dev/null + docker compose down &> /dev/null exit 2 } @@ -13,17 +13,17 @@ function trap_ctrlc () # when signal 2 (SIGINT) is received trap "trap_ctrlc" 2 -docker-compose pull +docker compose pull -docker-compose build +docker compose build exit_code="0" -docker-compose run \ +docker compose run \ --rm \ test "$@" \ || exit_code="$?" -docker-compose down &> /dev/null +docker compose down &> /dev/null exit ${exit_code}