Skip to content

Y24-220 Create v2 end point for tagsets #4732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 19 additions & 0 deletions app/controllers/api/v2/tag_sets_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think there should there be an addition to feature_flags.yml in this PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, if you haven't already, would you mind creating a story and a pull request to remove the feature flag, so it's easy to remove when we decide to? There's a team feature flags guidance page on Confluence in case if you haven't seen it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍 #4766

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you 🙏

I think the idea was to go even further, and create the (draft) pull request ready to merge in whenever appropriate - so that removing the feature flag is really effortless (obviously extra effort for you upfront, but hopefully less overall since it's fresh in your head).

Also, thinking about when to remove the flag - on the story you've written "once the prerequisite work (Y25-185) is completed".

We can definitely switch the new feature 'on' once Y25-185 is completed, but I think we could let the flag hang around for a couple of months or so afterwards, just to ensure the page gets used and we haven't missed anything?

This was what I thought about the level of risk (copied from Slack):

I think there is a small remaining risk, as we are making the functionality on that page more constrained, in that we're not including ALL the tag groups, and we're not allowing you to select any possible combination of tag 1 and tag 2.
So feature flagging could help in that we could resolve any prod issues immediately by flipping the flag.

Since you've implemented the flag, I feel we may as well keep it around for a little while in case we get any production issues due to the above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, okay. I agree, and all of your comments make complete sense. I will create a draft PR and chang the timing to remove the flag.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KatyTaylor
Following draft PRs created which enable the removal of
SS - #4770
Limber - sanger/limber#2275
Integration suite - https://gitlab.internal.sanger.ac.uk/psd/integration-suite/-/merge_requests/232

end
end
end
2 changes: 2 additions & 0 deletions app/models/tag_set.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 }
Expand Down
77 changes: 77 additions & 0 deletions app/resources/api/v2/tag_set_resource.rb
Original file line number Diff line number Diff line change
@@ -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/
#
Copy link
Contributor

@harrietc52 harrietc52 Mar 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to include the example GET requests, including the filter examples?

I’ve added example requests as part of updated docs here. You can then use the GitHub action to rebuild the Yard Syntax and view these comments/ examples here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harrietc52 , I've updated the documentation as well as the Sequencescape API v2.postman_collection.json in smb://files-smb/pipeline_solutions. Please check if it is ok.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you might need to run this Workflow, as described here, to update the SS Yard documentation on GitHub pages :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Postman looks good, thank you

# @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
2 changes: 2 additions & 0 deletions config/feature_flags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 4 additions & 11 deletions spec/requests/api/v2/labware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions spec/requests/api/v2/tag_sets_spec.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions spec/resources/api/v2/tag_set_resource_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading