From b056fc683a61d08b3c2be2eab4199e5543342ab9 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Wed, 4 Feb 2026 10:14:50 +0800 Subject: [PATCH] feat: [PPT-2341] Add bulk retrieval endpoint to metadata controller --- OPENAPI_DOC.yml | 134 +++++++++++++++++++ shard.lock | 2 +- spec/controllers/metadata_spec.cr | 78 +++++++++++ src/placeos-rest-api/controllers/metadata.cr | 32 ++++- 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 41e9b29d..5ccf539e 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -12619,6 +12619,90 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' + /api/engine/v2/metadata/{name}/bulk: + get: + summary: Fetch metadata with a specific name for multiple resources + description: 'Fetch metadata with a specific name for multiple resources + + + Allows bulk retrieval of metadata across multiple parent entities' + tags: + - Metadata + operationId: PlaceOS::Api::Metadata_bulk_fetch + parameters: + - name: name + in: path + description: the name of the metadata key to fetch + example: settings + required: true + schema: + type: string + - name: parent_ids + in: query + description: comma-separated list of parent IDs (zones, systems, users, etc.) + example: zone-1,zone-2,zone-3 + required: true + schema: + type: array + items: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Hash_String__PlaceOS__Model__Metadata__Interface___Nil_' + 409: + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 408: + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ParameterError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ParameterError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' /api/engine/v2/metadata/{id}: get: summary: Fetch metadata for a model @@ -25362,6 +25446,9 @@ components: type: integer format: Int64 nullable: true + launch_on_execute: + type: boolean + nullable: true control_system_id: type: string nullable: true @@ -26263,6 +26350,9 @@ components: type: integer format: Int64 nullable: true + launch_on_execute: + type: boolean + nullable: true control_system_id: type: string nullable: true @@ -27525,6 +27615,44 @@ components: type: object additionalProperties: type: object + Hash_String__PlaceOS__Model__Metadata__Interface___Nil_: + type: object + additionalProperties: + type: object + properties: + name: + type: string + description: + type: string + details: + type: object + parent_id: + type: string + nullable: true + schema_id: + type: string + nullable: true + editors: + type: array + items: + type: string + modified_by_id: + type: string + nullable: true + updated_at: + type: integer + format: Int64 + created_at: + type: integer + format: Int64 + required: + - name + - description + - details + - editors + - updated_at + - created_at + nullable: true PlaceOS__Api__Metadata__Children: type: object properties: @@ -28987,6 +29115,9 @@ components: type: integer format: Int64 nullable: true + launch_on_execute: + type: boolean + nullable: true control_system_id: type: string nullable: true @@ -31722,6 +31853,9 @@ components: type: integer format: Int64 nullable: true + launch_on_execute: + type: boolean + nullable: true control_system_id: type: string nullable: true diff --git a/shard.lock b/shard.lock index 45a0f1cb..d26ab4c9 100644 --- a/shard.lock +++ b/shard.lock @@ -219,7 +219,7 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.82.0 + version: 9.82.1 placeos-resource: git: https://github.com/place-labs/resource.git diff --git a/spec/controllers/metadata_spec.cr b/spec/controllers/metadata_spec.cr index 618914f2..5cb61ed7 100644 --- a/spec/controllers/metadata_spec.cr +++ b/spec/controllers/metadata_spec.cr @@ -238,6 +238,84 @@ module PlaceOS::Api end end + describe "GET /metadata/:name/bulk" do + it "fetches metadata with specific name for multiple resources" do + # Create multiple zones with the same metadata name + zone1 = Model::Generator.zone.save! + zone2 = Model::Generator.zone.save! + zone3 = Model::Generator.zone.save! + + zone1_id = zone1.id.as(String) + zone2_id = zone2.id.as(String) + zone3_id = zone3.id.as(String) + + # Create metadata with name "settings" for each zone + meta1 = Model::Generator.metadata(name: "settings", parent: zone1_id).save! + meta2 = Model::Generator.metadata(name: "settings", parent: zone2_id).save! + # zone3 intentionally has no metadata + + result = client.get( + path: "#{Metadata.base_route}/settings/bulk?parent_ids=#{zone1_id},#{zone2_id},#{zone3_id}", + headers: Spec::Authentication.headers, + ) + + result.success?.should be_true + metadata = Hash(String, Model::Metadata::Interface?).from_json(result.body) + + metadata.size.should eq 3 + metadata[zone1_id].should_not be_nil + metadata[zone1_id].not_nil!.name.should eq "settings" + metadata[zone2_id].should_not be_nil + metadata[zone2_id].not_nil!.name.should eq "settings" + metadata[zone3_id].should be_nil + end + + it "returns empty metadata for resources without the specified metadata" do + zone1 = Model::Generator.zone.save! + zone2 = Model::Generator.zone.save! + + zone1_id = zone1.id.as(String) + zone2_id = zone2.id.as(String) + + # Only zone1 has the metadata + Model::Generator.metadata(name: "config", parent: zone1_id).save! + + result = client.get( + path: "#{Metadata.base_route}/config/bulk?parent_ids=#{zone1_id},#{zone2_id}", + headers: Spec::Authentication.headers, + ) + + result.success?.should be_true + metadata = Hash(String, Model::Metadata::Interface?).from_json(result.body) + + metadata.size.should eq 2 + metadata[zone1_id].should_not be_nil + metadata[zone2_id].should be_nil + end + + it "allows guests to fetch bulk metadata" do + _, scoped_headers = Spec::Authentication.authentication(scope: [PlaceOS::Model::UserJWT::Scope.new("guest", PlaceOS::Model::UserJWT::Scope::Access::Read)]) + + zone1 = Model::Generator.zone.save! + zone2 = Model::Generator.zone.save! + + zone1_id = zone1.id.as(String) + zone2_id = zone2.id.as(String) + + Model::Generator.metadata(name: "settings", parent: zone1_id).save! + Model::Generator.metadata(name: "settings", parent: zone2_id).save! + + result = client.get( + path: "#{Metadata.base_route}/settings/bulk?parent_ids=#{zone1_id},#{zone2_id}", + headers: scoped_headers, + ) + + result.success?.should be_true + metadata = Hash(String, Model::Metadata::Interface?).from_json(result.body) + metadata.size.should eq 2 + end + end + describe "GET /metadata/:id" do it "shows control_system metadata" do control_system = Model::Generator.control_system.save! diff --git a/src/placeos-rest-api/controllers/metadata.cr b/src/placeos-rest-api/controllers/metadata.cr index aa778112..c0f07206 100644 --- a/src/placeos-rest-api/controllers/metadata.cr +++ b/src/placeos-rest-api/controllers/metadata.cr @@ -12,7 +12,7 @@ module PlaceOS::Api ############################################################################################### before_action :can_read, only: [:history] - before_action :can_read_guest, only: [:show, :children_metadata] + before_action :can_read_guest, only: [:show, :children_metadata, :bulk_fetch] before_action :can_write, only: [:update, :destroy] # Callbacks @@ -30,6 +30,36 @@ module PlaceOS::Api ############################################################################################### + # Fetch metadata with a specific name for multiple resources + # + # Allows bulk retrieval of metadata across multiple parent entities + @[AC::Route::GET("/:name/bulk", converters: {parent_ids: ConvertStringArray})] + def bulk_fetch( + @[AC::Param::Info(name: "name", description: "the name of the metadata key to fetch", example: "settings")] + metadata_name : String, + @[AC::Param::Info(description: "comma-separated list of parent IDs (zones, systems, users, etc.)", example: "zone-1,zone-2,zone-3")] + parent_ids : Array(String), + ) : Hash(String, ::PlaceOS::Model::Metadata::Interface?) + # Guest JWTs have restricted access + if user_token.guest_scope? + allowed_ids = guest_ids + # Ensure all requested IDs are in the guest's allowed list + unless parent_ids.all? { |id| allowed_ids.includes?(id) } + raise Error::Forbidden.new + end + end + + # Build result hash with parent_id as key and metadata as value + result = {} of String => ::PlaceOS::Model::Metadata::Interface? + + parent_ids.each do |parent_id| + metadata = ::PlaceOS::Model::Metadata.for(parent_id, metadata_name).first? + result[parent_id] = metadata ? metadata.interface : nil + end + + result + end + # Fetch metadata for a model # # Filter for a specific metadata by name via `name` param