From 79a867217c9d82f8f76532ca8067243e01272db3 Mon Sep 17 00:00:00 2001 From: Alex Nizamov Date: Tue, 16 Dec 2025 21:56:36 +0500 Subject: [PATCH] (#592) Containers API --- app/api/v1/base.rb | 3 + app/api/v1/containers.rb | 52 ++++++ app/models/envelope.rb | 2 + app/models/envelope_community.rb | 6 +- app/services/container_repository.rb | 37 ++++ lib/swagger_docs.rb | 2 + lib/swagger_docs/sections/containers.rb | 120 ++++++++++++ spec/api/v1/containers_spec.rb | 205 +++++++++++++++++++++ spec/factories/envelopes.rb | 4 + spec/factories/resources.rb | 39 ++++ spec/services/container_repository_spec.rb | 76 ++++++++ 11 files changed, 541 insertions(+), 5 deletions(-) create mode 100644 app/api/v1/containers.rb create mode 100644 app/services/container_repository.rb create mode 100644 lib/swagger_docs/sections/containers.rb create mode 100644 spec/api/v1/containers_spec.rb create mode 100644 spec/services/container_repository_spec.rb diff --git a/app/api/v1/base.rb b/app/api/v1/base.rb index a7146caa..f58a3f58 100644 --- a/app/api/v1/base.rb +++ b/app/api/v1/base.rb @@ -17,6 +17,7 @@ require 'v1/indexed_resources' require 'v1/indexer' require 'v1/envelope_communities' +require 'v1/containers' module API module V1 @@ -45,6 +46,7 @@ class Base < Grape::API mount API::V1::Ctdl.api_class mount API::V1::IndexedResources.api_class mount API::V1::Indexer.api_class + mount API::V1::Containers.api_class route_param :community_name do mount API::V1::Resources.api_class @@ -55,6 +57,7 @@ class Base < Grape::API mount API::V1::Ctdl.api_class mount API::V1::IndexedResources.api_class mount API::V1::Indexer.api_class + mount API::V1::Containers.api_class end namespace :metadata do diff --git a/app/api/v1/containers.rb b/app/api/v1/containers.rb new file mode 100644 index 00000000..781d4215 --- /dev/null +++ b/app/api/v1/containers.rb @@ -0,0 +1,52 @@ +require 'mountable_api' +require 'container_repository' +require 'helpers/shared_helpers' +require 'helpers/community_helpers' +require 'entities/envelope' + +module API + module V1 + # Implements the endpoints related to containers + class Containers < MountableAPI + mounted do # rubocop:todo Metrics/BlockLength + helpers CommunityHelpers + helpers SharedHelpers + + resource :containers do + before do + authenticate! + end + + route_param :container_ctid do + resource :resources do + before do + ctid = params[:container_ctid]&.downcase + + @envelope = current_community + .envelopes + .containers + .find_sole_by(envelope_ceterms_ctid: ctid) + + authorize @envelope, :update? + @repository = ContainerRepository.new(@envelope) + end + + desc "Appends URIs to the container's ceterms:hasMember" + patch do + @repository.add_member_uri(JSON.parse(request.body.read)) + present @envelope, with: API::Entities::Envelope + end + + desc "Removes URIs from the container's ceterms:hasMember" + delete do + @repository.remove_member_uris(JSON.parse(request.body.read)) + present @envelope, with: API::Entities::Envelope + end + + end + end + end + end + end + end +end diff --git a/app/models/envelope.rb b/app/models/envelope.rb index 43770bf1..e50b385a 100644 --- a/app/models/envelope.rb +++ b/app/models/envelope.rb @@ -60,6 +60,7 @@ class Envelope < ActiveRecord::Base RESOURCE_PUBLISH_TYPES = %w[primary secondary].freeze validates :resource_publish_type, inclusion: { in: RESOURCE_PUBLISH_TYPES, allow_blank: true } + scope :containers, -> { where(envelope_ctdl_type: CONTAINER_CTDL_TYPES) } scope :not_deleted, -> { where(deleted_at: nil) } scope :deleted, -> { where.not(deleted_at: nil) } scope :ordered_by_date, -> { order(created_at: :desc) } @@ -80,6 +81,7 @@ class Envelope < ActiveRecord::Base end } + CONTAINER_CTDL_TYPES = %w[ceterms:Collection].freeze NOT_FOUND = 'Envelope not found'.freeze DELETED = 'Envelope deleted'.freeze diff --git a/app/models/envelope_community.rb b/app/models/envelope_community.rb index 59e91aef..cdda1d52 100644 --- a/app/models/envelope_community.rb +++ b/app/models/envelope_community.rb @@ -100,10 +100,6 @@ def get_resource_type_from_values_map(cfg, envelope) end end - cfg['values_map'].fetch(key) do - raise MR::SchemaDoesNotExist, - "Cannot load json-schema. The property '#{cfg['property']}' " \ - "has an invalid value '#{key}'" - end + cfg['values_map'][key] end end diff --git a/app/services/container_repository.rb b/app/services/container_repository.rb new file mode 100644 index 00000000..a8343f60 --- /dev/null +++ b/app/services/container_repository.rb @@ -0,0 +1,37 @@ +# Manages subresources of a container +class ContainerRepository + attr_reader :envelope + + delegate :processed_resource, to: :envelope + + def initialize(envelope) + @envelope = envelope + end + + def add_member_uri(uris) + existing_uris = container['ceterms:hasMember'] || [] + container['ceterms:hasMember'] = (existing_uris + Array.wrap(uris)).uniq + update_envelope! + end + + def remove_member_uris(uris) + existing_uris = container['ceterms:hasMember'] || [] + container['ceterms:hasMember'] = existing_uris - Array.wrap(uris) + update_envelope! + end + + def container + @container ||= graph.find { it['@type'] == 'ceterms:Collection' } + end + + def graph + @graph ||= processed_resource['@graph'] + end + + def update_envelope! + envelope.update!(processed_resource:) + changed = envelope.previous_changes.any? + ExtractEnvelopeResourcesJob.perform_later(envelope.id) if changed + changed + end +end diff --git a/lib/swagger_docs.rb b/lib/swagger_docs.rb index 43a62102..3a995b2e 100644 --- a/lib/swagger_docs.rb +++ b/lib/swagger_docs.rb @@ -1,6 +1,7 @@ require 'ctdl_query' require 'swagger_docs/models' require 'swagger_docs/sections/admin' +require 'swagger_docs/sections/containers' require 'swagger_docs/sections/description_sets' require 'swagger_docs/sections/envelopes' require 'swagger_docs/sections/general' @@ -18,6 +19,7 @@ class SwaggerDocs include Models include Sections::General include Sections::Admin + include Sections::Containers include Sections::DescriptionSets include Sections::Envelopes include Sections::Graphs diff --git a/lib/swagger_docs/sections/containers.rb b/lib/swagger_docs/sections/containers.rb new file mode 100644 index 00000000..fbd360f3 --- /dev/null +++ b/lib/swagger_docs/sections/containers.rb @@ -0,0 +1,120 @@ +module MetadataRegistry + class SwaggerDocs + module Sections + # Swagger documentation for Containers API + module Containers + extend ActiveSupport::Concern + + included do + swagger_path '/{community_name}/containers/{container_ctid}/resources' do + operation :patch do # rubocop:todo Metrics/BlockLength + key :operationId, 'patchApiContainerResources' + key :description, "Appends one or more URIs to the container's ceterms:hasMember" + key :consumes, ['application/json'] + key :produces, ['application/json'] + key :tags, ['Containers'] + + security + + parameter community_name + parameter name: :container_ctid, + in: :path, + type: :string, + required: true, + description: 'CTID of the container' + + parameter do + key :name, :uris + key :in, :body + key :description, "URI(s) to append to the container's ceterms:hasMember. " \ + 'Can be a single URI or an array of URIs. ' \ + 'Duplicates are ignored.' + key :required, true + + schema do + key :type, :array + key :description, 'An array of resource URIs' + items do + key :type, :string + key :format, :uri + key :example, 'http://credentialengineregistry.org/resources/ce-abc123' + end + end + end + + response 200 do + key :description, 'Successfully added theURI(s) to the container' + schema { key :$ref, :Envelope } + end + + response 401 do + key :description, 'Unauthorized - authentication required' + end + + response 403 do + key :description, 'Forbidden - insufficient permissions' + end + + response 404 do + key :description, 'Container not found or not a collection type' + end + end + + operation :delete do # rubocop:todo Metrics/BlockLength + key :operationId, 'deleteApiContainerResources' + key :description, "Removes one or more URIs from the container's ceterms:hasMember" + key :consumes, ['application/json'] + key :produces, ['application/json'] + key :tags, ['Containers'] + + security + + parameter community_name + parameter name: :container_ctid, + in: :path, + type: :string, + required: true, + description: 'CTID of the container' + + parameter do + key :name, :uris + key :in, :body + key :description, "URI(s) to remove from the container's ceterms:hasMember. " \ + 'Can be a single URI or an array of URIs.' + key :required, true + + schema do + key :type, :array + key :description, 'An array of resource URIs' + items do + key :type, :string + key :format, :uri + key :example, 'http://credentialengineregistry.org/resources/ce-abc123' + end + end + end + + response 200 do + key :description, 'Successfully removed the URI(s) from the container' + schema { key :$ref, :Envelope } + end + + response 401 do + key :description, 'Unauthorized - authentication required' + end + + response 403 do + key :description, 'Forbidden - insufficient permissions' + end + + response 404 do + key :description, 'Container not found or not a collection type' + end + end + end + + end + end + end + end +end diff --git a/spec/api/v1/containers_spec.rb b/spec/api/v1/containers_spec.rb new file mode 100644 index 00000000..b731573f --- /dev/null +++ b/spec/api/v1/containers_spec.rb @@ -0,0 +1,205 @@ +RSpec.describe API::V1::Containers do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:container_ctid) { container.envelope_ceterms_ctid } + let(:envelope_ctdl_type) { 'ceterms:Collection' } + let(:subresource) { Faker::Json.shallow_json } + let(:user) { create(:user) } + let(:repository) { instance_double(ContainerRepository) } + + let(:container) do + create( + :envelope, + :with_graph_collection, + envelope_community:, + envelope_ctdl_type: + ) + end + + before do + allow(ContainerRepository).to receive(:new).with(container).and_return(repository) + end + + context 'with default community' do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:envelope_community) do + create(:envelope_community, name: 'ce_registry', default: true) + end + + # rubocop:todo RSpec/MultipleMemoizedHelpers + describe 'PATCH /containers/:container_ctid/resources' do + # rubocop:todo RSpec/NestedGroups + context 'when not authenticated' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups + # rubocop:enable RSpec/NestedGroups + it 'returns 401' do + patch "/containers/#{container_ctid}/resources", subresource + expect_status(:unauthorized) + end + end + + context 'when envelope is not a container type' do # rubocop:todo RSpec/NestedGroups + let(:envelope_ctdl_type) { 'ceterms:Credential' } + + it 'returns 404' do # rubocop:todo Layout/IndentationConsistency + patch "/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect_status(:not_found) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + # rubocop:todo RSpec/MultipleMemoizedHelpers + context 'when authenticated and container exists' do # rubocop:todo RSpec/NestedGroups + let(:parsed_subresource) { JSON.parse(subresource) } + + before do + allow(repository).to receive(:add_member_uri).with(parsed_subresource) + end + + it 'instantiates a repository and calls add_member_uri with the parsed resource' do + patch "/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect(repository).to have_received(:add_member_uri).with(parsed_subresource) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + end + + # rubocop:todo RSpec/MultipleMemoizedHelpers + describe 'DELETE /containers/:container_ctid/resources' do + # rubocop:todo RSpec/NestedGroups + context 'when not authenticated' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups + # rubocop:enable RSpec/NestedGroups + it 'returns 401' do + delete "/containers/#{container_ctid}/resources", subresource + expect_status(:unauthorized) + end + end + + context 'when envelope is not a container type' do # rubocop:todo RSpec/NestedGroups + let(:envelope_ctdl_type) { 'ceterms:Credential' } + + it 'returns 404' do # rubocop:todo Layout/IndentationConsistency + delete "/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect_status(:not_found) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + # rubocop:todo RSpec/MultipleMemoizedHelpers + context 'when authenticated and container exists' do # rubocop:todo RSpec/NestedGroups + let(:parsed_subresource) { JSON.parse(subresource) } + + before do + allow(repository).to receive(:remove_member_uris).with(parsed_subresource) + end + + it 'instantiates a repository and calls remove_member_uris with the parsed resource' do + delete "/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect(repository).to have_received(:remove_member_uris).with(parsed_subresource) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + end + end + + context 'with explicit community' do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:envelope_community) do + create(:envelope_community, name: 'navy') + end + + # rubocop:todo RSpec/MultipleMemoizedHelpers + describe 'PATCH /:community/containers/:container_ctid/resources' do + # rubocop:todo RSpec/NestedGroups + context 'when not authenticated' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups + # rubocop:enable RSpec/NestedGroups + it 'returns 401' do + patch "/navy/containers/#{container_ctid}/resources", subresource + + expect_status(:unauthorized) + end + end + + context 'when envelope is not a container type' do # rubocop:todo RSpec/NestedGroups + let(:envelope_ctdl_type) { 'ceterms:Credential' } + + it 'returns 404' do # rubocop:todo Layout/IndentationConsistency + patch "/navy/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect_status(:not_found) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + # rubocop:todo RSpec/MultipleMemoizedHelpers + context 'when authenticated and container exists' do # rubocop:todo RSpec/NestedGroups + let(:parsed_subresource) { JSON.parse(subresource) } + + before do + allow(repository).to receive(:add_member_uri).with(parsed_subresource) + end + + it 'instantiates a repository and calls add_member_uri with the parsed resource' do + patch "/navy/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect(repository).to have_received(:add_member_uri).with(parsed_subresource) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + end + + # rubocop:todo RSpec/MultipleMemoizedHelpers + describe 'DELETE /:community/containers/:container_ctid/resources' do + # rubocop:todo RSpec/NestedGroups + context 'when not authenticated' do # rubocop:todo RSpec/MultipleMemoizedHelpers, RSpec/NestedGroups + # rubocop:enable RSpec/NestedGroups + it 'returns 401' do + delete "/navy/containers/#{container_ctid}/resources", subresource + expect_status(:unauthorized) + end + end + + context 'when envelope is not a container type' do # rubocop:todo RSpec/NestedGroups + let(:envelope_ctdl_type) { 'ceterms:Credential' } + + it 'returns 404' do # rubocop:todo Layout/IndentationConsistency + delete "/navy/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect_status(:not_found) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + # rubocop:todo RSpec/MultipleMemoizedHelpers + context 'when authenticated and container exists' do # rubocop:todo RSpec/NestedGroups + let(:parsed_subresource) { JSON.parse(subresource) } + + before do + allow(repository).to receive(:remove_member_uris).with(parsed_subresource) + end + + it 'instantiates a repository and calls remove_member_uris with the parsed resource' do + delete "/navy/containers/#{container_ctid}/resources", + subresource, + 'Authorization' => "Token #{user.auth_token.value}" + + expect(repository).to have_received(:remove_member_uris).with(parsed_subresource) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + end + end +end diff --git a/spec/factories/envelopes.rb b/spec/factories/envelopes.rb index 2a09606e..d6bff98f 100644 --- a/spec/factories/envelopes.rb +++ b/spec/factories/envelopes.rb @@ -81,6 +81,10 @@ processed_resource { attributes_for(:cer_graph_competency_framework, provisional:) } end + trait :with_graph_collection do + processed_resource { attributes_for(:cer_graph_collection, provisional:) } + end + trait :provisional do provisional { true } end diff --git a/spec/factories/resources.rb b/spec/factories/resources.rb index 34d96a0e..26e3f1fd 100644 --- a/spec/factories/resources.rb +++ b/spec/factories/resources.rb @@ -172,4 +172,43 @@ ] end end + + factory :cer_graph_collection, parent: :base_resource do + transient { ctid { Envelope.generate_ctid } } + id { "http://credentialengineregistry.org/resources/#{ctid}" } + add_attribute(:@id) { id } + add_attribute(:@context) { 'http://credreg.net/ctdlasn/schema/context/json' } + add_attribute(:@graph) do + [ + attributes_for(:cer_collection, part_of: id), + attributes_for(:cer_collection_member, part_of: id) + ] + end + add_attribute(:'ceterms:ctid') { ctid } + end + + factory :cer_collection, parent: :base_resource do + transient do + ctid { Envelope.generate_ctid } + member_ids { [] } + end + id { "http://credentialengineregistry.org/resources/#{ctid}" } + add_attribute(:@id) { id } + add_attribute(:@type) { 'ceterms:Collection' } + add_attribute(:@context) { 'http://credreg.net/ctdlasn/schema/context/json' } + add_attribute(:'ceterms:ctid') { ctid } + add_attribute(:'ceterms:hasMember') { member_ids } + end + + factory :cer_collection_member, parent: :base_resource do + transient do + ctid { Envelope.generate_ctid } + member_ids { [] } + end + id { "http://credentialengineregistry.org/resources/#{ctid}" } + add_attribute(:@id) { id } + add_attribute(:@type) { 'ceterms:CollectionMember' } + add_attribute(:@context) { 'http://credreg.net/ctdlasn/schema/context/json' } + add_attribute(:'ceterms:ctid') { ctid } + end end diff --git a/spec/services/container_repository_spec.rb b/spec/services/container_repository_spec.rb new file mode 100644 index 00000000..f6aa7bba --- /dev/null +++ b/spec/services/container_repository_spec.rb @@ -0,0 +1,76 @@ +RSpec.describe ContainerRepository do # rubocop:todo RSpec/MultipleMemoizedHelpers + subject(:container_repository) { described_class.new(envelope) } + + let(:graph) { envelope.reload.processed_resource.fetch('@graph') } + let(:existing_subresource) { attributes_for(:cer_collection_member).stringify_keys } + let(:initial_graph) { [initial_container, existing_subresource] } + let(:new_subresource) { attributes_for(:cer_collection_member).stringify_keys } + let(:today) { Date.current + 1.week } + + let(:envelope) do + create( + :envelope, + :from_cer, + processed_resource: { '@graph' => initial_graph } + ) + end + + let(:initial_container) do + attributes_for( + :cer_collection, + member_ids: [existing_subresource['@id']] + ).stringify_keys + end + + describe '#add_member_uri' do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:new_uri) { new_subresource['@id'] } + let(:existing_uri) { existing_subresource['@id'] } + + let(:updated_container) do + initial_container.merge('ceterms:hasMember' => [existing_uri, new_uri]) + end + + it 'appends only unique URIs to hasMember' do # rubocop:todo RSpec/ExampleLength + expect do + travel_to(today) do + expect(container_repository.add_member_uri([ + existing_uri, + new_uri, + existing_uri, + new_uri + ])).to be(true) + end + + envelope.reload + end.to change { + envelope.processed_resource.dig('@graph', 0, 'ceterms:hasMember') + }.from([existing_uri]).to([existing_uri, new_uri]) + .and change(envelope, :last_verified_on).to(today) + .and enqueue_job(ExtractEnvelopeResourcesJob) + .with(envelope.id) + end + end + + describe '#remove_member_uris' do # rubocop:todo RSpec/MultipleMemoizedHelpers + let(:existing_uri) { existing_subresource['@id'] } + + it 'removes only matching URIs from hasMember' do # rubocop:todo RSpec/ExampleLength + expect do + travel_to(today) do + expect(container_repository.remove_member_uris([ + existing_uri, + 'http://example.com/non-existent' + ])).to be(true) + end + + envelope.reload + end.to change { + envelope.processed_resource.dig('@graph', 0, 'ceterms:hasMember') + }.from([existing_uri]).to([]) + .and change(envelope, :last_verified_on).to(today) + .and enqueue_job(ExtractEnvelopeResourcesJob) + .with(envelope.id) + end + end + +end