diff --git a/.rubocop.yml b/.rubocop.yml index d4480de13..78e50f655 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -22,6 +22,7 @@ AllCops: Performance: Exclude: - '**/test/**/*' +SuggestExtensions: false # Prefer assert_not over assert ! Rails/AssertNot: diff --git a/Gemfile.lock b/Gemfile.lock index e49c66f07..b3b3bfccf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -26,9 +26,9 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.10) - actionpack (>= 4.1, < 6.1) - activemodel (>= 4.1, < 6.1) + active_model_serializers (0.10.12) + actionpack (>= 4.1, < 6.2) + activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) activejob (5.2.4.4) @@ -56,25 +56,25 @@ GEM google-protobuf (~> 3.7) graphql (>= 1.9.8) arel (9.0.0) - ast (2.4.1) - audited (4.9.0) - activerecord (>= 4.2, < 6.1) + ast (2.4.2) + audited (4.10.0) + activerecord (>= 4.2, < 6.2) aws-eventstream (1.1.0) - aws-partitions (1.397.0) - aws-sdk-core (3.109.3) + aws-partitions (1.426.0) + aws-sdk-core (3.112.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.39.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-kms (1.42.0) + aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.85.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-s3 (1.88.0) + aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sdk-sqs (1.34.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-sqs (1.36.0) + aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) aws-sigv4 (1.2.2) aws-eventstream (~> 1, >= 1.0.2) @@ -98,9 +98,9 @@ GEM coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) - bibtex-ruby (5.1.5) + bibtex-ruby (6.0.0) latex-decode (~> 0.0) - binding_of_caller (0.8.0) + binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) bolognese (1.8.18) activesupport (>= 4.2.5) @@ -127,21 +127,21 @@ GEM rdf-rdfxml (~> 3.1) rdf-turtle (~> 3.1) thor (>= 0.19) - bootsnap (1.5.1) + bootsnap (1.7.2) msgpack (~> 1.0) builder (3.2.4) - bullet (6.1.0) + bullet (6.1.3) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) cancancan (2.3.0) - capybara (3.33.0) + capybara (3.35.3) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) rack (>= 1.6.0) rack-test (>= 0.6.3) - regexp_parser (~> 1.5) + regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) case_transform (0.2) activesupport @@ -155,7 +155,7 @@ GEM colorize (0.8.1) commonmarker (0.17.13) ruby-enum (~> 0.5) - concurrent-ruby (1.1.7) + concurrent-ruby (1.1.8) connection_pool (2.2.3) countries (2.1.4) i18n_data (~> 0.8.0) @@ -165,28 +165,34 @@ GEM country_select (3.1.1) countries (~> 2.0) sort_alphabetical (~> 1.0) - crack (0.4.4) + crack (0.4.5) + rexml crass (1.0.6) - crawler_detect (1.1.0) + crawler_detect (1.1.1) qonfig (~> 0.24) csl (1.5.2) namae (~> 1.0) csl-styles (1.0.1.10) csl (~> 1.0) - css_parser (1.7.1) + css_parser (1.9.0) addressable dalli (2.7.11) - database_cleaner (1.8.5) + database_cleaner (2.0.1) + database_cleaner-active_record (~> 2.0.0) + database_cleaner-active_record (2.0.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) ddtrace (0.32.0) msgpack - debug_inspector (0.0.3) + debug_inspector (1.0.0) departure (6.2.0) activerecord (>= 5.2.0, < 6.1) mysql2 (>= 0.4.0, <= 0.5.3) railties (>= 5.2.0, < 6.1) diff-lcs (1.4.4) diffy (3.4.0) - docile (1.3.2) + docile (1.3.5) docopt (0.6.1) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -203,7 +209,7 @@ GEM elasticsearch-transport (= 7.5.0) elasticsearch-api (7.5.0) multi_json - elasticsearch-extensions (0.0.31) + elasticsearch-extensions (0.0.33) ansi elasticsearch elasticsearch-model (7.1.1) @@ -226,7 +232,7 @@ GEM railties (>= 3.0.0) faker (1.9.6) i18n (>= 0.7) - faraday (0.17.3) + faraday (0.17.4) multipart-post (>= 1.2, < 3) faraday-encoding (0.0.5) faraday @@ -237,14 +243,14 @@ GEM faraday (>= 0.15) fast_jsonapi (1.5) activesupport (>= 4.2) - ffi (1.13.1) + ffi (1.14.2) flipper (0.17.2) flipper-active_support_cache_store (0.17.2) activesupport (>= 4.2, < 7) flipper (~> 0.17.2) gender_detector (0.1.2) unicode_utils (>= 1.3.0) - git (1.7.0) + git (1.8.1) rchardet (~> 1.8) globalid (0.4.2) activesupport (>= 4.2.0) @@ -257,7 +263,7 @@ GEM graphql (~> 1, > 1.8) graphql-errors (0.4.0) graphql (>= 1.6.0, < 2) - haml (5.2.0) + haml (5.2.1) temple (>= 0.8.0) tilt hamster (3.0.0) @@ -267,22 +273,22 @@ GEM htmlentities (4.3.4) http-cookie (1.0.3) domain_name (~> 0.5) - i18n (1.8.5) + i18n (1.8.8) concurrent-ruby (~> 1.0) i18n_data (0.8.0) iso-639 (0.3.5) iso8601 (0.9.1) jmespath (1.4.0) - json (2.3.1) + json (2.5.1) json-canonicalization (0.2.0) - json-ld (3.1.5) + json-ld (3.1.8) htmlentities (~> 4.3) json-canonicalization (~> 0.2) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.14) rack (~> 2.0) rdf (~> 3.1) - json-ld-preloaded (3.1.3) + json-ld-preloaded (3.1.4) json-ld (~> 3.1) rdf (~> 3.1) jsonapi-renderer (0.2.2) @@ -302,7 +308,7 @@ GEM activerecord kaminari-core (= 1.2.1) kaminari-core (1.2.1) - kt-paperclip (6.3.0) + kt-paperclip (6.4.1) activemodel (>= 4.2.0) activesupport (>= 4.2.0) mime-types @@ -322,7 +328,7 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - loofah (2.7.0) + loofah (2.9.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) macaddr (1.7.2) @@ -352,26 +358,26 @@ GEM mini_magick (4.11.0) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.14.2) - money (6.13.8) + minitest (5.14.3) + money (6.14.0) i18n (>= 0.6.4, <= 2) - msgpack (1.3.3) + msgpack (1.4.2) multi_json (1.15.0) multipart-post (2.1.1) mysql2 (0.5.3) - namae (1.0.1) - net-http-persistent (3.1.0) + namae (1.0.2) + net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - nio4r (2.5.4) + nio4r (2.5.5) nokogiri (1.10.10) mini_portile2 (~> 2.4.0) - oj (3.10.16) + oj (3.11.2) oj_mimic_json (1.0.1) optimist (3.0.1) pandoc-ruby (2.1.4) - parallel (1.20.0) - parser (2.7.2.0) + parallel (1.20.1) + parser (3.0.0.0) ast (~> 2.4.1) postrank-uri (1.0.24) addressable (>= 2.4.0) @@ -426,7 +432,7 @@ GEM rb-inotify (0.10.1) ffi (~> 1.0) rchardet (1.8.0) - rdf (3.1.7) + rdf (3.1.10) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-aggregate-repo (3.1.0) @@ -443,13 +449,14 @@ GEM rdf (~> 3.1) rdf-rdfa (~> 3.1) rdf-xsd (~> 3.1) - rdf-turtle (3.1.2) - ebnf (~> 2.0) - rdf (~> 3.1, >= 3.1.2) - rdf-vocab (3.1.8) - rdf (~> 3.1, >= 3.1.2) - rdf-xsd (3.1.0) + rdf-turtle (3.1.3) + ebnf (~> 2.1) + rdf (~> 3.1, >= 3.1.8) + rdf-vocab (3.1.10) + rdf (~> 3.1, >= 3.1.8) + rdf-xsd (3.1.1) rdf (~> 3.1) + rexml (~> 3.2) regexp_parser (1.8.2) request_store (1.5.0) rack (>= 1.4) @@ -485,32 +492,32 @@ GEM rspec-mocks (~> 3.9.0) rspec-support (~> 3.9.0) rspec-support (3.9.4) - rubocop (1.3.1) + rubocop (1.9.1) parallel (~> 1.10) - parser (>= 2.7.1.5) + parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8) + regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.1.1) + rubocop-ast (>= 1.2.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (1.1.1) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.4.1) parser (>= 2.7.1.5) rubocop-packaging (0.5.1) rubocop (>= 0.89, < 2.0) - rubocop-performance (1.9.0) + rubocop-performance (1.9.2) rubocop (>= 0.90.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.8.1) + rubocop-rails (2.9.1) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 0.87.0) - rubocop-rspec (2.0.0) + rubocop (>= 0.90.0, < 2.0) + rubocop-rspec (2.2.0) rubocop (~> 1.0) rubocop-ast (>= 1.1.0) - ruby-enum (0.8.0) + ruby-enum (0.9.0) i18n - ruby-progressbar (1.10.1) + ruby-progressbar (1.11.0) ruby_dep (1.5.0) safe_yaml (1.0.5) scanf (1.0.0) @@ -522,7 +529,7 @@ GEM aws-sdk-core (>= 2) concurrent-ruby thor - shoulda-matchers (4.4.1) + shoulda-matchers (4.5.1) activesupport (>= 4.2.0) simple_command (0.1.0) simplecov (0.17.1) @@ -534,16 +541,16 @@ GEM slack-notifier (2.3.2) sort_alphabetical (1.1.0) unicode_utils (>= 1.2.2) - sparql (3.1.3) + sparql (3.1.5) builder (~> 3.2) - ebnf (>= 1.1) - rdf (~> 3.1, >= 3.1.2) + ebnf (~> 2.1) + rdf (~> 3.1, >= 3.1.8) rdf-aggregate-repo (~> 3.1) rdf-xsd (~> 3.1) - sparql-client (~> 3.1) + sparql-client (~> 3.1, >= 3.1.2) sxp (~> 1.1) - sparql-client (3.1.0) - net-http-persistent (~> 3.1) + sparql-client (3.1.2) + net-http-persistent (~> 4.0, >= 4.0.1) rdf (~> 3.1) spring (2.1.1) spring-commands-rspec (1.0.4) @@ -570,7 +577,7 @@ GEM terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) test-prof (0.10.2) - thor (1.0.1) + thor (1.1.0) thread_safe (0.3.6) tilt (2.0.10) turnout (2.5.0) @@ -578,18 +585,18 @@ GEM rack (>= 1.3, < 3) rack-accept (~> 0.4) tilt (>= 1.4, < 3) - tzinfo (1.2.8) + tzinfo (1.2.9) thread_safe (~> 0.1) unf (0.1.4) unf_ext unf_ext (0.0.7.7) - unicode-display_width (1.7.0) + unicode-display_width (2.0.0) unicode_utils (1.4.0) - uniform_notifier (1.13.0) + uniform_notifier (1.13.2) uuid (2.3.9) macaddr (~> 1.0) vcr (5.1.0) - webmock (3.10.0) + webmock (3.11.2) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) diff --git a/app/controllers/v3/activities_controller.rb b/app/controllers/v3/activities_controller.rb new file mode 100644 index 000000000..918bbf064 --- /dev/null +++ b/app/controllers/v3/activities_controller.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +class V3::ActivitiesController < ApplicationController + include Countable + + before_action :set_activity, only: %i[show] + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "created" + { created: { order: "asc" } } + when "-created" + { created: { order: "desc" } } + else + { created: { order: "desc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + Activity.find_by_id(params[:id]) + elsif params[:ids].present? + Activity.find_by_id(params[:ids], page: page, sort: sort) + else + Activity.query( + params[:query], + uid: + params[:datacite_doi_id] || params[:provider_id] || + params[:client_id] || + params[:repository_id], + page: page, + sort: sort, + scroll_id: params[:scroll_id], + ) + end + + begin + if page[:scroll].present? + results = response.results + total = response.total + else + total = response.results.total + total_for_pages = + page[:cursor].nil? ? total.to_f : [total.to_f, 10_000].min + total_pages = page[:size] > 0 ? (total_for_pages / page[:size]).ceil : 0 + end + + if page[:scroll].present? + options = {} + options[:meta] = { + total: total, "scroll-id" => response.scroll_id + }.compact + options[:links] = { + self: request.original_url, + next: + if results.size < page[:size] || page[:size] == 0 + nil + else + request.base_url + "/activities?" + + { + "scroll-id" => response.scroll_id, + "page[scroll]" => page[:scroll], + "page[size]" => page[:size], + }.compact. + to_query + end, + }.compact + options[:is_collection] = true + + render json: V3::ActivitySerializer.new(results, options).serialized_json, + status: :ok + else + results = response.results + + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: + page[:cursor].nil? && page[:number].present? ? page[:number] : nil, + }.compact + + options[:links] = { + self: request.original_url, + next: + if response.results.size < page[:size] + nil + else + request.base_url + "/activities?" + + { + query: params[:query], + "page[cursor]" => page[:cursor] ? make_cursor(results) : nil, + "page[number]" => + if page[:cursor].nil? && page[:number].present? + page[:number] + 1 + end, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: V3::ActivitySerializer.new(results, options).serialized_json, + status: :ok + end + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::ActivitySerializer.new(@activity, options).serialized_json, + status: :ok + end + + protected + def set_activity + response = Activity.find_by_id(params[:id]) + @activity = response.results.first + fail ActiveRecord::RecordNotFound if @activity.blank? + end +end diff --git a/app/controllers/v3/contacts_controller.rb b/app/controllers/v3/contacts_controller.rb new file mode 100644 index 000000000..9df6562bb --- /dev/null +++ b/app/controllers/v3/contacts_controller.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +class V3::ContactsController < ApplicationController + include ActionController::MimeResponds + + before_action :set_contact, only: %i[show update destroy] + before_action :authenticate_user! + load_and_authorize_resource + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "name" + { "family_name" => { order: "asc" } } + when "-name" + { "family_name" => { order: "desc" } } + when "created" + { created: { order: "asc" } } + when "-created" + { created: { order: "desc" } } + else + { "family_name" => { order: "asc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + Contact.find_by_id(params[:id]) + else + Contact.query( + params[:query], + role_name: params[:role_name], + provider_id: params[:provider_id], + consortium_id: params[:consortium_id], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size] > 0 ? (total.to_f / page[:size]).ceil : 0 + + roles = + if total > 0 + facet_by_key(response.aggregations.roles.buckets) + end + + @contacts = response.results + respond_to do |format| + format.json do + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + roles: roles, + }.compact + + options[:links] = { + self: request.original_url, + next: + if @contacts.blank? + nil + else + request.base_url + "/contacts?" + + { + query: params[:query], + role: params[:role], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: sort, + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + options[:params] = { current_ability: current_ability } + + render json: + V3::ContactSerializer.new(@contacts, options). + serialized_json, + status: :ok + end + end + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ContactSerializer.new(@contact, options).serialized_json, + status: :ok + end + + def create + @contact = Contact.new(safe_params) + authorize! :create, @contact + + if @contact.save + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ContactSerializer.new(@contact, options).serialized_json, + status: :created + else + # Rails.logger.error @contact.errors.inspect + render json: serialize_errors(@contact.errors, uid: @contact.uid), + status: :unprocessable_entity + end + end + + def update + if @contact.update(safe_params) + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ContactSerializer.new(@contact, options).serialized_json, + status: :ok + else + # Rails.logger.error @contact.errors.inspect + render json: serialize_errors(@contact.errors, uid: @contact.uid), + status: :unprocessable_entity + end + end + + # don't delete, but set deleted_at timestamp + def destroy + if @contact.update(deleted_at: Time.zone.now) + head :no_content + else + # Rails.logger.error @contact.errors.inspect + render json: serialize_errors(@contact.errors, uid: @contact.uid), + status: :unprocessable_entity + end + end + + protected + def set_contact + @contact = Contact.where(uid: params[:id]).where(deleted_at: nil).first + fail ActiveRecord::RecordNotFound if @contact.blank? + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: [ + :uid, + :givenName, + :familyName, + :email, + :roleName, + { roleName: [] }, + :provider + ], + keys: { + "givenName" => :given_name, + "familyName" => :family_name, + "roleName" => :role_name, + }, + ) + end +end diff --git a/app/controllers/v3/datacite_dois_controller.rb b/app/controllers/v3/datacite_dois_controller.rb new file mode 100644 index 000000000..b9714da85 --- /dev/null +++ b/app/controllers/v3/datacite_dois_controller.rb @@ -0,0 +1,1189 @@ +# frozen_string_literal: true + +require "uri" +require "base64" +require "pp" + +class V3::DataciteDoisController < ApplicationController + include ActionController::MimeResponds + include Crosscitable + + prepend_before_action :authenticate_user! + before_action :set_include, only: %i[index show create update] + before_action :set_raven_context, only: %i[create update validate] + + def index + sort = + case params[:sort] + when "name" + { "doi" => { order: "asc" } } + when "-name" + { "doi" => { order: "desc" } } + when "created" + { created: { order: "asc" } } + when "-created" + { created: { order: "desc" } } + when "updated" + { updated: { order: "asc" } } + when "-updated" + { updated: { order: "desc" } } + when "published" + { published: { order: "asc" } } + when "-published" + { published: { order: "desc" } } + when "view-count" + { view_count: { order: "asc" } } + when "-view-count" + { view_count: { order: "desc" } } + when "download-count" + { download_count: { order: "asc" } } + when "-download-count" + { download_count: { order: "desc" } } + when "citation-count" + { citation_count: { order: "asc" } } + when "-citation-count" + { citation_count: { order: "desc" } } + when "relevance" + { "_score": { "order": "desc" } } + else + { updated: { order: "desc" } } + end + + page = page_from_params(params) + + sample_group_field = + case params[:sample_group] + when "client" + "client_id" + when "data-center" + "client_id" + when "provider" + "provider_id" + when "resource-type" + "types.resourceTypeGeneral" + end + + # only show findable DOIs to anonymous users and role user + if current_user.nil? || current_user.role_id == "user" + params[:state] = "findable" + end + + # facets are enabled by default + disable_facets = params[:disable_facets] + + if params[:id].present? + response = DataciteDoi.find_by_id(params[:id]) + elsif params[:ids].present? + response = DataciteDoi.find_by_ids(params[:ids], page: page, sort: sort) + else + response = + DataciteDoi.query( + params[:query], + state: params[:state], + exclude_registration_agencies: true, + published: params[:published], + created: params[:created], + registered: params[:registered], + provider_id: params[:provider_id], + consortium_id: params[:consortium_id], + client_id: params[:client_id], + affiliation_id: params[:affiliation_id], + funder_id: params[:funder_id], + re3data_id: params[:re3data_id], + opendoar_id: params[:opendoar_id], + license: params[:license], + certificate: params[:certificate], + prefix: params[:prefix], + user_id: params[:user_id], + resource_type_id: params[:resource_type_id], + resource_type: params[:resource_type], + schema_version: params[:schema_version], + subject: params[:subject], + field_of_science: params[:field_of_science], + has_citations: params[:has_citations], + has_references: params[:has_references], + has_parts: params[:has_parts], + has_part_of: params[:has_part_of], + has_versions: params[:has_versions], + has_version_of: params[:has_version_of], + has_views: params[:has_views], + has_downloads: params[:has_downloads], + has_person: params[:has_person], + has_affiliation: params[:has_affiliation], + has_organization: params[:has_organization], + has_funder: params[:has_funder], + link_check_status: params[:link_check_status], + link_check_has_schema_org: params[:link_check_has_schema_org], + link_check_body_has_pid: params[:link_check_body_has_pid], + link_check_found_schema_org_id: + params[:link_check_found_schema_org_id], + link_check_found_dc_identifier: + params[:link_check_found_dc_identifier], + link_check_found_citation_doi: params[:link_check_found_citation_doi], + link_check_redirect_count_gte: params[:link_check_redirect_count_gte], + sample_group: sample_group_field, + sample_size: params[:sample], + source: params[:source], + scroll_id: params[:scroll_id], + disable_facets: disable_facets, + page: page, + sort: sort, + random: params[:random], + ) + end + + begin + # If we're using sample groups we need to unpack the results from the aggregation bucket hits. + if sample_group_field.present? + sample_dois = [] + response.aggregations.samples.buckets.each do |bucket| + bucket.samples_hits.hits.hits.each do |hit| + sample_dois << hit._source + end + end + end + + # Results to return are either our sample group dois or the regular hit results + + # The total is just the length because for sample grouping we get everything back in one shot no pagination. + + if sample_dois + results = sample_dois + + total = sample_dois.length + total_pages = 1 + elsif page[:scroll].present? + # if scroll_id has expired + fail ActiveRecord::RecordNotFound if response.scroll_id.blank? + + results = response.results + total = response.total + else + results = response.results + total = response.results.total + total_for_pages = + page[:cursor].nil? ? [total.to_f, 10_000].min : total.to_f + total_pages = page[:size] > 0 ? (total_for_pages / page[:size]).ceil : 0 + end + + if page[:scroll].present? + options = {} + options[:meta] = { + total: total, "scroll-id" => response.scroll_id + }.compact + options[:links] = { + self: request.original_url, + next: + if results.size < page[:size] || page[:size] == 0 + nil + else + request.base_url + "/dois?" + + { + "scroll-id" => response.scroll_id, + "page[scroll]" => page[:scroll], + "page[size]" => page[:size], + }.compact. + to_query + end, + }.compact + options[:is_collection] = true + options[:params] = { + current_ability: current_ability, + detail: params[:detail], + affiliation: params[:affiliation], + is_collection: options[:is_collection], + } + + # sparse fieldsets + fields = fields_from_params(params) + if fields + render json: + DataciteDoiSerializer.new( + results, + options.merge(fields: fields), + ). + serialized_json, + status: :ok + else + render json: + V3::DataciteDoiSerializer.new(results, options).serialized_json, + status: :ok + end + else + if total.positive? && !disable_facets + states = facet_by_key(response.aggregations.states.buckets) + resource_types = facet_by_combined_key(response.aggregations.resource_types.buckets) + published = facet_by_range(response.aggregations.published.buckets) + created = facet_by_key_as_string(response.aggregations.created.buckets) + registered = facet_by_key_as_string(response.aggregations.registered.buckets) + providers = facet_by_combined_key(response.aggregations.providers.buckets) + clients = facet_by_combined_key(response.aggregations.clients.buckets) + prefixes = facet_by_key(response.aggregations.prefixes.buckets) + schema_versions = facet_by_schema(response.aggregations.schema_versions.buckets) + affiliations = facet_by_combined_key(response.aggregations.affiliations.buckets) + # sources = total.positive? ? facet_by_key(response.aggregations.sources.buckets) : nil + subjects = facet_by_key(response.aggregations.subjects.buckets) + fields_of_science = facet_by_fos( + response.aggregations.fields_of_science.subject.buckets, + ) + certificates = facet_by_key(response.aggregations.certificates.buckets) + licenses = facet_by_license(response.aggregations.licenses.buckets) + link_checks_status = facet_by_cumulative_year( + response.aggregations.link_checks_status.buckets, + ) + # links_with_schema_org = total.positive? ? facet_by_cumulative_year(response.aggregations.link_checks_has_schema_org.buckets) : nil + # link_checks_schema_org_id = total.positive? ? response.aggregations.link_checks_schema_org_id.value : nil + # link_checks_dc_identifier = total.positive? ? response.aggregations.link_checks_dc_identifier.value : nil + # link_checks_citation_doi = total.positive? ? response.aggregations.link_checks_citation_doi.value : nil + # links_checked = total.positive? ? response.aggregations.links_checked.value : nil + + citations = metric_facet_by_year(response.aggregations.citations.buckets) + views = metric_facet_by_year(response.aggregations.views.buckets) + downloads = metric_facet_by_year(response.aggregations.downloads.buckets) + else + states = nil + resource_types = nil + published = nil + created = nil + registered = nil + providers = nil + clients = nil + prefixes = nil + schema_versions = nil + affiliations = nil + subjects = nil + fields_of_science = nil + certificates = nil + licenses = nil + link_checks_status = nil + citations = nil + views = nil + downloads = nil + end + + respond_to do |format| + format.json do + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: + if page[:cursor].nil? && page[:number].present? + page[:number] + end, + states: states, + "resourceTypes" => resource_types, + created: created, + published: published, + registered: registered, + providers: providers, + clients: clients, + affiliations: affiliations, + prefixes: prefixes, + certificates: certificates, + licenses: licenses, + "schemaVersions" => schema_versions, + # sources: sources, + "linkChecksStatus" => link_checks_status, + # "linksChecked" => links_checked, + # "linksWithSchemaOrg" => links_with_schema_org, + # "linkChecksSchemaOrgId" => link_checks_schema_org_id, + # "linkChecksDcIdentifier" => link_checks_dc_identifier, + # "linkChecksCitationDoi" => link_checks_citation_doi, + subjects: subjects, + "fieldsOfScience" => fields_of_science, + citations: citations, + views: views, + downloads: downloads, + }.compact + + options[:links] = { + self: request.original_url, + next: + if results.size < page[:size] || page[:size] == 0 + nil + else + request.base_url + "/dois?" + + { + query: params[:query], + "provider-id" => params[:provider_id], + "consortium-id" => params[:consortium_id], + "client-id" => params[:client_id], + "funder-id" => params[:funder_id], + "affiliation-id" => params[:affiliation_id], + "resource-type-id" => params[:resource_type_id], + prefix: params[:prefix], + certificate: params[:certificate], + published: params[:published], + created: params[:created], + registered: params[:registered], + "has-citations" => params[:has_citations], + "has-references" => params[:has_references], + "has-parts" => params[:has_parts], + "has-part-of" => params[:has_part_of], + "has-versions" => params[:has_versions], + "has-version-of" => params[:has_version_of], + "has-views" => params[:has_views], + "has-downloads" => params[:has_downloads], + "has-person" => params[:has_person], + "has-affiliation" => params[:has_affiliation], + "has-funder" => params[:has_funder], + "disable-facets" => params[:disable_facets], + detail: params[:detail], + composite: params[:composite], + affiliation: params[:affiliation], + # The cursor link should be an array of values, but we want to encode it into a single string for the URL + "page[cursor]" => + page[:cursor] ? make_cursor(results) : nil, + "page[number]" => + if page[:cursor].nil? && page[:number].present? + page[:number] + 1 + end, + "page[size]" => page[:size], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + options[:params] = { + current_ability: current_ability, + detail: params[:detail], + composite: params[:composite], + affiliation: params[:affiliation], + is_collection: options[:is_collection], + } + + # sparse fieldsets + fields = fields_from_params(params) + if fields + render json: + DataciteDoiSerializer.new( + results, + options.merge(fields: fields), + ). + serialized_json, + status: :ok + else + render json: + V3::DataciteDoiSerializer.new(results, options). + serialized_json, + status: :ok + end + end + + format.citation do + # fetch formatted citations + render citation: response.records.to_a, + style: params[:style] || "apa", + locale: params[:locale] || "en-US" + end + header = %w[ + doi + url + registered + state + resourceTypeGeneral + resourceType + title + author + publisher + publicationYear + ] + format.any( + :bibtex, + :citeproc, + :codemeta, + :crosscite, + :datacite, + :datacite_json, + :jats, + :ris, + :schema_org, + ) { render request.format.to_sym => response.records.to_a } + format.csv do + render request.format.to_sym => response.records.to_a, + header: header + end + end + end + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + # only show findable DataCite DOIs to anonymous users and role user + # use current_user role to determine permissions to access draft and registered dois + # instead of using ability + # response = DataciteDoi.find_by_id(params[:id]) + # workaround until STI is enabled + doi = DataciteDoi.where(type: "DataciteDoi").where(doi: params[:id]).first + if doi.blank? || + ( + doi.aasm_state != "findable" && + not_allowed_by_doi_and_user(doi: doi, user: current_user) + ) + fail ActiveRecord::RecordNotFound + end + + respond_to do |format| + format.json do + # doi = response.results.first + if not_allowed_by_doi_and_user(doi: doi, user: current_user) + fail ActiveRecord::RecordNotFound + end + + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { + current_ability: current_ability, + detail: true, + composite: nil, + affiliation: params[:affiliation], + } + + render json: V3::DataciteDoiSerializer.new(doi, options).serialized_json, + status: :ok + end + + # doi = response.records.first + if not_allowed_by_doi_and_user(doi: doi, user: current_user) + fail ActiveRecord::RecordNotFound + end + + format.citation do + # fetch formatted citation + render citation: doi, + style: params[:style] || "apa", + locale: params[:locale] || "en-US" + end + header = %w[ + doi + url + registered + state + resourceTypeGeneral + resourceType + title + author + publisher + publicationYear + ] + format.any( + :bibtex, + :citeproc, + :codemeta, + :crosscite, + :datacite, + :datacite_json, + :jats, + :ris, + :schema_org, + ) { render request.format.to_sym => doi } + format.csv { render request.format.to_sym => doi, header: header } + end + end + + def validate + @doi = DataciteDoi.new(safe_params.merge(only_validate: true)) + + authorize! :validate, @doi + + if @doi.valid? + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::DataciteDoiSerializer.new(@doi, options).serialized_json, + status: :ok + else + logger.info @doi.errors.messages + render json: serialize_errors(@doi.errors.messages, uid: @doi.uid), status: :ok + end + end + + def create + fail CanCan::AuthorizationNotPerformed if current_user.blank? + + @doi = DataciteDoi.new(safe_params) + + # capture username and password for reuse in the handle system + @doi.current_user = current_user + + authorize! :new, @doi + + if @doi.save + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { + current_ability: current_ability, + detail: true, + affiliation: params[:affiliation], + } + + render json: V3::DataciteDoiSerializer.new(@doi, options).serialized_json, + status: :created, + location: @doi + else + logger.error @doi.errors.inspect + render json: serialize_errors(@doi.errors, uid: @doi.uid), + include: @include, + status: :unprocessable_entity + end + end + + def update + @doi = DataciteDoi.where(doi: params[:id]).first + exists = @doi.present? + + # capture username and password for reuse in the handle system + + if exists + @doi.current_user = current_user + + if params.dig(:data, :attributes, :mode) == "transfer" + # only update client_id + + authorize! :transfer, @doi + @doi.assign_attributes(safe_params.slice(:client_id)) + else + authorize! :update, @doi + if safe_params[:schema_version].blank? + @doi.assign_attributes( + safe_params.except(:doi, :client_id).merge( + schema_version: @doi[:schema_version] || LAST_SCHEMA_VERSION, + ), + ) + else + @doi.assign_attributes(safe_params.except(:doi, :client_id)) + end + end + else + doi_id = validate_doi(params[:id]) + fail ActiveRecord::RecordNotFound if doi_id.blank? + + @doi = DataciteDoi.new(safe_params.merge(doi: doi_id)) + # capture username and password for reuse in the handle system + @doi.current_user = current_user + + authorize! :new, @doi + end + + if @doi.save + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { + current_ability: current_ability, + detail: true, + affiliation: params[:affiliation], + } + + render json: DataciteDoiSerializer.new(@doi, options).serialized_json, + status: exists ? :ok : :created + else + logger.error @doi.errors.messages + render json: serialize_errors(@doi.errors.messages, uid: @doi.uid), + include: @include, + status: :unprocessable_entity + end + end + + def undo + @doi = DataciteDoi.where(doi: safe_params[:doi]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + + authorize! :undo, @doi + + if @doi.audits.last.undo + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability, detail: true } + + render json: V3::DataciteDoiSerializer.new(@doi, options).serialized_json, + status: :ok + else + # logger.error @doi.errors.messages + render json: serialize_errors(@doi.errors.messages, uid: @doi.uid), + include: @include, + status: :unprocessable_entity + end + end + + def destroy + @doi = DataciteDoi.where(doi: params[:id]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + + authorize! :destroy, @doi + + if @doi.draft? + if @doi.destroy + head :no_content + else + logger.error @doi.errors.inspect + render json: serialize_errors(@doi.errors, uid: @doi.uid), + status: :unprocessable_entity + end + else + response.headers["Allow"] = "HEAD, GET, POST, PATCH, PUT, OPTIONS" + render json: { + errors: [{ status: "405", title: "Method not allowed" }], + }.to_json, + status: :method_not_allowed + end + end + + def random + if params[:prefix].present? + dois = + generate_random_dois( + params[:prefix], + number: params[:number], size: params[:size], + ) + render json: { dois: dois }.to_json + else + render json: { + errors: [ + { status: "422", title: "Parameter prefix is required" }, + ], + }.to_json, + status: :unprocessable_entity + end + end + + def get_url + @doi = DataciteDoi.where(doi: params[:id]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + + authorize! :get_url, @doi + + if !@doi.is_registered_or_findable? || + %w[europ].include?(@doi.provider_id) || + @doi.type == "OtherDoi" + url = @doi.url + head :no_content && return if url.blank? + else + response = @doi.get_url + + if response.status == 200 + url = response.body.dig("data", "values", 0, "data", "value") + elsif response.status == 400 && + response.body.dig("errors", 0, "title", "responseCode") == 301 + response = + OpenStruct.new( + status: 403, + body: { + "errors" => [ + { + "status" => 403, + "title" => "SERVER NOT RESPONSIBLE FOR HANDLE", + }, + ], + }, + ) + url = nil + else + url = nil + end + end + + if url.present? + render json: { url: url }.to_json, status: :ok + else + render json: response.body.to_json, + status: response.status || :bad_request + end + end + + def get_dois + authorize! :get_urls, Doi + + client = + Client.where("datacentre.symbol = ?", current_user.uid.upcase).first + client_prefix = client.prefixes.first + head :no_content && return if client_prefix.blank? + + dois = + DataciteDoi.get_dois( + prefix: client_prefix.uid, + username: current_user.uid.upcase, + password: current_user.password, + ) + if dois.length.positive? + render json: { dois: dois }.to_json, status: :ok + else + head :no_content + end + end + + def set_url + authorize! :set_url, Doi + DataciteDoi.set_url + + render json: { message: "Adding missing URLs queued." }.to_json, status: :ok + end + + # legacy method + def status + render json: { message: "Not Implemented." }.to_json, + status: :not_implemented + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + + @include = @include & %i[client media] + else + @include = [] + end + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + # alternateIdentifiers as alias for identifiers + # easier before strong_parameters are checked + if params.dig(:data, :attributes).present? && + params.dig(:data, :attributes, :identifiers).blank? + params[:data][:attributes][:identifiers] = + Array.wrap(params.dig(:data, :attributes, :alternateIdentifiers)). + map do |a| + { + identifier: a[:alternateIdentifier], + identifierType: a[:alternateIdentifierType], + } + end + end + + attributes = [ + :doi, + :confirmDoi, + :url, + :titles, + { titles: %i[title titleType lang] }, + :publisher, + :publicationYear, + :created, + :prefix, + :suffix, + :types, + { + types: %i[ + resourceTypeGeneral + resourceType + schemaOrg + bibtex + citeproc + ris + ], + }, + :dates, + { dates: %i[date dateType dateInformation] }, + :subjects, + { subjects: %i[subject subjectScheme schemeUri valueUri lang] }, + :landingPage, + { + landingPage: [ + :checked, + :url, + :status, + :contentType, + :error, + :redirectCount, + { redirectUrls: [] }, + :downloadLatency, + :hasSchemaOrg, + :schemaOrgId, + { schemaOrgId: [] }, + :dcIdentifier, + :citationDoi, + :bodyHasPid, + ], + }, + :contentUrl, + { contentUrl: [] }, + :sizes, + { sizes: [] }, + :formats, + { formats: [] }, + :language, + :descriptions, + { descriptions: %i[description descriptionType lang] }, + :rightsList, + { + rightsList: %i[ + rights + rightsUri + rightsIdentifier + rightsIdentifierScheme + schemeUri + lang + ], + }, + :xml, + :regenerate, + :source, + :version, + :metadataVersion, + :schemaVersion, + :state, + :isActive, + :reason, + :registered, + :updated, + :mode, + :event, + :regenerate, + :should_validate, + :client, + :creators, + { + creators: [ + :nameType, + { + nameIdentifiers: %i[nameIdentifier nameIdentifierScheme schemeUri], + }, + :name, + :givenName, + :familyName, + { + affiliation: %i[ + name + affiliationIdentifier + affiliationIdentifierScheme + schemeUri + ], + }, + :lang, + ], + }, + :contributors, + { + contributors: [ + :nameType, + { + nameIdentifiers: %i[nameIdentifier nameIdentifierScheme schemeUri], + }, + :name, + :givenName, + :familyName, + { + affiliation: %i[ + name + affiliationIdentifier + affiliationIdentifierScheme + schemeUri + ], + }, + :contributorType, + :lang, + ], + }, + :identifiers, + { identifiers: %i[identifier identifierType] }, + :alternateIdentifiers, + { alternateIdentifiers: %i[alternateIdentifier alternateIdentifierType] }, + :relatedIdentifiers, + { + relatedIdentifiers: %i[ + relatedIdentifier + relatedIdentifierType + relationType + relatedMetadataScheme + schemeUri + schemeType + resourceTypeGeneral + relatedMetadataScheme + schemeUri + schemeType + ], + }, + :fundingReferences, + { + fundingReferences: %i[ + funderName + funderIdentifier + funderIdentifierType + awardNumber + awardUri + awardTitle + ], + }, + :geoLocations, + { + geoLocations: [ + { geoLocationPoint: %i[pointLongitude pointLatitude] }, + { + geoLocationBox: %i[ + westBoundLongitude + eastBoundLongitude + southBoundLatitude + northBoundLatitude + ], + }, + :geoLocationPlace, + ], + }, + :container, + { + container: %i[ + type + identifier + identifierType + title + volume + issue + firstPage + lastPage + ], + }, + :published, + :downloadsOverTime, + { downloadsOverTime: %i[yearMonth total] }, + :viewsOverTime, + { viewsOverTime: %i[yearMonth total] }, + :citationsOverTime, + { citationsOverTime: %i[year total] }, + :citationCount, + :downloadCount, + :partCount, + :partOfCount, + :referenceCount, + :versionCount, + :versionOfCount, + :viewCount, + ] + relationships = [{ client: [data: %i[type id]] }] + + # default values for attributes stored as JSON + defaults = { + data: { + titles: [], + descriptions: [], + types: {}, + container: {}, + dates: [], + subjects: [], + rightsList: [], + creators: [], + contributors: [], + sizes: [], + formats: [], + contentUrl: [], + identifiers: [], + relatedIdentifiers: [], + fundingReferences: [], + geoLocations: [], + }, + } + + p = + params.require(:data).permit( + :type, + :id, + attributes: attributes, relationships: relationships, + ). + reverse_merge(defaults) + client_id = + p.dig("relationships", "client", "data", "id") || + current_user.try(:client_id) + p = p.fetch("attributes").merge(client_id: client_id) + + # extract attributes from xml field and merge with attributes provided directly + xml = + p[:xml].present? ? Base64.decode64(p[:xml]).force_encoding("UTF-8") : nil + + if xml.present? + # remove optional utf-8 bom + xml.gsub!("\xEF\xBB\xBF", "") + + # remove leading and trailing whitespace + xml = xml.strip + end + + Array.wrap(params[:creators])&.each do |c| + if c[:nameIdentifiers]&.respond_to?(:keys) + fail( + ActionController::UnpermittedParameters, + ["nameIdentifiers must be an Array"], + ) + end + end + + Array.wrap(params[:contributors])&.each do |c| + if c[:nameIdentifiers]&.respond_to?(:keys) + fail( + ActionController::UnpermittedParameters, + ["nameIdentifiers must be an Array"], + ) + end + end + + meta = xml.present? ? parse_xml(xml, doi: p[:doi]) : {} + p[:schemaVersion] = + if METADATA_FORMATS.include?(meta["from"]) + LAST_SCHEMA_VERSION + else + p[:schemaVersion] + end + xml = meta["string"] + + # if metadata for DOIs from other registration agencies are not found + fail ActiveRecord::RecordNotFound if meta["state"] == "not_found" + + read_attrs = [ + p[:creators], + p[:contributors], + p[:titles], + p[:publisher], + p[:publicationYear], + p[:types], + p[:descriptions], + p[:container], + p[:sizes], + p[:formats], + p[:version], + p[:language], + p[:dates], + p[:identifiers], + p[:relatedIdentifiers], + p[:fundingReferences], + p[:geoLocations], + p[:rightsList], + p[:subjects], + p[:contentUrl], + p[:schemaVersion], + ].compact + + # generate random DOI if no DOI is provided + # make random DOI predictable in test + if p[:doi].blank? && p[:prefix].present? && Rails.env.test? + p[:doi] = generate_random_dois(p[:prefix], number: 123_456).first + elsif p[:doi].blank? && p[:prefix].present? + p[:doi] = generate_random_dois(p[:prefix]).first + end + + # replace DOI, but otherwise don't touch the XML + # use Array.wrap(read_attrs.first) as read_attrs may also be [[]] + if meta["from"] == "datacite" && Array.wrap(read_attrs.first).blank? + xml = replace_doi(xml, doi: p[:doi] || meta["doi"]) + elsif xml.present? || Array.wrap(read_attrs.first).present? + regenerate = true + end + + p[:xml] = xml if xml.present? + + read_attrs_keys = %i[ + url + creators + contributors + titles + publisher + publicationYear + types + descriptions + container + sizes + formats + language + dates + identifiers + relatedIdentifiers + fundingReferences + geoLocations + rightsList + agency + subjects + contentUrl + schemaVersion + ] + + # merge attributes from xml into regular attributes + # make sure we don't accidentally set any attributes to nil + read_attrs_keys.each do |attr| + if p.has_key?(attr) || meta.has_key?(attr.to_s.underscore) + p.merge!( + attr.to_s.underscore => + p[attr] || meta[attr.to_s.underscore] || p[attr], + ) + end + end + + # handle version metadata + if p.has_key?(:version) || meta["version_info"].present? + p[:version_info] = p[:version] || meta["version_info"] + end + + # only update landing_page info if something is received via API to not overwrite existing data + p[:landing_page] = p[:landingPage] if p[:landingPage].present? + + p.merge(regenerate: p[:regenerate] || regenerate).except( + # ignore camelCase keys, and read-only keys + :confirmDoi, + :prefix, + :suffix, + :publicationYear, + :alternateIdentifiers, + :rightsList, + :relatedIdentifiers, + :fundingReferences, + :geoLocations, + :metadataVersion, + :schemaVersion, + :state, + :mode, + :isActive, + :landingPage, + :created, + :registered, + :updated, + :published, + :lastLandingPage, + :version, + :lastLandingPageStatus, + :lastLandingPageStatusCheck, + :lastLandingPageStatusResult, + :lastLandingPageContentType, + :contentUrl, + :viewsOverTime, + :downloadsOverTime, + :citationsOverTime, + :citationCount, + :downloadCount, + :partCount, + :partOfCount, + :referenceCount, + :versionCount, + :versionOfCount, + :viewCount, + ) + end + + def set_raven_context + return nil if params.dig(:data, :attributes, :xml).blank? + + Raven.extra_context metadata: + Base64.decode64(params.dig(:data, :attributes, :xml)) + end +end diff --git a/app/controllers/v3/events_controller.rb b/app/controllers/v3/events_controller.rb new file mode 100644 index 000000000..f593cba5f --- /dev/null +++ b/app/controllers/v3/events_controller.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +class V3::EventsController < ApplicationController + include Identifiable + + include Facetable + + include BatchLoaderHelper + + prepend_before_action :authenticate_user!, except: %i[index show] + before_action :detect_crawler + before_action :load_event, only: %i[show] + before_action :set_include, only: %i[index show create update] + authorize_resource only: %i[destroy] + + def create + @event = + Event.where(subj_id: safe_params[:subj_id]).where( + obj_id: safe_params[:obj_id], + ). + where(source_id: safe_params[:source_id]). + where(relation_type_id: safe_params[:relation_type_id]). + first + exists = @event.present? + + # create event if it doesn't exist already + @event = Event.new(safe_params.except(:format)) if @event.blank? + + authorize! :create, @event + + if @event.update(safe_params) + options = {} + options[:is_collection] = false + + render json: V3::EventSerializer.new(@event, options).serialized_json, + status: exists ? :ok : :created + else + logger.error @event.errors.inspect + errors = + @event.errors.full_messages.map do |message| + { status: 422, title: message } + end + render json: { errors: errors }, status: :unprocessable_entity + end + end + + def update + @event = Event.where(uuid: params[:id]).first + exists = @event.present? + + # create event if it doesn't exist already + @event = Event.new(safe_params.except(:format)) if @event.blank? + + authorize! :update, @event + + if @event.update(safe_params) + options = {} + options[:is_collection] = false + + render json: V3::EventSerializer.new(@event, options).serialized_json, + status: exists ? :ok : :created + else + logger.error @event.errors.inspect + errors = + @event.errors.full_messages.map do |message| + { status: 422, title: message } + end + render json: { errors: errors }, status: :unprocessable_entity + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::EventSerializer.new(@event, options).serialized_json, + status: :ok + end + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "obj_id" + { "obj_id" => { order: "asc" } } + when "-obj_id" + { "obj_id" => { order: "desc" } } + when "total" + { "total" => { order: "asc" } } + when "-total" + { "total" => { order: "desc" } } + when "created" + { created_at: { order: "asc" } } + when "-created" + { created_at: { order: "desc" } } + when "updated" + { updated_at: { order: "asc" } } + when "-updated" + { updated_at: { order: "desc" } } + when "relation_type_id" + { relation_type_id: { order: "asc" } } + else + { updated_at: { order: "asc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + Event.find_by_id(params[:id]) + elsif params[:ids].present? + Event.find_by_id(params[:ids], page: page, sort: sort) + else + Event.query( + params[:query], + subj_id: params[:subj_id], + obj_id: params[:obj_id], + source_doi: params[:source_doi], + target_doi: params[:target_doi], + doi: params[:doi_id] || params[:doi], + orcid: params[:orcid], + prefix: params[:prefix], + subtype: params[:subtype], + citation_type: params[:citation_type], + source_id: params[:source_id], + registrant_id: params[:registrant_id], + relation_type_id: params[:relation_type_id], + source_relation_type_id: params[:source_relation_type_id], + target_relation_type_id: params[:target_relation_type_id], + issn: params[:issn], + publication_year: params[:publication_year], + occurred_at: params[:occurred_at], + year_month: params[:year_month], + aggregations: params[:aggregations], + unique: params[:unique], + state_event: params[:state], + scroll_id: params[:scroll_id], + page: page, + sort: sort, + ) + end + + if page[:scroll].present? + results = response.results + total = response.total + else + total = response.results.total + total_for_pages = + page[:cursor].nil? ? [total.to_f, 10_000].min : total.to_f + total_pages = + page[:size].positive? ? (total_for_pages / page[:size]).ceil : 0 + end + + if page[:scroll].present? + options = {} + options[:meta] = { + total: total, "scroll-id" => response.scroll_id + }.compact + options[:links] = { + self: request.original_url, + next: + if results.size < page[:size] || page[:size] == 0 + nil + else + request.base_url + "/events?" + + { + "scroll-id" => response.scroll_id, + "page[scroll]" => page[:scroll], + "page[size]" => page[:size], + }.compact. + to_query + end, + }.compact + options[:is_collection] = true + + render json: V3::EventSerializer.new(results, options).serialized_json, + status: :ok + else + sources = + if total.positive? + facet_by_source(response.response.aggregations.sources.buckets) + end + prefixes = + if total.positive? + facet_by_source(response.response.aggregations.prefixes.buckets) + end + citation_types = + if total.positive? + facet_by_citation_type( + response.response.aggregations.citation_types.buckets, + ) + end + relation_types = + if total.positive? + facet_by_relation_type( + response.response.aggregations.relation_types.buckets, + ) + end + registrants = + if total.positive? + facet_by_registrants( + response.response.aggregations.registrants.buckets, + ) + end + + results = response.results + + options = {} + options[:include] = @include + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: + page[:cursor].nil? && page[:number].present? ? page[:number] : nil, + sources: sources, + prefixes: prefixes, + "citationTypes" => citation_types, + "relationTypes" => relation_types, + registrants: registrants, + }.compact + + options[:links] = { + self: request.original_url, + next: + if results.size < page[:size] || page[:size] == 0 + nil + else + request.base_url + "/events?" + + { + "query" => params[:query], + "subj-id" => params[:subj_id], + "obj-id" => params[:obj_id], + "doi" => params[:doi], + "orcid" => params[:orcid], + "prefix" => params[:prefix], + "subtype" => params[:subtype], + "citation_type" => params[:citation_type], + "source-id" => params[:source_id], + "relation-type-id" => params[:relation_type_id], + "issn" => params[:issn], + "registrant-id" => params[:registrant_id], + "publication-year" => params[:publication_year], + "year-month" => params[:year_month], + "page[cursor]" => page[:cursor] ? make_cursor(results) : nil, + "page[number]" => + if page[:cursor].nil? && page[:number].present? + page[:number] + 1 + end, + "page[size]" => page[:size], + }.compact. + to_query + end, + }.compact + + options[:is_collection] = true + + render json: V3::EventSerializer.new(results, options).serialized_json, + status: :ok + end + end + + def destroy + @event = Event.where(uuid: params[:id]).first + fail ActiveRecord::RecordNotFound if @event.blank? + + if @event.destroy + head :no_content + else + errors = + @event.errors.full_messages.map do |message| + { status: 422, title: message } + end + render json: { errors: errors }, status: :unprocessable_entity + end + end + + protected + def load_event + response = Event.find_by_id(params[:id]) + @event = response.results.first + fail ActiveRecord::RecordNotFound if @event.blank? + end + + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[subj obj] + else + @include = [] + end + end + + private + def safe_params + nested_params = [ + :id, + :name, + { author: ["givenName", "familyName", :name] }, + :funder, + { funder: ["@id", "@type", :name] }, + "alternateName", + "proxyIdentifiers", + { "proxyIdentifiers" => [] }, + :publisher, + :periodical, + {  periodical: %i[type id name issn] }, + "volumeNumber", + "issueNumber", + :pagination, + :issn, + "datePublished", + "dateModified", + "registrantId", + :doi, + :url, + :type, + ] + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: [ + :id, + "messageAction", + "sourceToken", + :callback, + "subjId", + "objId", + "relationTypeId", + "sourceId", + :total, + :license, + "occurredAt", + :subj, + :obj, + { subj: nested_params, obj: nested_params }, + ], + keys: { id: :uuid }, + ) + end +end diff --git a/app/controllers/v3/exports_controller.rb b/app/controllers/v3/exports_controller.rb new file mode 100644 index 000000000..9d373eae4 --- /dev/null +++ b/app/controllers/v3/exports_controller.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +class V3::ExportsController < ApplicationController + include ActionController::MimeResponds + + before_action :authenticate_user_with_basic_auth! + + MEMBER_TYPES = { + "consortium" => "Consortium", + "consortium_organization" => "Consortium Organization", + "direct_member" => "Direct Member", + "member_only" => "Member Only", + "contractual_member" => "Contractual Member", + "registration_agency" => "DOI Registration Agency", + }.freeze + + REGIONS = { + "APAC" => "Asia Pacific", "EMEA" => "EMEA", "AMER" => "Americas" + }.freeze + + def contacts + authorize! :export, :contacts + + headers = %w[uid fabricaAccountId fabricaId email firstName lastName type createdAt modifiedAt deletedAt isActive] + + rows = Contact.all.reduce([]) do |sum, contact| + row = { + "uid" => contact.uid, + "fabricaAccountId" => contact.provider.symbol, + "fabricaId" => contact.provider.symbol + "-" + contact.email, + "email" => contact.email, + "firstName" => contact.given_name, + "lastName" => contact.family_name, + "type" => contact.role_name.join(";"), + "createdAt" => contact.created_at.try(:iso8601), + "modifiedAt" => contact.updated_at.try(:iso8601), + "deletedAt" => contact.deleted_at.try(:iso8601), + "isActive" => contact.deleted_at.blank?, + }.values + + sum << CSV.generate_line(row) + sum + end + + csv = [CSV.generate_line(headers)] + rows + filename = "contacts-#{Date.today}.csv" + send_data csv, filename: filename + end + + def organizations + authorize! :export, :organizations + + begin + # Loop through all providers + page = { size: 1_000, number: 1 } + response = + Provider.query( + nil, + page: page, + from_date: params[:from_date], + until_date: params[:until_date], + include_deleted: true, + ) + providers = response.results.to_a + + total = response.results.total + total_pages = page[:size] > 0 ? (total.to_f / page[:size]).ceil : 0 + + # keep going for all pages + page_num = 2 + while page_num <= total_pages + page = { size: 1_000, number: page_num } + response = + Provider.query( + nil, + page: page, + from_date: params[:from_date], + until_date: params[:until_date], + include_deleted: true, + ) + providers = providers + response.results.to_a + page_num += 1 + end + + headers = [ + "Name", + "fabricaAccountId", + "Parent Organization", + "Is Active", + "Organization Description", + "Website", + "Region", + "Focus Area", + "Sector", + "Member Type", + "Email", + "Group Email", + "billingStreet", + "Billing Zip/Postal Code", + "billingCity", + "Department", + "billingOrganization", + "billingStateCode", + "billingCountryCode", + "twitter", + "ROR", + "Fabrica Creation Date", + "Fabrica Modification Date", + "Fabrica Deletion Date", + ] + + csv = headers.to_csv + + providers.each do |provider| + row = { + accountName: provider.name, + fabricaAccountId: provider.symbol, + parentFabricaAccountId: + if provider.consortium_id.present? + provider.consortium_id.upcase + end, + isActive: provider.deleted_at.blank?, + accountDescription: provider.description, + accountWebsite: provider.website, + region: + provider.region.present? ? export_region(provider.region) : nil, + focusArea: provider.focus_area, + sector: provider.organization_type, + accountType: export_member_type(provider.member_type), + generalContactEmail: provider.system_email, + groupEmail: provider.group_email, + billingStreet: provider.billing_information.address, + billingPostalCode: provider.billing_information.post_code, + billingCity: provider.billing_information.city, + billingDepartment: provider.billing_information.department, + billingOrganization: provider.billing_information.organization, + billingStateCode: + if provider.billing_information.state.present? + provider.billing_information.state.split("-").last + end, + billingCountryCode: provider.billing_information.country, + twitter: provider.twitter_handle, + rorId: provider.ror_id, + created: export_date(provider.created), + modified: export_date(provider.updated), + deleted: + if provider.deleted_at.present? + export_date(provider.deleted_at) + end, + }.values + + csv += CSV.generate_line row + end + + filename = + if params[:until_date] + "organizations-#{params[:until_date]}.csv" + else + "organizations-#{Date.today}.csv" + end + + send_data csv, filename: filename + rescue StandardError, + Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + render json: { "errors" => { "title" => e.message } }.to_json, + status: :bad_request + end + end + + def repositories + # authorize! :export, :repositories + + # Loop through all clients + page = { size: 1_000, number: 1 } + response = + Client.query( + nil, + page: page, + from_date: params[:from_date], + until_date: params[:until_date], + include_deleted: true, + ) + clients = response.results.to_a + + total = response.results.total + total_pages = page[:size] > 0 ? (total.to_f / page[:size]).ceil : 0 + + # keep going for all pages + page_num = 2 + while page_num <= total_pages + page = { size: 1_000, number: page_num } + response = + Client.query( + nil, + page: page, + from_date: params[:from_date], + until_date: params[:until_date], + include_deleted: true, + ) + clients = clients + response.results.to_a + page_num += 1 + end + + logger.warn "Exporting #{clients.length} repositories." + + # Get doi counts via DOIs query and combine next to clients. + response = + DataciteDoi.query( + nil, + state: "registered,findable", + page: { size: 0, number: 1 }, + totals_agg: "client_export", + ) + + client_totals = {} + totals_buckets = response.aggregations.clients_totals.buckets + totals_buckets.each do |totals| + client_totals[totals["key"]] = { + "count" => totals["doc_count"], + "this_year" => totals.this_year.buckets[0]["doc_count"], + "last_year" => totals.last_year.buckets[0]["doc_count"], + } + end + + draft_response = + DataciteDoi.query( + nil, + state: "draft", + page: { size: 0, number: 1 }, + totals_agg: "client_export", + ) + + draft_client_totals = {} + draft_totals_buckets = draft_response.aggregations.clients_totals.buckets + draft_totals_buckets.each do |totals| + draft_client_totals[totals["key"]] = { + "count" => totals["doc_count"], + "this_year" => totals.this_year.buckets[0]["doc_count"], + "last_year" => totals.last_year.buckets[0]["doc_count"], + } + end + + headers = [ + "Repository Name", + "Repository ID", + "Organization", + "isActive", + "Description", + "Repository URL", + "generalContactEmail", + "serviceContactEmail", + "serviceContactGivenName", + "serviceContactFamilyName", + "Fabrica Creation Date", + "Fabrica Modification Date", + "Fabrica Deletion Date", + "doisCurrentYear", + "doisPreviousYear", + "doisTotal", + "doisDraftTotal", + "doisDbTotal", + "doisMissing" + ] + + csv = headers.to_csv + + # get doi counts from database + dois_by_client = DataciteDoi.group(:datacentre).count + + clients.each do |client| + # Limit for salesforce default of max 80 chars + name = + +client.name.truncate(80) + # Clean the name to remove quotes, which can break csv parsers + name.gsub!(/["']/, "") + + db_total = dois_by_client[client.id.to_i].to_i + es_total = client_totals[client.uid] ? client_totals[client.uid]["count"] : 0 + es_draft_total = draft_client_totals[client.uid] ? draft_client_totals[client.uid]["count"] : 0 + + row = { + accountName: name, + fabricaAccountId: client.symbol, + parentFabricaAccountId: + client.provider.present? ? client.provider.symbol : nil, + isActive: client.deleted_at.blank?, + accountDescription: client.description, + accountWebsite: client.url, + generalContactEmail: client.system_email, + serviceContactEmail: + client.service_contact.present? ? client.service_contact.email : nil, + serviceContactGivenName: + if client.service_contact.present? + client.service_contact.given_name + end, + serviceContactFamilyName: + if client.service_contact.present? + client.service_contact.family_name + end, + created: export_date(client.created), + modified: export_date(client.updated), + deleted: + client.deleted_at.present? ? export_date(client.deleted_at) : nil, + doisCountCurrentYear: + if client_totals[client.uid] + client_totals[client.uid]["this_year"] + else + 0 + end, + doisCountPreviousYear: + if client_totals[client.uid] + client_totals[client.uid]["last_year"] + else + 0 + end, + doisCountTotal: es_total, + doisCountDraftTotal: es_draft_total, + doisDbTotal: db_total, + doisMissing: db_total - (es_total + es_draft_total), + }.values + + csv += CSV.generate_line row + end + + filename = + if params[:until_date] + "repositories-#{params[:until_date]}.csv" + else + "repositories-#{Date.today}.csv" + end + + send_data csv, filename: filename + rescue StandardError, + Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + render json: { "errors" => { "title" => e.message } }.to_json, + status: :bad_request + end + + def import_dois_not_indexed + ImportDoisNotIndexedJob.perform_later(nil) + render plain: "OK", + status: 202, + content_type: "text/plain" + end + + def export_date(date) + DateTime.strptime(date, "%Y-%m-%dT%H:%M:%S").strftime( + "%d/%m/%YT%H:%M:%S.%3NUTC%:z", + ) + end + + def export_member_type(member_type) + MEMBER_TYPES[member_type] + end + + def export_region(region) + REGIONS[region] + end +end diff --git a/app/controllers/v3/heartbeat_controller.rb b/app/controllers/v3/heartbeat_controller.rb new file mode 100644 index 000000000..afc8caf3b --- /dev/null +++ b/app/controllers/v3/heartbeat_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class V3::HeartbeatController < ApplicationController + def index + heartbeat = Heartbeat.new + render plain: heartbeat.string, + status: heartbeat.status, + content_type: "text/plain" + end +end diff --git a/app/controllers/v3/index_controller.rb b/app/controllers/v3/index_controller.rb new file mode 100644 index 000000000..89ac77c9f --- /dev/null +++ b/app/controllers/v3/index_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class V3::IndexController < ApplicationController + include ActionController::MimeResponds + + def index + render plain: ENV["SITE_TITLE"] + end + + def show + doi = Doi.where(doi: params[:id], aasm_state: "findable").first + fail ActiveRecord::RecordNotFound if doi.blank? + + respond_to do |format| + format.html do + # forward to URL registered in handle system for no content negotiation + redirect_to doi.url, status: :see_other + end + format.citation do + # extract optional style and locale from header + headers = + request.headers["HTTP_ACCEPT"].to_s.gsub(/\s+/, "").split(";", 3). + reduce({}) do |sum, item| + sum[:style] = item.split("=").last if item.start_with?("style") + sum[:locale] = item.split("=").last if item.start_with?("locale") + sum + end + render citation: doi, + style: params[:style] || headers[:style] || "apa", + locale: params[:locale] || headers[:locale] || "en-US" + end + format.any( + :bibtex, + :citeproc, + :codemeta, + :crosscite, + :datacite, + :datacite_json, + :jats, + :ris, + :schema_org, + ) { render request.format.to_sym => doi } + header = %w[ + doi + url + registered + state + resourceTypeGeneral + resourceType + title + author + publisher + publicationYear + ] + format.csv { render request.format.to_sym => doi, header: header } + end + rescue ActionController::UnknownFormat, ActionController::RoutingError + # forward to URL registered in handle system for unrecognized format + redirect_to doi.url, status: :see_other + end + + def routing_error + fail ActiveRecord::RecordNotFound + end + + def method_not_allowed + response.set_header("Allow", "POST") + render json: { + "message": "This endpoint only supports POST requests.", + }.to_json, + status: :method_not_allowed + end +end diff --git a/app/controllers/v3/media_controller.rb b/app/controllers/v3/media_controller.rb new file mode 100644 index 000000000..ceaeb9a88 --- /dev/null +++ b/app/controllers/v3/media_controller.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +class V3::MediaController < ApplicationController + before_action :set_doi + before_action :set_media, only: %i[show update destroy] + before_action :set_include + before_action :authenticate_user! + + def index + collection = @doi.media + total = @doi.cached_media_count.reduce(0) { |sum, d| sum + d[:count].to_i } + + page = page_from_params(params) + total_pages = (total.to_f / page[:size]).ceil + + order = + case params[:sort] + when "name" + "dataset.doi" + when "-name" + "dataset.doi DESC" + when "created" + "media.created" + else + "media.created DESC" + end + + @media = collection.order(order).page(page[:number]).per(page[:size]) + + options = {} + options[:meta] = { + total: total, "totalPages" => total_pages, page: page[:number].to_i + }.compact + + options[:links] = { + self: request.original_url, + next: + if @media.blank? + nil + else + request.base_url + "/media?" + + { + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: V3::MediaSerializer.new(@media, options).serialized_json, + status: :ok + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::MediaSerializer.new(@media, options).serialized_json, + status: :ok + end + + def create + authorize! :update, @doi + + @media = Media.new(safe_params.merge(doi: @doi)) + + if @media.save + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::MediaSerializer.new(@media, options).serialized_json, + status: :created + else + Rails.logger.error @media.errors.inspect + render json: serialize_errors(@media.errors), + status: :unprocessable_entity + end + end + + def update + authorize! :update, @doi + + if @media.update(safe_params.merge(doi: @doi)) + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::MediaSerializer.new(@media, options).serialized_json, + status: :ok + else + Rails.logger.error @media.errors.inspect + render json: serialize_errors(@media.errors), + status: :unprocessable_entity + end + end + + def destroy + authorize! :update, @doi + + if @media.destroy + head :no_content + else + Rails.logger.error @media.errors.inspect + render json: serialize_errors(@media.errors), + status: :unprocessable_entity + end + end + + protected + def set_doi + @doi = DataciteDoi.where(doi: params[:datacite_doi_id]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + end + + def set_media + id = Base32::URL.decode(CGI.unescape(params[:id])) + fail ActiveRecord::RecordNotFound if id.blank? + + @media = Media.where(id: id.to_i).first + fail ActiveRecord::RecordNotFound if @media.blank? + end + + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[doi] + else + @include = [] + end + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: ["mediaType", :url], keys: { "mediaType" => :media_type }, + ) + end +end diff --git a/app/controllers/v3/metadata_controller.rb b/app/controllers/v3/metadata_controller.rb new file mode 100644 index 000000000..94c12761a --- /dev/null +++ b/app/controllers/v3/metadata_controller.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +class V3::MetadataController < ApplicationController + before_action :set_doi + before_action :set_metadata, only: %i[show destroy] + before_action :set_include + before_action :authenticate_user! + + def index + @doi = DataciteDoi.where(doi: params[:doi_id]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + + collection = @doi.metadata + total = + @doi.cached_metadata_count.reduce(0) { |sum, d| sum + d[:count].to_i } + + page = page_from_params(params) + total_pages = (total.to_f / page[:size]).ceil + + order = + case params[:sort] + when "name" + "dataset.doi" + when "-name" + "dataset.doi DESC" + when "created" + "metadata.created" + else + "metadata.created DESC" + end + + @metadata = collection.order(order).page(page[:number]).per(page[:size]) + + options = {} + options[:meta] = { + total: total, "totalPages" => total_pages, page: page[:number].to_i + }.compact + + options[:links] = { + self: request.original_url, + next: + if @metadata.blank? + nil + else + request.base_url + "/media?" + + { + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: V3::MetadataSerializer.new(@metadata, options).serialized_json, + status: :ok + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::MetadataSerializer.new(@metadata, options).serialized_json, + status: :ok + end + + def create + authorize! :update, @doi + + # convert back to plain xml + xml = safe_params[:xml].present? ? Base64.decode64(safe_params[:xml]) : nil + @metadata = Metadata.new(safe_params.merge(doi: @doi, xml: xml)) + + if @metadata.save + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::MetadataSerializer.new(@metadata, options).serialized_json, + status: :created + else + Rails.logger.error @metadata.errors.inspect + render json: serialize_errors(@metadata.errors), + status: :unprocessable_entity + end + end + + def destroy + authorize! :update, @doi + + if @doi.draft? + if @metadata.destroy + head :no_content + else + Rails.logger.error @metadata.errors.inspect + render json: serialize_errors(@metadata.errors), + status: :unprocessable_entity + end + else + response.headers["Allow"] = "HEAD, GET, POST, PATCH, PUT, OPTIONS" + render json: { + errors: [{ status: "405", title: "Method not allowed" }], + }.to_json, + status: :method_not_allowed + end + end + + protected + def set_doi + @doi = DataciteDoi.where(doi: params[:datacite_doi_id]).first + fail ActiveRecord::RecordNotFound if @doi.blank? + end + + def set_metadata + id = Base32::URL.decode(CGI.unescape(params[:id])) + fail ActiveRecord::RecordNotFound if id.blank? + + @metadata = Metadata.where(id: id.to_i).first + fail ActiveRecord::RecordNotFound if @metadata.blank? + end + + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[doi] + else + @include = [] + end + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: %i[xml], + ) + end +end diff --git a/app/controllers/v3/prefixes_controller.rb b/app/controllers/v3/prefixes_controller.rb new file mode 100644 index 000000000..4c661b4ac --- /dev/null +++ b/app/controllers/v3/prefixes_controller.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +class V3::PrefixesController < ApplicationController + before_action :set_prefix, only: %i[show update destroy] + before_action :authenticate_user! + before_action :set_include + load_and_authorize_resource except: %i[index show totals] + around_action :skip_bullet, only: %i[index], if: -> { defined?(Bullet) } + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "name" + { "uid" => { order: "asc", unmapped_type: "keyword" } } + when "-name" + { "uid" => { order: "desc", unmapped_type: "keyword" } } + when "created" + { created_at: { order: "asc" } } + when "-created" + { created_at: { order: "desc" } } + else + { "uid" => { order: "asc", unmapped_type: "keyword" } } + end + + page = page_from_params(params) + + response = + if params[:id].present? + Prefix.find_by_id(params[:id]) + else + Prefix.query( + params[:query], + year: params[:year], + state: params[:state], + provider_id: params[:provider_id], + client_id: params[:client_id], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size].positive? ? (total.to_f / page[:size]).ceil : 0 + years = + if total.positive? + facet_by_year(response.response.aggregations.years.buckets) + end + states = + if total.positive? + facet_by_key(response.response.aggregations.states.buckets) + end + providers = + if total.positive? + facet_by_combined_key( + response.response.aggregations.providers.buckets, + ) + end + clients = + if total.positive? + facet_by_combined_key(response.response.aggregations.clients.buckets) + end + + prefixes = response.results + + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + years: years, + states: states, + providers: providers, + clients: clients, + }.compact + + options[:links] = { + self: request.original_url, + next: + if prefixes.blank? + nil + else + request.base_url + "/prefixes?" + + { + query: params[:query], + prefix: params[:prefix], + year: params[:year], + provider_id: params[:provider_id], + client_id: params[:client_id], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: V3::PrefixSerializer.new(prefixes, options).serialized_json, + status: :ok + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::PrefixSerializer.new(@prefix, options).serialized_json, + status: :ok + end + + def create + @prefix = Prefix.new(safe_params) + authorize! :create, @prefix + + if @prefix.save + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: V3::PrefixSerializer.new(@prefix, options).serialized_json, + status: :created, + location: @prefix + else + logger.error @prefix.errors.inspect + render json: serialize_errors(@prefix.errors), + status: :unprocessable_entity + end + end + + def update + response.headers["Allow"] = "HEAD, GET, POST, OPTIONS" + render json: { + errors: [{ status: "405", title: "Method not allowed" }], + }.to_json, + status: :method_not_allowed + end + + def destroy + message = "Prefix #{@prefix.uid} deleted." + if @prefix.destroy + Rails.logger.warn message + head :no_content + else + Rails.logger.error @prefix.errors.inspect + render json: serialize_errors(@prefix.errors), + status: :unprocessable_entity + end + end + + def totals + return [] if params[:client_id].blank? + + page = { size: 0, number: 1 } + response = + Doi.query( + nil, + client_id: params[:client_id], + state: "findable,registered", + page: page, + totals_agg: "prefix", + ) + registrant = + prefixes_totals(response.response.aggregations.prefixes_totals.buckets) + + render json: registrant, status: :ok + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = + @include & %i[clients providers client_prefixes provider_prefixes] + else + @include = [] + end + end + + private + def set_prefix + @prefix = Prefix.where(uid: params[:id]).first + + # fallback to call handle server, i.e. for prefixes not from DataCite + unless @prefix.present? || Rails.env.test? + @prefix = Handle.where(id: params[:id]) + end + fail ActiveRecord::RecordNotFound if @prefix.blank? + end + + def safe_params + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: %i[id created_at], keys: { id: :uid }, + ) + end +end diff --git a/app/controllers/v3/provider_prefixes_controller Kopie.rb b/app/controllers/v3/provider_prefixes_controller Kopie.rb new file mode 100644 index 000000000..f9426a4dd --- /dev/null +++ b/app/controllers/v3/provider_prefixes_controller Kopie.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class V3::ProviderPrefixesController < ApplicationController + prepend_before_action :authenticate_user! + before_action :set_provider_prefix, only: %i[show update destroy] + before_action :set_include + authorize_resource except: %i[index show] + around_action :skip_bullet, only: %i[index], if: -> { defined?(Bullet) } + + def index + sort = + case params[:sort] + when "name" + { "prefix_id" => { order: "asc", unmapped_type: "keyword" } } + when "-name" + { "prefix_id" => { order: "desc", unmapped_type: "keyword" } } + when "created" + { created_at: { order: "asc" } } + when "-created" + { created_at: { order: "desc" } } + else + { created_at: { order: "desc" } } + end + + page = page_from_params(params) + + if params[:id].present? + response = ProviderPrefix.find_by_id(params[:id]) + else + response = + ProviderPrefix.query( + params[:query], + prefix_id: params[:prefix_id], + consortium_id: params[:consortium_id], + provider_id: params[:provider_id], + consortium_organization_id: params[:consortium_organization_id], + state: params[:state], + year: params[:year], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size].positive? ? (total.to_f / page[:size]).ceil : 0 + years = + if total.positive? + facet_by_year(response.aggregations.years.buckets) + end + states = + if total.positive? + facet_by_key(response.aggregations.states.buckets) + end + providers = + if total.positive? + facet_by_combined_key(response.aggregations.providers.buckets) + end + + provider_prefixes = response.results + + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + years: years, + states: states, + providers: providers, + }.compact + + options[:links] = { + self: request.original_url, + next: + if provider_prefixes.blank? + nil + else + request.base_url + "/provider_prefixes?" + + { + query: params[:query], + prefix: params[:prefix], + year: params[:year], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: + V3::ProviderPrefixSerializer.new(provider_prefixes, options). + serialized_json, + status: :ok + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: + V3::ProviderPrefixSerializer.new(@provider_prefix, options). + serialized_json, + status: :ok + end + + def create + @provider_prefix = ProviderPrefix.new(safe_params) + authorize! :create, @provider_prefix + + if @provider_prefix.save + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: + V3::ProviderPrefixSerializer.new(@provider_prefix, options). + serialized_json, + status: :created + else + # Rails.logger.error @provider_prefix.errors.inspect + render json: serialize_errors(@provider_prefix.errors), + status: :unprocessable_entity + end + end + + def update + response.headers["Allow"] = "HEAD, GET, POST, DELETE, OPTIONS" + render json: { + errors: [{ status: "405", title: "Method not allowed" }], + }.to_json, + status: :method_not_allowed + end + + def destroy + message = "Provider prefix #{@provider_prefix.uid} deleted." + if @provider_prefix.destroy + Rails.logger.warn message + head :no_content + else + # Rails.logger.error @provider_prefix.errors.inspect + render json: serialize_errors(@provider_prefix.errors, uid: @provider_prefix.uid), + status: :unprocessable_entity + end + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[provider prefix clients client_prefixes] + else + @include = [] + end + end + + private + def set_provider_prefix + @provider_prefix = ProviderPrefix.where(uid: params[:id]).first + fail ActiveRecord::RecordNotFound if @provider_prefix.blank? + end + + def safe_params + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: %i[id provider prefix], + ) + end +end diff --git a/app/controllers/v3/providers_controller.rb b/app/controllers/v3/providers_controller.rb new file mode 100644 index 000000000..e94adc21b --- /dev/null +++ b/app/controllers/v3/providers_controller.rb @@ -0,0 +1,467 @@ +# frozen_string_literal: true + +class V3::ProvidersController < ApplicationController + include ActionController::MimeResponds + include Countable + + prepend_before_action :authenticate_user! + before_action :set_provider, only: %i[show update destroy stats] + before_action :set_include + load_and_authorize_resource only: %i[update destroy] + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "name" + { "display_name.raw" => { order: "asc" } } + when "-name" + { "display_name.raw" => { order: "desc" } } + when "created" + { created: { order: "asc" } } + when "-created" + { created: { order: "desc" } } + else + { "display_name.raw" => { order: "asc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + Provider.find_by_id(params[:id]) + elsif params[:ids].present? + Provider.find_by_id(params[:ids], page: page, sort: sort) + else + Provider.query( + params[:query], + year: params[:year], + from_date: params[:from_date], + until_date: params[:until_date], + region: params[:region], + consortium_id: params[:consortium_id], + member_type: params[:member_type], + organization_type: params[:organization_type], + focus_area: params[:focus_area], + non_profit_status: params[:non_profit_status], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size] > 0 ? (total.to_f / page[:size]).ceil : 0 + + years = + if total > 0 + facet_by_key_as_string(response.response.aggregations.years.buckets) + end + regions = + if total > 0 + facet_by_region(response.response.aggregations.regions.buckets) + end + member_types = + if total > 0 + facet_by_key(response.response.aggregations.member_types.buckets) + end + organization_types = + if total > 0 + facet_by_key( + response.response.aggregations.organization_types.buckets, + ) + end + focus_areas = + if total > 0 + facet_by_key(response.response.aggregations.focus_areas.buckets) + end + non_profit_statuses = + if total > 0 + facet_by_key( + response.response.aggregations.non_profit_statuses.buckets, + ) + end + + @providers = response.results + respond_to do |format| + format.json do + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + years: years, + regions: regions, + "memberTypes" => member_types, + "organizationTypes" => organization_types, + "focusAreas" => focus_areas, + "nonProfitStatuses" => non_profit_statuses, + }.compact + + options[:links] = { + self: request.original_url, + next: + if @providers.blank? + nil + else + request.base_url + "/providers?" + + { + query: params[:query], + year: params[:year], + region: params[:region], + "member_type" => params[:member_type], + "organization_type" => params[:organization_type], + "focus-area" => params[:focus_area], + "non-profit-status" => params[:non_profit_status], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: sort, + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + options[:params] = { current_ability: current_ability } + + fields = fields_from_params(params) + if fields + render json: + ProviderSerializer.new( + @providers, + options.merge(fields: fields), + ). + serialized_json, + status: :ok + else + render json: + V3::ProviderSerializer.new(@providers, options). + serialized_json, + status: :ok + end + end + header = %w[ + accountName + fabricaAccountId + year + is_active + accountDescription + accountWebsite + region + country + logo_url + focusArea + organisation_type + accountType + generalContactEmail + groupEmail + technicalContactEmail + technicalContactGivenName + technicalContactFamilyName + secondaryTechnicalContactEmail + secondaryTechnicalContactGivenName + secondaryTechnicalContactFamilyName + serviceContactEmail + serviceContactGivenName + serviceContactFamilyName + secondaryServiceContactEmail + secondaryServiceContactGivenName + secondaryServiceContactFamilyName + votingContactEmail + votingContactGivenName + votingContactFamilyName + billingStreet + billingPostalCode + billingCity + department + billingOrganization + billingState + billingCountry + billingContactEmail + billingContactGivenName + billingontactFamilyName + secondaryBillingContactEmail + secondaryBillingContactGivenName + secondaryBillingContactFamilyName + twitter + ror_id + member_type + joined + created + updated + deleted_at + ] + format.csv do + render request.format.to_sym => response.records.to_a, header: header + end + end + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + if @provider.member_type == "consortium" + options[:meta] = { + "consortiumOrganizationCount" => + Array.wrap(@provider.consortium_organization_ids).length, + } + elsif %w[direct_member consortium_organization].include?( + @provider.member_type, + ) + options[:meta] = { + "repositoryCount" => Array.wrap(@provider.client_ids).length, + } + end + + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ProviderSerializer.new(@provider, options).serialized_json, + status: :ok + end + + def create + # generate random symbol if no symbol is provided + @provider = + Provider.new( + safe_params.reverse_merge(symbol: generate_random_provider_symbol), + ) + authorize! :create, @provider + + if @provider.save + @provider.send_welcome_email(responsible_id: current_user.uid) + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ProviderSerializer.new(@provider, options).serialized_json, + status: :ok + else + # Rails.logger.error @provider.errors.inspect + render json: serialize_errors(@provider.errors, uid: @provider.uid), + status: :unprocessable_entity + end + end + + def update + if @provider.update(safe_params) + options = {} + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::ProviderSerializer.new(@provider, options).serialized_json, + status: :ok + else + # Rails.logger.error @provider.errors.inspect + render json: serialize_errors(@provider.errors, uid: @provider.uid), + status: :unprocessable_entity + end + end + + # don't delete, but set deleted_at timestamp + # a provider with active clients or with prefixes can't be deleted + def destroy + if active_client_count(provider_id: @provider.symbol).positive? + message = "Can't delete provider that has active clients." + status = 400 + Rails.logger.warn message + render json: { + errors: [{ status: status.to_s, title: message }], + }.to_json, + status: status + elsif @provider.update(is_active: nil, deleted_at: Time.zone.now) + unless Rails.env.test? + @provider.send_delete_email(responsible_id: current_user.uid) + end + head :no_content + else + # Rails.logger.error @provider.errors.inspect + render json: serialize_errors(@provider.errors, uid: @provider.uid), + status: :unprocessable_entity + end + end + + def random + symbol = generate_random_provider_symbol + render json: { symbol: symbol }.to_json + end + + def totals + page = { size: 0, number: 1 } + + state = + if current_user.present? && current_user.is_admin_or_staff? && + params[:state].present? + params[:state] + else + "registered,findable" + end + response = + DataciteDoi.query(nil, state: state, page: page, totals_agg: "provider") + registrant = + providers_totals(response.response.aggregations.providers_totals.buckets) + + render json: registrant, status: :ok + end + + def stats + if params[:id] == "admin" + providers = provider_count(consortium_id: nil) + clients = client_count(provider_id: nil) + dois = doi_count(provider_id: nil) + # resource_types = resource_type_count(provider_id: nil) + # citations = nil # citation_count(provider_id: nil) + # views = nil # view_count(provider_id: nil) + # downloads = nil # download_count(provider_id: nil) + elsif @provider.member_type == "consortium" + providers = provider_count(consortium_id: params[:id]) + clients = client_count(consortium_id: params[:id]) + dois = doi_count(consortium_id: params[:id]) + # resource_types = resource_type_count(consortium_id: params[:id]) + # citations = citation_count(consortium_id: params[:id]) + # views = view_count(consortium_id: params[:id]) + # downloads = download_count(consortium_id: params[:id]) + else + providers = nil + clients = client_count(provider_id: params[:id]) + dois = doi_count(provider_id: params[:id]) + # resource_types = resource_type_count(provider_id: params[:id]) + # citations = citation_count(provider_id: params[:id]) + # views = view_count(provider_id: params[:id]) + # downloads = download_count(provider_id: params[:id]) + end + + meta = { + # downloads: downloads, + providers: providers, + clients: clients, + dois: dois, + }.compact + + render json: meta, status: :ok + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[consortium consortium_organizations contacts] + else + @include = [] + end + end + + def set_provider + @provider = + Provider.unscoped.where( + "allocator.role_name IN ('ROLE_FOR_PROFIT_PROVIDER', 'ROLE_CONTRACTUAL_PROVIDER', 'ROLE_CONSORTIUM' , 'ROLE_CONSORTIUM_ORGANIZATION', 'ROLE_ALLOCATOR', 'ROLE_ADMIN', 'ROLE_MEMBER', 'ROLE_REGISTRATION_AGENCY')", + ). + where(deleted_at: nil). + where(symbol: params[:id]). + first + fail ActiveRecord::RecordNotFound if @provider.blank? + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: [ + :name, + "displayName", + :symbol, + :logo, + :description, + :website, + :joined, + "globusUuid", + "organizationType", + "focusArea", + :consortium, + "systemEmail", + "groupEmail", + "isActive", + "passwordInput", + :country, + "billingInformation", + { + "billingInformation": [ + "postCode", + :state, + :city, + :address, + :department, + :organization, + :country, + ], + }, + "rorId", + "twitterHandle", + "memberType", + "nonProfitStatus", + "salesforceId", + "technicalContact", + { "technicalContact": [:email, "givenName", "familyName"] }, + "secondaryTechnicalContact", + { "secondaryTechnicalContact": [:email, "givenName", "familyName"] }, + "secondaryBillingContact", + { "secondaryBillingContact": [:email, "givenName", "familyName"] }, + "billingContact", + { "billingContact": [:email, "givenName", "familyName"] }, + "serviceContact", + { "serviceContact": [:email, "givenName", "familyName"] }, + "secondaryServiceContact", + { "secondaryServiceContact": [:email, "givenName", "familyName"] }, + "votingContact", + { "votingContact": [:email, "givenName", "familyName"] }, + ], + keys: { + "displayName" => :display_name, + "organizationType" => :organization_type, + "focusArea" => :focus_area, + country: :country_code, + "isActive" => :is_active, + "passwordInput" => :password_input, + "billingInformation" => :billing_information, + "postCode" => :post_code, + "rorId" => :ror_id, + "twitterHandle" => :twitter_handle, + "memberType" => :member_type, + "technicalContact" => :technical_contact, + "secondaryTechnicalContact" => :secondary_technical_contact, + "secondaryBillingContact" => :secondary_billing_contact, + "billingContact" => :billing_contact, + "serviceContact" => :service_contact, + "secondaryServiceContact" => :secondary_service_contact, + "votingContact" => :voting_contact, + "groupEmail" => :group_email, + "systemEmail" => :system_email, + "nonProfitStatus" => :non_profit_status, + "salesforceId" => :salesforce_id, + "globusUuid" => :globus_uuid, + }, + ) + end +end diff --git a/app/controllers/v3/repositories_controller.rb b/app/controllers/v3/repositories_controller.rb new file mode 100644 index 000000000..99cc8a0bc --- /dev/null +++ b/app/controllers/v3/repositories_controller.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +class V3::RepositoriesController < ApplicationController + include ActionController::MimeResponds + include Countable + + before_action :set_repository, only: %i[show update destroy] + before_action :authenticate_user! + before_action :set_include + load_and_authorize_resource :client, + parent: false, + except: %i[index show create totals random stats] + around_action :skip_bullet, only: %i[index], if: -> { defined?(Bullet) } + + def index + sort = + case params[:sort] + when "relevance" + { "_score" => { order: "desc" } } + when "name" + { "name.raw" => { order: "asc" } } + when "-name" + { "name.raw" => { order: "desc" } } + when "created" + { created: { order: "asc" } } + when "-created" + { created: { order: "desc" } } + else + { "name.raw" => { order: "asc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + Client.find_by_id(params[:id]) + elsif params[:ids].present? + Client.find_by_id(params[:ids], page: page, sort: sort) + else + Client.query( + params[:query], + year: params[:year], + from_date: params[:from_date], + until_date: params[:until_date], + provider_id: params[:provider_id], + consortium_id: params[:consortium_id], + re3data_id: params[:re3data_id], + opendoar_id: params[:opendoar_id], + software: params[:software], + certificate: params[:certificate], + repository_type: params[:repository_type], + client_type: params[:client_type], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size] > 0 ? (total.to_f / page[:size]).ceil : 0 + years = + total.positive? ? facet_by_year(response.aggregations.years.buckets) : nil + providers = + if total.positive? + facet_by_combined_key(response.aggregations.providers.buckets) + end + software = + if total.positive? + facet_by_software(response.aggregations.software.buckets) + end + certificates = + if total.positive? + facet_by_key(response.aggregations.certificates.buckets) + end + client_types = + if total.positive? + facet_by_key(response.aggregations.client_types.buckets) + end + repository_types = + if total.positive? + facet_by_key(response.aggregations.repository_types.buckets) + end + + respond_to do |format| + format.json do + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + years: years, + providers: providers, + "clientTypes" => client_types, + "repositoryTypes" => repository_types, + certificates: certificates, + software: software, + }.compact + + options[:links] = { + self: request.original_url, + next: + if response.results.blank? + nil + else + request.base_url + "/repositories?" + + { + query: params[:query], + "provider-id" => params[:provider_id], + software: params[:software], + certificate: params[:certificate], + "client-type" => params[:client_type], + "repository-type" => params[:repository_type], + year: params[:year], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + options[:params] = { current_ability: current_ability } + + fields = fields_from_params(params) + if fields + render json: + RepositorySerializer.new( + response.results, + options.merge(fields: fields), + ). + serialized_json, + status: :ok + else + render json: + V3::RepositorySerializer.new(response.results, options). + serialized_json, + status: :ok + end + end + header = %w[ + accountName + fabricaAccountId + parentFabricaAccountId + salesForceId + parentSalesForceId + isActive + created + updated + re3data_id + client_type + alternate_name + description + url + software + system_email + ] + format.csv do + render request.format.to_sym => response.records.to_a, header: header + end + end + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + repository = Client.where(symbol: params[:id]).where(deleted_at: nil).first + fail ActiveRecord::RecordNotFound if repository.blank? + + options = {} + options[:meta] = { + "doiCount" => + doi_count(client_id: params[:id]).reduce(0) do |sum, item| + sum += item["count"] + sum + end, + "prefixCount" => Array.wrap(repository.prefix_ids).length, + }.compact + options[:include] = @include + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::RepositorySerializer.new(repository, options).serialized_json, + status: :ok + end + + def create + @client = Client.new(safe_params) + + authorize! :create, @client + + if @client.save + @client.send_welcome_email(responsible_id: current_user.uid) + options = {} + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + render json: V3::RepositorySerializer.new(@client, options).serialized_json, + status: :created + else + # Rails.logger.error @client.errors.inspect + render json: serialize_errors(@client.errors, uid: @client.uid), + status: :unprocessable_entity + end + end + + def update + options = {} + options[:is_collection] = false + options[:params] = { current_ability: current_ability } + + if params.dig(:data, :attributes, :mode) == "transfer" + # only update provider_id + authorize! :transfer, @client + + @client.transfer(provider_target_id: safe_params[:target_id]) + render json: V3::RepositorySerializer.new(@client, options).serialized_json, + status: :ok + elsif @client.update(safe_params) + render json: V3::RepositorySerializer.new(@client, options).serialized_json, + status: :ok + else + # Rails.logger.error @client.errors.inspect + render json: serialize_errors(@client.errors, uid: @client.uid), + status: :unprocessable_entity + end + end + + # don't delete, but set deleted_at timestamp + # a repository with dois or prefixes can't be deleted + def destroy + if @client.dois.present? + message = "Can't delete repository that has DOIs." + status = 400 + Rails.logger.warn message + render json: { + errors: [{ status: status.to_s, title: message }], + }.to_json, + status: status + elsif @client.update(is_active: nil, deleted_at: Time.zone.now) + @client.send_delete_email unless Rails.env.test? + head :no_content + else + # Rails.logger.error @client.errors.inspect + render json: serialize_errors(@client.errors, uid: @client.uid), + status: :unprocessable_entity + end + end + + def random + symbol = generate_random_repository_symbol + render json: { symbol: symbol }.to_json + end + + def totals + page = { size: 0, number: 1 } + + state = + if current_user.present? && current_user.is_admin_or_staff? && + params[:state].present? + params[:state] + else + "registered,findable" + end + response = + DataciteDoi.query( + nil, + provider_id: params[:provider_id], + state: state, + page: page, + totals_agg: "client", + ) + registrant = + if response.results.total.positive? + clients_totals(response.aggregations.clients_totals.buckets) + else + [] + end + + render json: registrant, status: :ok + end + + def stats + meta = { + dois: + doi_count( + client_id: + # downloads: download_count(client_id: params[:id]), + params[ + :id + ], + ), + "resourceTypes" => resource_type_count(client_id: params[:id]), + }.compact + + render json: meta, status: :ok + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[provider] + else + @include = [] + end + end + + def set_repository + @client = Client.where(symbol: params[:id]).where(deleted_at: nil).first + fail ActiveRecord::RecordNotFound if @client.blank? + end + + private + def safe_params + if params[:data].blank? + fail JSON::ParserError, + "You need to provide a payload following the JSONAPI spec" + end + + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: [ + :symbol, + :name, + "systemEmail", + :domains, + :provider, + :url, + "globusUuid", + "repositoryType", + { "repositoryType" => [] }, + :description, + :language, + { language: [] }, + "alternateName", + :software, + "targetId", + "isActive", + "passwordInput", + "clientType", + :re3data, + :opendoar, + :issn, + { issn: %i[issnl electronic print] }, + :certificate, + { certificate: [] }, + "serviceContact", + { "serviceContact": [:email, "givenName", "familyName"] }, + "salesforceId", + ], + keys: { + "systemEmail" => :system_email, + "salesforceId" => :salesforce_id, + "globusUuid" => :globus_uuid, + "targetId" => :target_id, + "isActive" => :is_active, + "passwordInput" => :password_input, + "clientType" => :client_type, + "alternateName" => :alternate_name, + "repositoryType" => :repository_type, + "serviceContact" => :service_contact, + }, + ) + end +end diff --git a/app/controllers/v3/repository_prefixes_controller.rb b/app/controllers/v3/repository_prefixes_controller.rb new file mode 100644 index 000000000..ea4394713 --- /dev/null +++ b/app/controllers/v3/repository_prefixes_controller.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "uri" + +class V3::RepositoryPrefixesController < ApplicationController + before_action :set_client_prefix, only: %i[show update destroy] + before_action :authenticate_user! + before_action :set_include + around_action :skip_bullet, only: %i[index], if: -> { defined?(Bullet) } + + def index + sort = + case params[:sort] + when "name" + { "prefix_id" => { order: "asc" } } + when "-name" + { "prefix_id" => { order: "desc" } } + when "created" + { created_at: { order: "asc" } } + when "-created" + { created_at: { order: "desc" } } + else + { created_at: { order: "desc" } } + end + + page = page_from_params(params) + + response = if params[:id].present? + ClientPrefix.find_by_id(params[:id]) + else + ClientPrefix.query( + params[:query], + client_id: params[:repository_id], + prefix_id: params[:prefix_id], + prefix: params[:prefix], + year: params[:year], + page: page, + sort: sort, + ) + end + + begin + total = response.results.total + total_pages = page[:size].positive? ? (total.to_f / page[:size]).ceil : 0 + years = + if total.positive? + facet_by_year(response.response.aggregations.years.buckets) + end + providers = + if total.positive? + facet_by_combined_key( + response.response.aggregations.providers.buckets, + ) + end + repositories = + if total.positive? + facet_by_combined_key(response.response.aggregations.clients.buckets) + end + + repository_prefixes = response.results + + options = {} + options[:meta] = { + total: total, + "totalPages" => total_pages, + page: page[:number], + years: years, + providers: providers, + repositories: repositories, + }.compact + + options[:links] = { + self: request.original_url, + next: + if repository_prefixes.blank? + nil + else + request.base_url + "/repository-prefixes?" + + { + query: params[:query], + prefix_id: params[:prefix_id], + repository_id: params[:repository_id], + year: params[:year], + "page[number]" => page[:number] + 1, + "page[size]" => page[:size], + sort: params[:sort], + }.compact. + to_query + end, + }.compact + options[:include] = @include + options[:is_collection] = true + + render json: + V3::RepositoryPrefixSerializer.new(repository_prefixes, options). + serialized_json, + status: :ok + rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e + Raven.capture_exception(e) + + message = + JSON.parse(e.message[6..-1]).to_h.dig( + "error", + "root_cause", + 0, + "reason", + ) + + render json: { "errors" => { "title" => message } }.to_json, + status: :bad_request + end + end + + def show + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: + V3::RepositoryPrefixSerializer.new(@client_prefix, options). + serialized_json, + status: :ok + end + + def create + @client_prefix = ClientPrefix.new(safe_params) + authorize! :create, @client_prefix + + if @client_prefix.save + options = {} + options[:include] = @include + options[:is_collection] = false + + render json: + V3::RepositoryPrefixSerializer.new(@client_prefix, options). + serialized_json, + status: :created + else + # Rails.logger.error @client_prefix.errors.inspect + render json: serialize_errors(@client_prefix.errors, uid: @client_prefix.uid), + status: :unprocessable_entity + end + end + + def update + authorize! :update, @client_prefix + response.headers["Allow"] = "HEAD, GET, POST, DELETE, OPTIONS" + render json: { + errors: [{ status: "405", title: "Method not allowed" }], + }.to_json, + status: :method_not_allowed + end + + def destroy + authorize! :destroy, @client_prefix + message = "Client prefix #{@client_prefix.uid} deleted." + if @client_prefix.destroy + Rails.logger.warn message + head :no_content + else + # Rails.logger.error @client_prefix.errors.inspect + render json: serialize_errors(@client_prefix.errors, uid: @client_prefix.uid), + status: :unprocessable_entity + end + end + + protected + def set_include + if params[:include].present? + @include = + params[:include].split(",").map { |i| i.downcase.underscore.to_sym } + @include = @include & %i[repository prefix provider_prefix provider] + else + @include = [] + end + end + + private + def set_client_prefix + @client_prefix = ClientPrefix.where(uid: params[:id]).first + fail ActiveRecord::RecordNotFound if @client_prefix.blank? + end + + def safe_params + ActiveModelSerializers::Deserialization.jsonapi_parse!( + params, + only: [:id, :repository, :prefix, "provider-prefix"], + keys: { repository: :client, "provider-prefix" => :provider_prefix }, + ) + end +end diff --git a/app/controllers/v3/sessions_controller.rb b/app/controllers/v3/sessions_controller.rb new file mode 100644 index 000000000..2d7c8d6fa --- /dev/null +++ b/app/controllers/v3/sessions_controller.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class V3::SessionsController < ApplicationController + def create_token + if safe_params[:grant_type] != "password" + error_response("Wrong grant type.") && return + end + if safe_params[:username].blank? || safe_params[:username] == "undefined" || + safe_params[:password].blank? || + safe_params[:password] == "undefined" + error_response("Missing account ID or password.") && return + end + + credentials = + User.encode_auth_param( + username: safe_params[:username], password: safe_params[:password], + ) + user = User.new(credentials, type: "basic") + + error_response(user.errors) && return if user.errors.present? + if user.role_id == "anonymous" + error_response("Wrong account ID or password.") && return + end + + render json: { + "access_token" => user.jwt, "expires_in" => 3_600 * 24 * 30 + }.to_json, + status: :ok + end + + def create_oidc_token + if safe_params[:token].blank? || safe_params[:token] == "undefined" + error_response("Missing token.") && return + end + + user = User.new(safe_params[:token], type: "oidc") + error_response(user.errors) && return if user.errors.present? + + render json: { + "access_token" => user.jwt, "expires_in" => 3_600 * 24 * 30 + }.to_json, + status: :ok + end + + def reset + if safe_params[:username].blank? + message = "Missing account ID." + status = :ok + else + response = User.reset(safe_params[:username]) + if response.present? + message = response[:message] + status = response[:status] + else + message = "Account not found." + status = :ok + end + end + + render json: { "message" => message }.to_json, status: status + end + + private + def error_response(message) + status = 400 + logger.error message + render json: { errors: [{ status: status.to_s, title: message }] }.to_json, + status: status + end + + def safe_params + params.permit( + :grant_type, + :username, + :password, + :token, + :client_id, + :client_secret, + :refresh_token, + :session, + :format, + :controller, + :action, + ) + end +end diff --git a/app/serializers/v3/activity_serializer.rb b/app/serializers/v3/activity_serializer.rb new file mode 100644 index 000000000..9d6e4ad19 --- /dev/null +++ b/app/serializers/v3/activity_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class V3::ActivitySerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :activities + set_id :request_uuid + + attributes "prov:wasGeneratedBy", + "prov:generatedAtTime", + "prov:wasDerivedFrom", + "prov:wasAttributedTo", + :action, + :version, + :changes + + attribute "prov:wasDerivedFrom", &:was_derived_from + + attribute "prov:wasAttributedTo", &:was_attributed_to + + attribute "prov:wasGeneratedBy", &:was_generated_by + + attribute "prov:generatedAtTime", &:created +end diff --git a/app/serializers/v3/contact_serializer.rb b/app/serializers/v3/contact_serializer.rb new file mode 100644 index 000000000..c838292f9 --- /dev/null +++ b/app/serializers/v3/contact_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class V3::ContactSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :contacts + set_id :uid + + attributes :given_name, + :family_name, + :name, + :email, + :role_name, + :created, + :updated, + :deleted + + belongs_to :provider, record_type: :providers + + attribute :name do |object| + object.name.present? ? object.name : nil + end + + attribute :created, &:created_at + attribute :updated, &:updated_at + attribute :deleted, &:deleted_at +end diff --git a/app/serializers/v3/datacite_doi_serializer.rb b/app/serializers/v3/datacite_doi_serializer.rb new file mode 100644 index 000000000..333cfed40 --- /dev/null +++ b/app/serializers/v3/datacite_doi_serializer.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +class V3::DataciteDoiSerializer + include FastJsonapi::ObjectSerializer + + set_key_transform :camel_lower + set_type :dois + set_id :uid + # don't cache dois, as works are cached using the doi model + + attributes :doi, + :prefix, + :suffix, + :identifiers, + :alternate_identifiers, + :creators, + :titles, + :publisher, + :container, + :publication_year, + :subjects, + :contributors, + :dates, + :language, + :types, + :related_identifiers, + :sizes, + :formats, + :version, + :rights_list, + :descriptions, + :geo_locations, + :funding_references, + :xml, + :url, + :content_url, + :metadata_version, + :schema_version, + :source, + :is_active, + :state, + :reason, + :landing_page, + :view_count, + :views_over_time, + :download_count, + :downloads_over_time, + :reference_count, + :citation_count, + :citations_over_time, + :part_count, + :part_of_count, + :version_count, + :version_of_count, + :created, + :registered, + :published, + :updated + attributes :prefix, + :suffix, + :views_over_time, + :downloads_over_time, + :citations_over_time, + if: Proc.new { |_object, params| params && params[:detail] } + + belongs_to :client, record_type: :clients + belongs_to :provider, + record_type: :providers, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :media, + record_type: :media, + id_method_name: :uid, + if: + Proc.new { |_object, params| + params && params[:detail] && !params[:is_collection] + } + has_many :references, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_references, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :citations, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_citations, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :parts, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_parts, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :part_of, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_part_of, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :versions, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_versions, + if: Proc.new { |_object, params| params && params[:detail] } + has_many :version_of, + record_type: :dois, + serializer: DataciteDoiSerializer, + object_method_name: :indexed_version_of, + if: Proc.new { |_object, params| params && params[:detail] } + + attribute :xml, + if: + Proc.new { |_object, params| + params && params[:detail] + } do |object| + Base64.strict_encode64(object.xml) if object.xml.present? + rescue ArgumentError + nil + end + + attribute :doi, &:uid + + attribute :creators do |object, params| + # Always return an array of creators and affiliations + # use new array format only if affiliation param present + Array.wrap(object.creators). + map do |c| + c["affiliation"] = + Array.wrap(c["affiliation"]).map do |a| + params[:affiliation] ? a : a["name"] + end.compact + c["nameIdentifiers"] = Array.wrap(c["nameIdentifiers"]) + c + end.compact + end + + attribute :contributors, + if: + Proc.new { |_object, params| + params && params[:composite].blank? + } do |object, params| + # Always return an array of contributors and affiliations + # use new array format only if param present + Array.wrap(object.contributors). + map do |c| + c["affiliation"] = + Array.wrap(c["affiliation"]).map do |a| + params[:affiliation] ? a : a["name"] + end.compact + c["nameIdentifiers"] = Array.wrap(c["nameIdentifiers"]) + c + end.compact + end + + attribute :rights_list do |object| + Array.wrap(object.rights_list) + end + + attribute :funding_references, + if: + Proc.new { |_object, params| + params && params[:composite].blank? + } do |object| + Array.wrap(object.funding_references) + end + + attribute :identifiers do |object| + Array.wrap(object.identifiers).select do |r| + [object.doi, object.url].exclude?(r["identifier"]) + end + end + + attribute :alternate_identifiers, + if: + Proc.new { |_object, params| + params && params[:detail] + } do |object| + Array.wrap(object.identifiers).select do |r| + [object.doi, object.url].exclude?(r["identifier"]) + end.map do |a| + { + "alternateIdentifierType" => a["identifierType"], + "alternateIdentifier" => a["identifier"], + } + end.compact + end + + attribute :related_identifiers, + if: + Proc.new { |_object, params| + params && params[:composite].blank? + } do |object| + Array.wrap(object.related_identifiers) + end + + attribute :geo_locations, + if: + Proc.new { |_object, params| + params && params[:composite].blank? + } do |object| + Array.wrap(object.geo_locations) + end + + attribute :dates do |object| + Array.wrap(object.dates) + end + + attribute :subjects, + if: + Proc.new { |_object, params| + params && params[:composite].blank? + } do |object| + Array.wrap(object.subjects) + end + + attribute :sizes do |object| + Array.wrap(object.sizes) + end + + attribute :descriptions do |object| + Array.wrap(object.descriptions) + end + + attribute :formats do |object| + Array.wrap(object.formats) + end + + attribute :container do |object| + object.container || {} + end + + attribute :types do |object| + object.types || {} + end + + attribute :state, &:aasm_state + + attribute :version, &:version_info + + attribute :published do |object| + object.respond_to?(:published) ? object.published : nil + end + + attribute :is_active do |object| + object.is_active.to_s.getbyte(0) == 1 + end + + attribute :landing_page, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_landing_page_results, + object, + ) == + true + }, + &:landing_page +end diff --git a/app/serializers/v3/event_serializer.rb b/app/serializers/v3/event_serializer.rb new file mode 100644 index 000000000..11f56a110 --- /dev/null +++ b/app/serializers/v3/event_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class V3::EventSerializer + include FastJsonapi::ObjectSerializer + # include BatchLoaderHelper + + set_key_transform :camel_lower + set_type :events + set_id :uuid + + attributes :subj_id, + :obj_id, + :source_id, + :target_doi, + :relation_type_id, + :source_relation_type_id, + :target_relation_type_id, + :total, + :message_action, + :source_token, + :license, + :occurred_at, + :timestamp + + attribute :timestamp, &:updated_at + + attribute :source_doi do |object| + object.source_doi.downcase if object.source_doi.present? + end + + attribute :target_doi do |object| + object.target_doi.downcase if object.target_doi.present? + end + + belongs_to :subj, serializer: ObjectSerializer, record_type: :objects + belongs_to :obj, serializer: ObjectSerializer, record_type: :objects +end diff --git a/app/serializers/v3/media_serializer.rb b/app/serializers/v3/media_serializer.rb new file mode 100644 index 000000000..58b91fa19 --- /dev/null +++ b/app/serializers/v3/media_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class V3::MediaSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :media + set_id :uid + cache_options enabled: true, cache_length: 24.hours + + attributes :version, :url, :media_type, :created, :updated + + belongs_to :datacite_doi, record_type: :datacite_dois +end diff --git a/app/serializers/v3/prefix_serializer.rb b/app/serializers/v3/prefix_serializer.rb new file mode 100644 index 000000000..7dd71a754 --- /dev/null +++ b/app/serializers/v3/prefix_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class V3::PrefixSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :prefixes + set_id :uid + + attributes :prefix, :created_at + + attribute :prefix, &:uid + + has_many :clients, record_type: :clients + has_many :providers, record_type: :providers + has_many :client_prefixes, record_type: "client-prefixes" + has_many :provider_prefixes, record_type: "provider-prefixes" +end diff --git a/app/serializers/v3/provider_prefix_serializer.rb b/app/serializers/v3/provider_prefix_serializer.rb new file mode 100644 index 000000000..1e4fb4621 --- /dev/null +++ b/app/serializers/v3/provider_prefix_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class V3::ProviderPrefixSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type "provider-prefixes" + set_id :uid + attributes :created_at, :updated_at + + belongs_to :provider, record_type: :providers + belongs_to :prefix, record_type: :prefixes + has_many :clients, record_type: :clients + has_many :client_prefixes, record_type: "client-prefixes" +end diff --git a/app/serializers/v3/provider_serializer.rb b/app/serializers/v3/provider_serializer.rb new file mode 100644 index 000000000..caf533008 --- /dev/null +++ b/app/serializers/v3/provider_serializer.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +class V3::ProviderSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :providers + set_id :uid + # cache_options enabled: true, cache_length: 24.hours ### we cannot filter if we cache + + attributes :name, + :display_name, + :symbol, + :website, + :system_email, + :group_email, + :globus_uuid, + :description, + :region, + :country, + :logo_url, + :member_type, + :organization_type, + :focus_area, + :non_profit_status, + :is_active, + :has_password, + :joined, + :twitter_handle, + :billing_information, + :ror_id, + :salesforce_id, + :technical_contact, + :secondary_technical_contact, + :billing_contact, + :secondary_billing_contact, + :service_contact, + :secondary_service_contact, + :voting_contact, + :created, + :updated + + has_many :clients, record_type: :clients + has_many :prefixes, record_type: :prefixes + has_many :contacts, record_type: :contact + belongs_to :consortium, + record_type: :providers, + serializer: ProviderSerializer, + if: Proc.new { |provider| provider.consortium_id } + has_many :consortium_organizations, + record_type: :providers, + serializer: ProviderSerializer, + if: Proc.new { |provider| provider.member_type == "consortium" } + + attribute :country, &:country_code + + attribute :is_active do |object| + object.is_active.getbyte(0) == 1 + end + + attribute :has_password, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + object.password.present? + end + + attribute :billing_information, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_billing_information, + object, + ) == + true + } do |object| + if object.billing_information.present? + object.billing_information.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :twitter_handle, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_billing_information, + object, + ) == + true + }, + &:twitter_handle + + attribute :globus_uuid, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_billing_information, + object, + ) == + true + }, + &:globus_uuid + + attribute :system_email, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + object.system_email + end + + attribute :group_email, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + object.group_email + end + + # Convert all contacts json models back to json style camelCase + attribute :technical_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.technical_contact.present? + object.technical_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :secondary_technical_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.secondary_technical_contact.present? + object.secondary_technical_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :billing_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.billing_contact.present? + object.billing_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :secondary_billing_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.secondary_billing_contact.present? + object.secondary_billing_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :service_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.service_contact.present? + object.service_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :secondary_service_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.secondary_service_contact.present? + object.secondary_service_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :voting_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.voting_contact.present? + object.voting_contact.transform_keys! { |key| key.to_s.camelcase(:lower) } + else + {} + end + end + + attribute :salesforce_id, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?(:read_salesforce_id, object) == + true + }, + &:salesforce_id +end diff --git a/app/serializers/v3/repository_prefix_serializer.rb b/app/serializers/v3/repository_prefix_serializer.rb new file mode 100644 index 000000000..1aa8dc86d --- /dev/null +++ b/app/serializers/v3/repository_prefix_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class V3::RepositoryPrefixSerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type "repository-prefixes" + set_id :uid + + attributes :created_at, :updated_at + + belongs_to :repository, + object_method_name: :client, id_method_name: :client_id + belongs_to :provider + belongs_to :provider_prefix + belongs_to :prefix +end diff --git a/app/serializers/v3/repository_serializer.rb b/app/serializers/v3/repository_serializer.rb new file mode 100644 index 000000000..ef5ee375e --- /dev/null +++ b/app/serializers/v3/repository_serializer.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +class V3::RepositorySerializer + include FastJsonapi::ObjectSerializer + set_key_transform :camel_lower + set_type :repositories + set_id :uid + + attributes :name, + :symbol, + :re3data, + :opendoar, + :year, + :system_email, + :service_contact, + :globus_uuid, + :alternate_name, + :description, + :client_type, + :repository_type, + :language, + :certificate, + :domains, + :issn, + :url, + :salesforce_id, + :created, + :updated + + belongs_to :provider, record_type: :providers + has_many :prefixes, record_type: :prefixes + + attribute :re3data do |object| + "https://doi.org/#{object.re3data_id}" if object.re3data_id.present? + end + + attribute :opendoar do |object| + if object.opendoar_id.present? + "https://v2.sherpa.ac.uk/id/repository/#{object.opendoar_id}" + end + end + + attribute :is_active do |object| + object.is_active.getbyte(0) == 1 + end + + attribute :has_password, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + object.password.present? + end + + attribute :system_email, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + object.system_email + end + + attribute :service_contact, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_contact_information, + object, + ) == + true + } do |object| + if object.service_contact.present? + object.service_contact.transform_keys! do |key| + key.to_s.camelcase(:lower) + end + else + {} + end + end + + attribute :globus_uuid, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?( + :read_billing_information, + object, + ) == + true + }, + &:globus_uuid + + attribute :salesforce_id, + if: + Proc.new { |object, params| + params[:current_ability] && + params[:current_ability].can?(:read_salesforce_id, object) == + true + }, + &:salesforce_id +end diff --git a/config/application.rb b/config/application.rb index e1b86bae4..abe5d5863 100644 --- a/config/application.rb +++ b/config/application.rb @@ -79,6 +79,12 @@ class Application < Rails::Application config.paths.add Rails.root.join("app", "graphql", "resolvers").to_s, eager_load: true + # include versioned REST API + config.paths.add Rails.root.join("app", "controllers", "v3").to_s, + eager_load: true + config.paths.add Rails.root.join("app", "serializers", "v3").to_s, + eager_load: true + # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/config/routes.rb b/config/routes.rb index ac733b060..ac961ba2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -197,6 +197,64 @@ resources :contacts + # v3 REST API + namespace :v3 do + # manage DOIs + post "dois/validate", to: "datacite_dois#validate" + post "dois/undo", to: "datacite_dois#undo" + post "dois/status", to: "datacite_dois#status" + post "dois/set-url", to: "datacite_dois#set_url" + post "dois/delete-test-dois", to: "datacite_dois#delete_test_dois" + get "dois/random", to: "datacite_dois#random" + get "dois/:id/get-url", to: "datacite_dois#get_url", constraints: { id: /.+/ } + get "dois/get-dois", to: "datacite_dois#get_dois" + + get "providers/image/:id", to: "providers#image", constraints: { id: /.+/ } + + get "providers/totals", to: "providers#totals" + get "clients/totals", to: "clients#totals" + get "repositories/totals", to: "repositories#totals" + get "prefixes/totals", to: "prefixes#totals" + get "/providers/:id/stats", to: "providers#stats" + get "/clients/:id/stats", to: "clients#stats", constraints: { id: /.+/ } + get "/repositories/:id/stats", + to: "repositories#stats", constraints: { id: /.+/ } + + # Reporting + get "export/organizations", + to: "exports#organizations", defaults: { format: :csv } + get "export/repositories", + to: "exports#repositories", defaults: { format: :csv } + get "export/contacts", to: "exports#contacts", defaults: { format: :csv } + get "export/check-indexed-dois", to: "exports#import_dois_not_indexed" + + resources :activities, only: %i[index show] + resources :contacts + resources :datacite_dois, path: "dois", constraints: { id: /.+/ } do + resources :metadata + resources :media + resources :activities + resources :events + end + resources :prefixes, constraints: { id: /.+/ } + resources :provider_prefixes, path: "provider-prefixes" + resources :random, only: %i[index] + resources :providers do + resources :repositories, constraints: { id: /.+/ }, shallow: true + resources :datacite_dois, path: "dois", constraints: { id: /.+/ } + resources :prefixes, constraints: { id: /.+/ } + resources :contacts + resources :activities + end + resources :providers, constraints: { id: /.+/ } + resources :repositories, constraints: { id: /.+/ } do + resources :prefixes, constraints: { id: /.+/ } + resources :datacite_dois, path: "dois", constraints: { id: /.+/ } + resources :activities + end + resources :repository_prefixes, path: "repository-prefixes" + end + constraints(->(req) { req.env["HTTP_ACCEPT"].to_s.include?("version=2") }) do resources :events end diff --git a/spec/fixtures/vcr_cassettes/DataciteDoisController/GET_/dois_with_views_and_downloads/includes_events.yml b/spec/fixtures/vcr_cassettes/DataciteDoisController/GET_/dois_with_views_and_downloads/includes_events.yml new file mode 100644 index 000000000..7e9182b08 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/DataciteDoisController/GET_/dois_with_views_and_downloads/includes_events.yml @@ -0,0 +1,57 @@ +--- +http_interactions: +- request: + method: get + uri: https://doi.org/ra/10.14454 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Mozilla/5.0 (compatible; Maremma/4.7.2; mailto:info@datacite.org) + Accept: + - text/html,application/json,application/xml;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5 + response: + status: + code: 200 + message: '' + headers: + Date: + - Wed, 10 Feb 2021 12:43:28 GMT + Content-Type: + - application/json;charset=UTF-8 + Connection: + - keep-alive + Set-Cookie: + - __cfduid=d4c82e651849d9ac402721c23e5898e3b1612961008; expires=Fri, 12-Mar-21 + 12:43:28 GMT; path=/; domain=.doi.org; HttpOnly; SameSite=Lax; Secure + Cf-Cache-Status: + - DYNAMIC + Cf-Request-Id: + - '082d90e381000006094a9bc000000001' + Expect-Ct: + - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report?s=L56mjCXtoGD2Li9cB0NONMBZk3BjwcqWmQeegfa8TtaCM6iryei60%2FAYeN%2BXV77pxkwZ%2Bh9K4qqcSdSQDgtYjS3kmFWws2oT"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"max_age":604800,"report_to":"cf-nel"}' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Server: + - cloudflare + Cf-Ray: + - 61f5ea7f3e630609-FRA + Alt-Svc: + - h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + [ + { + "DOI": "10.14454", + "RA": "DataCite" + } + ] + http_version: null + recorded_at: Wed, 10 Feb 2021 12:43:26 GMT +recorded_with: VCR 5.1.0 diff --git a/spec/fixtures/vcr_cassettes/EventsController/show/as_regular_user_with_include_subj/JSON.yml b/spec/fixtures/vcr_cassettes/EventsController/show/as_regular_user_with_include_subj/JSON.yml new file mode 100644 index 000000000..7d73e66ef --- /dev/null +++ b/spec/fixtures/vcr_cassettes/EventsController/show/as_regular_user_with_include_subj/JSON.yml @@ -0,0 +1,111 @@ +--- +http_interactions: +- request: + method: get + uri: https://doi.org/ra/10.14454 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Mozilla/5.0 (compatible; Maremma/4.7.2; mailto:info@datacite.org) + Accept: + - text/html,application/json,application/xml;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5 + response: + status: + code: 200 + message: '' + headers: + Date: + - Wed, 10 Feb 2021 12:33:50 GMT + Content-Type: + - application/json;charset=UTF-8 + Connection: + - keep-alive + Set-Cookie: + - __cfduid=dbeebe1b42256f4226f832e05b23c1ef51612960430; expires=Fri, 12-Mar-21 + 12:33:50 GMT; path=/; domain=.doi.org; HttpOnly; SameSite=Lax; Secure + Cf-Cache-Status: + - DYNAMIC + Cf-Request-Id: + - '082d88119a00001f1d6b275000000001' + Expect-Ct: + - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report?s=S9vwT2AX6n%2BNNDE6xTZLHh%2BwywBkzXYoKlFMXcq0%2BRFAE7MT8Qul3618D6OAXsuplmj4Vcoc3n2NJ5aGION2z7649UyXd1Wo"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"max_age":604800,"report_to":"cf-nel"}' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Server: + - cloudflare + Cf-Ray: + - 61f5dc6289f21f1d-FRA + Alt-Svc: + - h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + [ + { + "DOI": "10.14454", + "RA": "DataCite" + } + ] + http_version: null + recorded_at: Wed, 08 Apr 2015 00:00:00 GMT +- request: + method: get + uri: https://doi.org/ra/10.14454 + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - Mozilla/5.0 (compatible; Maremma/4.7.2; mailto:info@datacite.org) + Accept: + - text/html,application/json,application/xml;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5 + response: + status: + code: 200 + message: '' + headers: + Date: + - Wed, 10 Feb 2021 12:33:50 GMT + Content-Type: + - application/json;charset=UTF-8 + Connection: + - keep-alive + Set-Cookie: + - __cfduid=d982c6804b5c46b6380981cfebdd3c9091612960430; expires=Fri, 12-Mar-21 + 12:33:50 GMT; path=/; domain=.doi.org; HttpOnly; SameSite=Lax; Secure + Cf-Cache-Status: + - DYNAMIC + Cf-Request-Id: + - '082d88127200004a6721292000000001' + Expect-Ct: + - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" + Report-To: + - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report?s=VvxqsJsIyKm4iXfBLwzWhBdj0V1C0ns4XXX3BrswP66emZkDwBzTMIbx0W0ubCYjHIVV0GTvpPHJl7Pq%2FjY6sQ931TyXeotm"}]}' + Nel: + - '{"report_to":"cf-nel","max_age":604800}' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Server: + - cloudflare + Cf-Ray: + - 61f5dc63ed604a67-FRA + Alt-Svc: + - h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + [ + { + "DOI": "10.14454", + "RA": "DataCite" + } + ] + http_version: null + recorded_at: Wed, 08 Apr 2015 00:00:00 GMT +recorded_with: VCR 5.1.0