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