diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index dc8ae12d55..32464e1c24 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -102,6 +102,7 @@ Lint/EmptyBlock: - 'app/api/endpoints/suppliers.rb' - 'app/api/endpoints/tag2_layout_templates.rb' - 'app/api/endpoints/tag_groups.rb' + - 'app/api/endpoints/tag_sets.rb' - 'app/api/endpoints/tag_layout_templates.rb' - 'app/api/endpoints/transfer_requests.rb' - 'app/api/endpoints/transfer_templates.rb' diff --git a/app/controllers/api/v2/tag_sets_controller.rb b/app/controllers/api/v2/tag_sets_controller.rb new file mode 100644 index 0000000000..3b707fdc42 --- /dev/null +++ b/app/controllers/api/v2/tag_sets_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Api + module V2 + # Provides a JSON API controller for TagSet + # See: http://jsonapi-resources.com/ for JSONAPI::Resource documentation + class TagSetsController < JSONAPI::ResourceController + # By default JSONAPI::ResourceController provides most the standard + # behaviour, and in many cases this file may be left empty. + before_action :check_feature_flag + + private + + def check_feature_flag + render json: { error: 'TagSets API is disabled' } unless Flipper.enabled?(:y24_220_enable_tag_set_api) + end + end + end +end diff --git a/app/models/tag_set.rb b/app/models/tag_set.rb index 6e068d6a5c..ddd1a206a0 100644 --- a/app/models/tag_set.rb +++ b/app/models/tag_set.rb @@ -4,6 +4,7 @@ # It can also be used to represent single index tag sets # Background explained in Y24-170 (https://github.com/sanger/sequencescape/issues/4160) class TagSet < ApplicationRecord + include Uuid::Uuidable # For dual index tags, tag_group is i7 oligos and tag2_group is i5 oligos belongs_to :tag_group, class_name: 'TagGroup', optional: false @@ -36,6 +37,7 @@ class TagSet < ApplicationRecord # The scoping retrieves the visible tag sets and makes sure they are dual index. scope :visible_dual_index, -> { dual_index.visible } + scope :by_adapter_type, ->(adapter_type_name) { joins(:tag_group).merge(TagGroup.by_adapter_type(adapter_type_name)) } scope :single_index, -> { where(tag2_group: nil) } scope :visible_single_index, -> { single_index.visible } diff --git a/app/resources/api/v2/tag_set_resource.rb b/app/resources/api/v2/tag_set_resource.rb new file mode 100644 index 0000000000..00cae0b7c0 --- /dev/null +++ b/app/resources/api/v2/tag_set_resource.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Api + module V2 + # Provides a JSON:API representation of {TagSet}, which links together two related tag groups. + # A TagSet represents a logical grouping of tags used for indexing in sequencing experiments. + # It typically consists of a primary tag group and an optional secondary tag group, + # enabling support for both single and dual indexing workflows. + # This resource allows clients to query, filter, and retrieve information about tag sets, + # including their associated tag groups and metadata, through the `/api/v2/tag_sets/` endpoint. + # + # @note Access this resource via the `/api/v2/tag_sets/` endpoint. + # + # @example GET request for all TagSet resources + # GET /api/v2/tag_sets/ + # + # @example GET request for a specific TagSet by ID + # GET /api/v2/tag_sets/123/ + # + # @example Filtering by tag group adapter type name + # GET /api/v2/tag_sets/?filter[tag_group_adapter_type_name]=AdapterType1 + # + # @example Filtering by visibility (applied by default) + # GET /api/v2/tag_sets/?filter[visible]=true + # + # For more information about JSON:API, see the [JSON:API Specifications](https://jsonapi.org/format/) + # or the [JSONAPI::Resources](http://jsonapi-resources.com/) package, which implements JSON:API for Sequencescape. + class TagSetResource < BaseResource + immutable + + default_includes :uuid_object, :tag_group, :tag2_group + + ### + # Relationships + ### + + # @!attribute [r] tag_group + # A relationship for the primary tag group associated with the tag layout template. + # @return [Api::V2::TagGroupResource] + has_one :tag_group, readonly: true + + # @!attribute [r] tag2_group + # A relationship for the secondary tag group associated with the tag layout template. + # This is used during dual indexing, but will not be found during single indexing. + # @return [Api::V2::TagGroupResource] + has_one :tag2_group, readonly: true + + ### + # Attributes + ### + + # @!attribute [r] uuid + # The UUID of the tag set. + # @return [String]. + attribute :uuid, readonly: true + + # @!attribute [r] name + # The display name of the tag set. + # @return [String] + attribute :name, readonly: true + + ### + # Filters + ### + + # Allows filtering by the adapter type name of the tag group. + # @example + # GET /api/v2/tag_sets/?filter[tag_group_adapter_type_name]=AdapterType1 + filter :tag_group_adapter_type_name, apply: ->(records, value, _options) { records.by_adapter_type(value) } + + # Allows filtering by visibility. + # @example + # GET /api/v2/tag_sets/?filter[visible]=true + filter :visible, default: true, apply: ->(records, _value, _options) { records.visible } + end + end +end diff --git a/config/feature_flags.yml b/config/feature_flags.yml index 719af38eed..76f57412a0 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -4,3 +4,5 @@ dpl_395_2_enable_advanced_search_tab: Shows the Search tab in the top navigation bar # Below is flagged off until some existing data that would violate the rule is fixed. Should be re-enabled in Y24-058. y24_052_enable_data_release_timing_validation: Enables server-side validation that enforces a relationship between the values of two study metadata fields +# Below is disabled until the new API is ready for production. This is dependent on Y24-185, which involves creating tag sets for all tag groups used for CustomTaggedPlates. +y24_220_enable_tag_set_api: Enables the Tag Set API diff --git a/config/routes.rb b/config/routes.rb index 544494cc5a..896dd8d524 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,7 @@ jsonapi_resources :submissions jsonapi_resources :tag_group_adapter_types jsonapi_resources :tag_groups + jsonapi_resources :tag_sets, only: %i[index show] jsonapi_resources :tag_layout_templates jsonapi_resources :tag_layouts, except: %i[update] jsonapi_resources :tags diff --git a/spec/requests/api/v2/labware_spec.rb b/spec/requests/api/v2/labware_spec.rb index f648eebba7..a27b80d45a 100644 --- a/spec/requests/api/v2/labware_spec.rb +++ b/spec/requests/api/v2/labware_spec.rb @@ -12,22 +12,15 @@ before do create(:sample_tube) create(:library_tube) - end - - it 'sends a list of labware' do api_get base_endpoint + end - # test for the 200 status-code + it 'responds with a success http code when retrieving a list of receptacles.' do expect(response).to have_http_status(:success) - - # check to make sure the right amount of messages are returned - expect(json['data'].length).to eq(2) end - it 'identifies the type of labware' do - api_get base_endpoint - listed = json['data'].pluck('type').sort - expect(listed).to eq(%w[tubes tubes]) + it 'returns the correct number of receptacles' do + expect(json['data'].length).to eq(2) end # Check filters, ESPECIALLY if they aren't simple attribute filters diff --git a/spec/requests/api/v2/tag_sets_spec.rb b/spec/requests/api/v2/tag_sets_spec.rb new file mode 100644 index 0000000000..3c008e8509 --- /dev/null +++ b/spec/requests/api/v2/tag_sets_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'rails_helper' +require './spec/requests/api/v2/shared_examples/api_key_authenticatable' +require './spec/requests/api/v2/shared_examples/requests' + +describe 'TagSets API', with: :api_v2 do + let(:model_class) { TagSet } + let(:base_endpoint) { '/api/v2/tag_sets' } + let(:resource_type) { model_class.name.demodulize.pluralize.underscore } + + before { Flipper.enable(:y24_220_enable_tag_set_api) } + + it_behaves_like 'ApiKeyAuthenticatable' + + context 'with multiple TagSets' do + before do + create_list(:tag_set, 5) + create(:tag_set, tag_group: create(:tag_group, visible: false), tag2_group: create(:tag_group)) + api_get base_endpoint + end + + it 'responds with a success http code when sending a list of tag_sets with visible tag groups' do + expect(response).to have_http_status(:success) + end + + it 'returns the correct number of tag_sets with visible tag groups' do + expect(json['data'].length).to eq(5) + end + end + + context 'when filtering tag sets by tag_group_adapter_type_name' do + before do + adapter_type = build(:adapter_type, name: 'adapter_type_1') + create(:tag_set, tag_group: create(:tag_group, adapter_type:), tag2_group: create(:tag_group, adapter_type:)) + create_list(:tag_set, 5) + api_get "#{base_endpoint}?filter[tag_group_adapter_type_name]=adapter_type_1" + end + + it 'responds with a success http code when filtering by tag_group_adapter_type_name' do + expect(response).to have_http_status(:success) + end + + it 'returns the correct number of tag_sets when filtering by tag_group_adapter_type_name' do + expect(json['data'].length).to eq(1) + end + + it 'returns the correct tag_set when filtering by tag_group_adapter_type_name' do + expect(json['data'][0]['attributes']['name']).to eq(TagSet.first.name) + end + end + + context 'with a single resource' do + let(:resource) { create(:tag_set) } + + context 'without included relationships' do + before { api_get "#{base_endpoint}/#{resource.id}" } + + it 'responds with a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns the correct resource id' do + expect(json.dig('data', 'id')).to eq(resource.id.to_s) + end + + it 'returns the correct resource type' do + expect(json.dig('data', 'type')).to eq(resource_type) + end + + it 'returns the correct resource name' do + expect(json.dig('data', 'attributes', 'name')).to eq(resource.name) + end + + it 'returns the correct resource uuid' do + expect(json.dig('data', 'attributes', 'uuid')).to eq(resource.uuid) + end + + it 'returns reference to tag_group' do + expect(json.dig('data', 'relationships', 'tag_group')).to be_present + end + + it 'returns reference to tag2_group' do + expect(json.dig('data', 'relationships', 'tag2_group')).to be_present + end + end + end + + context 'with included relationships' do + let(:resource) { create(:tag_set) } + + it_behaves_like 'a GET request including a has_one relationship', 'tag_group' + it_behaves_like 'a GET request including a has_one relationship', 'tag2_group' + end + + context 'with included tags for tag groups' do + let(:resource) { create(:tag_set, tag_group:, tag2_group:) } + let(:tag_group) { create(:tag_group, tags:) } + let(:tag2_group) { create(:tag_group, tags: tags2) } + let(:tags) do + [ + build(:tag, oligo: 'AAA', map_id: 1), + build(:tag, oligo: 'TTT', map_id: 2), + build(:tag, oligo: 'CCC', map_id: 3), + build(:tag, oligo: 'GGG', map_id: 4) + ] + end + let(:tags2) do + [ + build(:tag, oligo: 'TTT', map_id: 1), + build(:tag, oligo: 'AAA', map_id: 2), + build(:tag, oligo: 'CCC', map_id: 3), + build(:tag, oligo: 'GGG', map_id: 4) + ] + end + + before { api_get "#{base_endpoint}/#{resource.id}?include=tag_group,tag2_group" } + + it 'returns a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns tags for the first tag group within the tag set in included' do + expect(json['included'][0]['attributes']['tags']).to eq( + [ + { 'index' => 1, 'oligo' => 'AAA' }, + { 'index' => 2, 'oligo' => 'TTT' }, + { 'index' => 3, 'oligo' => 'CCC' }, + { 'index' => 4, 'oligo' => 'GGG' } + ] + ) + end + + it 'returns tags for the second tag group within the tag set in included' do + expect(json['included'][1]['attributes']['tags']).to eq( + [ + { 'index' => 1, 'oligo' => 'TTT' }, + { 'index' => 2, 'oligo' => 'AAA' }, + { 'index' => 3, 'oligo' => 'CCC' }, + { 'index' => 4, 'oligo' => 'GGG' } + ] + ) + end + end +end diff --git a/spec/resources/api/v2/tag_set_resource_spec.rb b/spec/resources/api/v2/tag_set_resource_spec.rb new file mode 100644 index 0000000000..17bf118302 --- /dev/null +++ b/spec/resources/api/v2/tag_set_resource_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' +require './app/resources/api/v2/tag_group_resource' + +RSpec.describe Api::V2::TagSetResource, type: :resource do + subject(:resource) { described_class.new(resource_model, {}) } + + let(:resource_model) { build_stubbed(:tag_set, tag_group:, tag2_group:) } + let(:tag_group) { create(:tag_group) } + let(:tag2_group) { create(:tag_group) } + + it { is_expected.to have_model_name 'TagSet' } + + # Test attributes + it 'exposes attributes', :aggregate_failures do + expect(resource).to have_attribute :uuid + expect(resource).to have_attribute :name + expect(resource).not_to have_updatable_field(:id) + expect(resource).not_to have_updatable_field(:uuid) + expect(resource).not_to have_updatable_field(:name) + end + + # Relationships + it { is_expected.to have_a_readonly_has_one(:tag_group).with_class_name('TagGroup') } + it { is_expected.to have_a_readonly_has_one(:tag2_group).with_class_name('TagGroup') } + + describe '#tag group' do + it 'returns the correct tag group information' do + expect(resource.tag_group._model).to eq(tag_group) + end + + it 'returns the correct tag2 group information' do + expect(resource.tag2_group._model).to eq(tag2_group) + end + end +end