From 46864528565df77dcdccad31b5f069fd55cb80b6 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 11 Jun 2026 09:26:03 +0800 Subject: [PATCH 1/4] Rewrite API handler, upgrade to Ruby 4.0 + latest AWS SDK --- .github/awsl-layer-docker/Dockerfile | 9 +- .github/awsl-layer-docker/build.sh | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/on-release.yml | 2 +- .ruby-version | 2 +- Gemfile | 4 +- lib/app.rb | 188 +--------------- lib/finder.rb | 29 --- lib/override/relaton/db.rb | 2 +- lib/override/relaton_calconnect/hit.rb | 21 -- .../relaton_calconnect/hit_collection.rb | 14 -- lib/relaton/api.rb | 100 +++++++++ lib/relaton/api/finder.rb | 25 +++ lib/storage.rb | 2 +- spec/app_spec.rb | 140 ------------ spec/finder_spec.rb | 22 +- spec/relaton/api_spec.rb | 202 +++++++++++------- .../relaton_calconnect/hit_collection_spec.rb | 9 - spec/storage_spec.rb | 1 + 19 files changed, 280 insertions(+), 496 deletions(-) delete mode 100644 lib/finder.rb delete mode 100644 lib/override/relaton_calconnect/hit.rb delete mode 100644 lib/override/relaton_calconnect/hit_collection.rb create mode 100644 lib/relaton/api.rb create mode 100644 lib/relaton/api/finder.rb delete mode 100644 spec/app_spec.rb delete mode 100644 spec/relaton_calconnect/hit_collection_spec.rb diff --git a/.github/awsl-layer-docker/Dockerfile b/.github/awsl-layer-docker/Dockerfile index 3e7352a..bb6e088 100644 --- a/.github/awsl-layer-docker/Dockerfile +++ b/.github/awsl-layer-docker/Dockerfile @@ -1,12 +1,9 @@ -FROM lambci/lambda:20210129-build-ruby2.7 as builder +FROM public.ecr.aws/lambda/ruby:4.0 as builder -ADD Gemfile . -RUN yum install -y libyaml-devel +COPY Gemfile Gemfile.lock ./ RUN bundle config set path "/lambda" RUN bundle install --without=development --jobs 4 --retry 3 -FROM lambci/yumda:2 as yumda -RUN yum install -y libyaml - +FROM public.ecr.aws/lambda/ruby:4.0 COPY --from=builder "/lambda/ruby" "/lambda/opt/ruby/gems" diff --git a/.github/awsl-layer-docker/build.sh b/.github/awsl-layer-docker/build.sh index bb38794..c1086ff 100755 --- a/.github/awsl-layer-docker/build.sh +++ b/.github/awsl-layer-docker/build.sh @@ -1,7 +1,7 @@ #!/bin/sh rm -Rf ./libs -cp ../../Gemfile Gemfile +cp ../../Gemfile Gemfile && cp ../../Gemfile.lock Gemfile.lock docker build --no-cache -t lambda . id=$(docker create lambda) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e98518e..2b422a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2 + ruby-version: 4.0 # - name: Cache Dependencies # uses: actions/cache@v1 diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index be2e078..eaecf31 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -38,7 +38,7 @@ jobs: uses: ruby/setup-ruby@v1 with: bundler-cache: false - ruby-version: 3.2 + ruby-version: 4.0 - name: build "-api" source working-directory: git diff --git a/.ruby-version b/.ruby-version index 944880f..7636e75 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.0 +4.0.5 diff --git a/Gemfile b/Gemfile index a64d3e3..dae0750 100644 --- a/Gemfile +++ b/Gemfile @@ -5,8 +5,8 @@ source "https://rubygems.org" git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } # gem "aws-partitions", "~> 1.613.0" -gem "aws-sdk-s3", "~> 1.114.0" -gem "relaton", ENV["RELATION_GEM_VERSION"] || "~> 1.18.0" +gem "aws-sdk-s3", "~> 1.188" +gem "relaton", ENV["RELATION_GEM_VERSION"] || "~> 2.1.0" group :test do gem "rspec" diff --git a/lib/app.rb b/lib/app.rb index 8ffdd78..9739327 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -1,188 +1,6 @@ # frozen_string_literal: true -require_relative "finder" +require_relative "relaton/api" -module Relaton - class Api - class << self - # - # AWS Lambda handler - # - # @param [Hash] event - # @option event [String] :path - # @option event [Hash] :queryStringParameters - # - # @param [Hash] context - # - # @return [Hash] AWS Lambda response - # - def handler(event:, context: {}) # rubocop:disable Lint/UnusedMethodArgument - router(event) || resource_not_exist - rescue StandardError => e - puts "Execution error!" - puts e.message - puts e.backtrace - end - - private - - # - # Route request - # - # @param [Hash] event - # @option event [String] :path - # @option event [Hash] :queryStringParameters - # - # @return [Hash] AWS Lambda response - # - def router(event) # rubocop:disable Metrics/MethodLength - case event["path"] - when /\/api\/v1\/document$/ - case event["httpMethod"] - when "GET" then fetch event - end - when /\/api\/v1\/version$/ - case event["httpMethod"] - when "GET" then version event["queryStringParameters"]&.fetch("format") - end - end - end - - # - # Formatted version response - # - # @param [String, nil] format - # - # @return [Hash] - # - def version(format) # rubocop:disable Metrics/MethodLength - version = ENV.fetch "API_VERSION" - case format - when "xml" - xml = "#{version}#{Relaton::VERSION}" - response xml, type: "text/xml" - when "json" - json = { release: version, relaton: Relaton::VERSION }.to_json - response json, type: "application/json" - else - response "Release: #{version}, Relaton version: #{Relaton::VERSION}" - end - end - - # - # Look up a document - # - # @param [Hash] event - # @option event [Hash] :queryStringParameters - # - # @return [Hash] AWS Lambda response - # - def fetch(event) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - if event["queryStringParameters"].nil? - return bad_request "Parameters are missed or incorrect. "\ - "See the documentation https://github.com/relaton"\ - "/api.relaton.org#fetch-bibdata-of-a-document" - elsif event["queryStringParameters"]["code"].nil? - return bad_request "Parameter 'code' is required." - end - - item = Relaton::Finder.instance.fetch(*params(event)) - return not_found "Document not found." unless item - - xml = item.to_xml bibdata: true - response xml, type: "text/xml" - rescue Aws::Xml::Parser::ParsingError - bad_request "Parameter 'code' contains invalid symbols. "\ - "See this guide https://docs.aws.amazon.com/AmazonS3/"\ - "latest/userguide/object-keys.html" - rescue RelatonBib::RequestError => e - service_unavailable e.message - end - - # - # Parameters for document fetching - # - # @param [Hash] event - # @option event [Hash] :queryStringParameters - # - # @return [Array] - # - def params(event) - allowed_params = %w[all_parts keep_year] - opts = event["queryStringParameters"].each_with_object({}) do |(k, v), o| - allowed_params.include?(k) && o[k.to_sym] = v - end - - [ - event["queryStringParameters"]["code"], - event["queryStringParameters"]["year"], - opts, - ] - end - - # - # Respond resourse not exist - # - # @return [Hash] AWS Lambda response - # - def resource_not_exist - not_found "Resource doesn't exist." - end - - # - # Bad request response - # - # @param [String] msg - # - # @return [Hash] AWS Lambda response - # - def bad_request(msg) - response "Bad request. #{msg}", status: 400 - end - - # - # Not found response - # - # @param [String] msg - # - # @return [Hash] AWS Lambda response - # - def not_found(msg) - response msg, status: 404 - end - - # - # Service unavailable respoonse - # - # @param [String] msg message - # - # @return [Hash] AWS Lambda response - # - def service_unavailable(msg) - response msg, status: 503 - end - - # - # AWS Lambda response - # - # @param [String] body - # @param [String] type - # @param [Integer] status - # - # @return [Hash] AWS Lambda response - # - def response(body, type: "text/plain", status: 200) - { - statusCode: status, - headers: { - "Content-Type" => type, - "Access-Control-Allow-Origin" => "*", - "Access-Control-Allow-Headers" => "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", - "Access-Control-Allow-Methods" => "GET, POST, OPTIONS", - }, - body: body, - } - end - end - end -end +Encoding.default_internal = Encoding::UTF_8 +Encoding.default_external = Encoding::UTF_8 diff --git a/lib/finder.rb b/lib/finder.rb deleted file mode 100644 index fce58d9..0000000 --- a/lib/finder.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "singleton" -require "relaton" -# require "relaton_bib" - -Relaton::Registry.instance - -require "./lib/override/relaton/db" -require "./lib/override/relaton_calconnect/hit_collection" - -Encoding.default_internal = Encoding::UTF_8 -Encoding.default_external = Encoding::UTF_8 - -module Relaton - class Finder - include Singleton - - def initialize - Relaton.configure do |config| - config.use_api = false - end - - @db = Relaton::Db.init_bib_caches global_cache: true - end - - def fetch(code, year, opts) - @db.fetch code, year, opts - end - end -end diff --git a/lib/override/relaton/db.rb b/lib/override/relaton/db.rb index aa94ffd..91cbd80 100644 --- a/lib/override/relaton/db.rb +++ b/lib/override/relaton/db.rb @@ -8,8 +8,8 @@ def initialize(global_cache, local_cache) @registry = Relaton::Registry.instance @db = open_cache_biblio(global_cache, type: :global) @local_db = open_cache_biblio(local_cache, type: :local) - # @static_db = open_cache_biblio "static_cache" @queues = {} + @semaphore = Mutex.new end private diff --git a/lib/override/relaton_calconnect/hit.rb b/lib/override/relaton_calconnect/hit.rb deleted file mode 100644 index 2aa648d..0000000 --- a/lib/override/relaton_calconnect/hit.rb +++ /dev/null @@ -1,21 +0,0 @@ -module RelatonCalconnect - class Hit - ENDPOINT = "https://raw.githubusercontent.com/relaton/relaton-data-calconnect/main/data/".freeze - - # Parse page. - # @return [RelatonCalconnect::CcBliographicItem, nil] - def fetch # rubocop:disable Metrics/MethodLength - @fetch ||= begin - code, year = @hit[:ref].split ":" - year ||= @hit[:year] - code += ":#{year}" if year - ref = code.upcase.gsub %r{[/\s:]}, "_" - resp = Faraday.get "#{ENDPOINT}#{ref}.yaml" - if resp.status == 200 - hash = YAML.safe_load resp.body - CcBibliographicItem.from_hash hash - end - end - end - end -end diff --git a/lib/override/relaton_calconnect/hit_collection.rb b/lib/override/relaton_calconnect/hit_collection.rb deleted file mode 100644 index ee0fb31..0000000 --- a/lib/override/relaton_calconnect/hit_collection.rb +++ /dev/null @@ -1,14 +0,0 @@ -require_relative "hit" - -module RelatonCalconnect - class HitCollection - # @param ref [Strig] - # @param year [String] - def initialize(ref, year = nil) - super - @array = [] - hit = Hit.new({ ref: ref, year: year }, self) - @array << hit if hit.fetch - end - end -end diff --git a/lib/relaton/api.rb b/lib/relaton/api.rb new file mode 100644 index 0000000..7810cdf --- /dev/null +++ b/lib/relaton/api.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "api/finder" + +module Relaton + class Api + class << self + def handler(event:, context: {}) + router(event) || not_found("Resource doesn't exist.") + rescue StandardError => e + $stderr.puts "[ERROR] #{e.class}: #{e.message}" + $stderr.puts e.backtrace.first(10).join("\n") if e.backtrace + internal_error("#{e.class}: #{e.message}") + end + + private + + def router(event) + case event["path"] + when /\/api\/v1\/document$/ + fetch(event) if event["httpMethod"] == "GET" + when /\/api\/v1\/version$/ + version(event["queryStringParameters"]&.fetch("format")) if event["httpMethod"] == "GET" + end + end + + def version(format) + ver = ENV.fetch("API_VERSION", "unknown") + case format + when "xml" + response "#{escape(ver)}#{Relaton::VERSION}", + type: "text/xml" + when "json" + response({ release: ver, relaton: Relaton::VERSION }.to_json, type: "application/json") + else + response "Release: #{ver}, Relaton version: #{Relaton::VERSION}" + end + end + + def fetch(event) + params = event["queryStringParameters"] || {} + code = params["code"]&.strip + return bad_request("Parameter 'code' is required.") if code.nil? || code.empty? + + item = Finder.instance.fetch(normalize(code), params["year"]&.strip, extract_opts(params)) + return not_found("Document not found.") unless item + + response item.to_xml(bibdata: true), type: "text/xml" + rescue Relaton::RequestError => e + service_unavailable(e.message) + rescue ArgumentError => e + bad_request(e.message) + end + + def normalize(code) + code.gsub(/[\s\u00a0]+/, " ").strip + end + + def extract_opts(params) + {}.tap do |opts| + opts[:all_parts] = params["all_parts"] if params.key?("all_parts") + opts[:keep_year] = params["keep_year"] if params.key?("keep_year") + end + end + + def escape(str) + str.gsub("&", "&").gsub("<", "<").gsub(">", ">") + end + + def bad_request(msg) + response("Bad request. #{msg}", status: 400) + end + + def not_found(msg) + response(msg, status: 404) + end + + def service_unavailable(msg) + response(msg, status: 503) + end + + def internal_error(msg) + response("Internal error. #{msg}", status: 500) + end + + def response(body, type: "text/plain", status: 200) + { + statusCode: status, + headers: { + "Content-Type" => type, + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Headers" => "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", + "Access-Control-Allow-Methods" => "GET, POST, OPTIONS", + }, + body: body, + } + end + end + end +end diff --git a/lib/relaton/api/finder.rb b/lib/relaton/api/finder.rb new file mode 100644 index 0000000..3061746 --- /dev/null +++ b/lib/relaton/api/finder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "singleton" +require "relaton" + +Relaton::Registry.instance + +require_relative "../../override/relaton/db" + +module Relaton + class Api + class Finder + include Singleton + + def initialize + Relaton.configure { |config| config.use_api = false } + @db = Relaton::Db.init_bib_caches global_cache: true + end + + def fetch(code, year = nil, opts = {}) + @db.fetch(code, year, opts) + end + end + end +end diff --git a/lib/storage.rb b/lib/storage.rb index 043185a..7ba8ab2 100644 --- a/lib/storage.rb +++ b/lib/storage.rb @@ -76,7 +76,7 @@ def list_objects(prefix) # Delete item # @param keys [String, Array] path to file without extension def delete(keys) - RelatonBib.array(keys).map { |f| { key: f } }.each_slice(1000) do |objects| + Array(keys).map { |f| { key: f } }.each_slice(1000) do |objects| @s3.delete_objects bucket: ENV.fetch("AWS_BUCKET"), delete: { objects: objects } end end diff --git a/spec/app_spec.rb b/spec/app_spec.rb deleted file mode 100644 index d859afa..0000000 --- a/spec/app_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -describe Relaton::Api do - # context "GET standard" do - # before :each do - # allow(Relaton::Storage.instance).to receive(:get_version).and_return(nil) - # end - - # context "Returns version" do - # before :each do - # expect(ENV).to receive(:fetch).with("API_VERSION").and_return "0.1" - # end - - # it "plain text" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/version", - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 200 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to include "Release: 0.1, Relaton version:" - # end - - # it "xml" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/version", - # "queryStringParameters" => { "format" => "xml" }, - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 200 - # expect(resp[:headers]["Content-Type"]).to eq "text/xml" - # expect(resp[:body]).to include "0.1" - # end - - # it "json" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/version", - # "queryStringParameters" => { "format" => "json" }, - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 200 - # expect(resp[:headers]["Content-Type"]).to eq "application/json" - # expect(resp[:body]).to include "{\"release\":\"0.1\",\"relaton\":" - # end - # end - - # context "returns status 404" do - # it "not found" do - # VCR.use_cassette "fetch_s3_not_found" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # "queryStringParameters" => { "code" => "ISO 123456" }, - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 404 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to eq "Document not found." - # end - # end - - # it "resource doesn't exist" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/fetch", - # "queryStringParameters" => { "code" => "ISO 123456" }, - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 404 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to eq "Resource doesn't exist." - # end - # end - - # context "returns status 400" do - # it "missed code parameter" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # "queryStringParameters" => { "year" => "2019" }, - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 400 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to eq "Bad request. Parameter 'code' is required." - # end - - # it "missed search prarameters" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # } - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 400 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to include "Bad request. Parameters are missed or incorrect." - # end - - # it "incorrect code" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # "queryStringParameters" => { "code" => "ISO\u0010241-1" }, - # } - # VCR.use_cassette "incorrect_code" do - # resp = Relaton::Api.handler event: event - # expect(resp[:statusCode]).to eq 400 - # expect(resp[:headers]["Content-Type"]).to eq "text/plain" - # expect(resp[:body]).to include "Bad request. Parameter 'code' contains invalid symbols." - # end - # end - # end - - # context "return status 503" do - # it "service unavailble" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # "queryStringParameters" => { "code" => "ISO\u0010241-1" }, - # } - # Singleton.__init__ Relaton::Finder - # db = double "db instance" - # expect(db).to receive(:fetch).and_raise RelatonBib::RequestError - # expect(Relaton::Db).to receive(:init_bib_caches).and_return db - # Relaton::Api.handler event: event - # end - # end - # end - - # it "log error" do - # event = { - # "httpMethod" => "GET", - # "path" => "/api/v1/document", - # "queryStringParameters" => { "code" => "ISO 123456" }, - # } - # expect(subject.class).to receive(:fetch).and_raise SocketError - # expect { Relaton::Api.handler event: event }.to output(/Execution error!/).to_stdout - # end -end diff --git a/spec/finder_spec.rb b/spec/finder_spec.rb index 609a4e1..cdbf405 100644 --- a/spec/finder_spec.rb +++ b/spec/finder_spec.rb @@ -1,21 +1,21 @@ -describe Relaton::Finder do - let(:db) { double "db" } +describe Relaton::Api::Finder do + let(:db) { instance_double(Relaton::Db) } before do - Singleton.__init__(Relaton::Finder) - config = double "config" - expect(config).to receive(:use_api=).with false - expect(Relaton).to receive(:configure).and_yield config - expect(Relaton::Db).to receive(:init_bib_caches).with(global_cache: true).and_return db + Singleton.__init__(described_class) + config = double("config") + allow(config).to receive(:use_api=) + allow(Relaton).to receive(:configure).and_yield(config) + allow(Relaton::Db).to receive(:init_bib_caches).with(global_cache: true).and_return db end - it "initialize" do + it "initializes" do finder = described_class.instance expect(finder.instance_variable_get(:@db)).to eq db end - it "fetch" do - expect(db).to receive(:fetch).with("code", "year", "opts") - described_class.instance.fetch "code", "year", "opts" + it "delegates fetch to Db" do + expect(db).to receive(:fetch).with("ISO 9000", "2015", {}) + described_class.instance.fetch "ISO 9000", "2015", {} end end diff --git a/spec/relaton/api_spec.rb b/spec/relaton/api_spec.rb index 57f02c2..c58636b 100644 --- a/spec/relaton/api_spec.rb +++ b/spec/relaton/api_spec.rb @@ -1,52 +1,48 @@ describe Relaton::Api do before :each do - allow(ENV).to receive(:fetch).with("API_VERSION").and_return "0.1" + allow(ENV).to receive(:fetch).with("API_VERSION", any_args).and_return "0.1" end context ".handler" do - it "router return response" do + it "returns router response" do event = { "httpMethod" => "GET", "path" => "/api/v1/document", - "queryStringParameters" => { - "code" => "ISO 19115-2", "year" => "2019" - }, + "queryStringParameters" => { "code" => "ISO 19115-2", "year" => "2019" }, } - expect(Relaton::Api).to receive(:router).with(event).and_return statusCode: 200 - resp = Relaton::Api.handler event: event + expect(Relaton::Api).to receive(:router).with(event).and_return(statusCode: 200) + resp = Relaton::Api.handler(event: event) expect(resp[:statusCode]).to eq 200 end - it "call resource_not_exist" do + it "returns 404 when router returns nil" do event = { "httpMethod" => "GET", "path" => "/api/v1/document", - "queryStringParameters" => { - "code" => "ISO 19115-2", "year" => "2019" - }, + "queryStringParameters" => { "code" => "ISO 19115-2", "year" => "2019" }, } expect(Relaton::Api).to receive(:router).with(event).and_return nil - expect(Relaton::Api).to receive(:resource_not_exist).and_return statusCode: 404 - resp = Relaton::Api.handler event: event + resp = Relaton::Api.handler(event: event) expect(resp[:statusCode]).to eq 404 + expect(resp[:body]).to eq "Resource doesn't exist." end - it "handle error" do - expect do - expect(Relaton::Api).to receive(:router).and_raise StandardError - Relaton::Api.handler event: nil - end.to output(/Execution error!/).to_stdout + it "returns 500 on unhandled error" do + expect(Relaton::Api).to receive(:router).and_raise StandardError, "boom" + resp = Relaton::Api.handler(event: nil) + expect(resp[:statusCode]).to eq 500 + expect(resp[:body]).to include "Internal error. StandardError: boom" end end context ".router" do - it "fetch document" do + it "routes to fetch" do event = { "path" => "/api/v1/document", "httpMethod" => "GET" } expect(Relaton::Api).to receive(:fetch).with(event).and_return :resp expect(Relaton::Api.send(:router, event)).to eq :resp end - it "return version" do + it "routes to version" do event = { "path" => "/api/v1/version", "httpMethod" => "GET", @@ -56,84 +52,76 @@ expect(Relaton::Api.send(:router, event)).to eq :ver end - it "return nil when path is unknown" do + it "returns nil for unknown path" do event = { "path" => "/api/v1/unknown", "httpMethod" => "GET" } expect(Relaton::Api.send(:router, event)).to be_nil end - it "return nil when method is unknown" do + it "returns nil for unsupported method" do event = { "path" => "/api/v1/document", "httpMethod" => "POST" } expect(Relaton::Api.send(:router, event)).to be_nil end end context ".version" do - it "XML format" do + it "returns XML" do resp = Relaton::Api.send(:version, "xml") - headers = resp[:headers] expect(resp[:statusCode]).to eq 200 - expect(headers["Access-Control-Allow-Headers"]).to eq( - "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", - ) - expect(headers["Access-Control-Allow-Methods"]).to eq "GET, POST, OPTIONS" - expect(headers["Access-Control-Allow-Origin"]).to eq "*" - expect(headers["Content-Type"]).to eq "text/xml" - expect(resp[:body]).to match( - /\d+\.\d+<\/release>\d+\.\d+\.\d+<\/relaton><\/version>/, - ) - end - - it "JSON format" do + expect(resp[:headers]["Content-Type"]).to eq "text/xml" + expect(resp[:body]).to match(%r{0\.1\d+\.\d+\.\d+}) + end + + it "returns JSON" do resp = Relaton::Api.send(:version, "json") expect(resp[:headers]["Content-Type"]).to eq "application/json" - expect(resp[:body]).to match(/{"release":"\d+\.\d+","relaton":"\d+\.\d+.\d+"}/) + expect(resp[:body]).to match(/{"release":"0\.1","relaton":"\d+\.\d+\.\d+"}/) end - it "default text format" do + it "returns plain text" do resp = Relaton::Api.send(:version, nil) expect(resp[:headers]["Content-Type"]).to eq "text/plain" - expect(resp[:body]).to match(/Release:\s\d+\.\d+,\sRelaton\sversion:\s\d+\.\d+\.\d+/) + expect(resp[:body]).to match(/Release:\s0\.1,\sRelaton\sversion:\s\d+\.\d+\.\d+/) end end context ".fetch" do + let(:event) { { "queryStringParameters" => { "code" => "ISO 19115-2", "year" => "2019" } } } + let(:finder) { instance_double(Relaton::Api::Finder) } + + before :each do + allow(Relaton::Api::Finder).to receive(:instance).and_return finder + end + context "bad request" do - it "queryStringParameters is missed" do + it "rejects missing query string" do resp = Relaton::Api.send(:fetch, "path" => "/api/v1/document") expect(resp[:statusCode]).to eq 400 - expect(resp[:body]).to eq( - "Bad request. Parameters are missed or incorrect. See the documentation " \ - "https://github.com/relaton/api.relaton.org#fetch-bibdata-of-a-document", - ) + expect(resp[:body]).to eq "Bad request. Parameter 'code' is required." end - it "code is missed" do + it "rejects missing code" do resp = Relaton::Api.send(:fetch, "queryStringParameters" => {}) expect(resp[:statusCode]).to eq 400 - expect(resp[:body]).to eq("Bad request. Parameter 'code' is required.") + expect(resp[:body]).to eq "Bad request. Parameter 'code' is required." end - end - context "call finder" do - let(:event) do - { "queryStringParameters" => { "code" => "ISO 19115-2", "year" => "2019" } } - end - - let(:finder) { double "finder" } - - before :each do - expect(Relaton::Finder).to receive(:instance).and_return finder + it "rejects empty code" do + resp = Relaton::Api.send(:fetch, "queryStringParameters" => { "code" => " " }) + expect(resp[:statusCode]).to eq 400 + expect(resp[:body]).to eq "Bad request. Parameter 'code' is required." end + end - it "not found" do + context "finder integration" do + it "returns 404 when document not found" do expect(finder).to receive(:fetch).with("ISO 19115-2", "2019", {}).and_return nil resp = described_class.send(:fetch, event) expect(resp[:statusCode]).to eq 404 expect(resp[:body]).to eq "Document not found." end - it "found" do - item = double "item" + it "returns XML document when found" do + item = double("item") expect(item).to receive(:to_xml).with(bibdata: true).and_return "" expect(finder).to receive(:fetch).with("ISO 19115-2", "2019", {}).and_return item resp = described_class.send(:fetch, event) @@ -142,28 +130,96 @@ expect(resp[:body]).to eq "" end - it "hadle Aws::Xml::Parser::ParsingError" do - expect(finder).to receive(:fetch).and_raise Aws::Xml::Parser::ParsingError.new("error", 1, 1) + it "returns 503 on RequestError" do + expect(finder).to receive(:fetch).and_raise Relaton::RequestError, "upstream down" + resp = described_class.send(:fetch, event) + expect(resp[:statusCode]).to eq 503 + expect(resp[:body]).to eq "upstream down" + end + + it "returns 400 on ArgumentError" do + expect(finder).to receive(:fetch).and_raise ArgumentError, "bad args" resp = described_class.send(:fetch, event) expect(resp[:statusCode]).to eq 400 - expect(resp[:body]).to eq( - "Bad request. Parameter 'code' contains invalid symbols. See this guide " \ - "https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html", - ) + expect(resp[:body]).to eq "Bad request. bad args" end + end - it "handle RelatonBib::RequestError" do - expect(finder).to receive(:fetch).and_raise RelatonBib::RequestError, "error" + context "input normalization" do + it "normalizes whitespace in code" do + event = { "queryStringParameters" => { "code" => " ISO\u00A019115-2 " } } + item = double("item") + expect(item).to receive(:to_xml).with(bibdata: true).and_return "" + expect(finder).to receive(:fetch).with("ISO 19115-2", nil, {}).and_return item resp = described_class.send(:fetch, event) - expect(resp[:statusCode]).to eq 503 - expect(resp[:body]).to eq "error" + expect(resp[:statusCode]).to eq 200 end end end - it ".resource_not_exist" do - resp = described_class.send(:resource_not_exist) - expect(resp[:statusCode]).to eq 404 - expect(resp[:body]).to eq "Resource doesn't exist." + context ".normalize" do + it "collapses whitespace" do + expect(Relaton::Api.send(:normalize, "ISO\u00A0\u00A09000")).to eq "ISO 9000" + end + + it "strips leading/trailing space" do + expect(Relaton::Api.send(:normalize, " ISO 9000 ")).to eq "ISO 9000" + end + + it "leaves clean input unchanged" do + expect(Relaton::Api.send(:normalize, "ISO 9000:2015")).to eq "ISO 9000:2015" + end + end + + context ".extract_opts" do + it "extracts all_parts" do + params = { "all_parts" => "true", "code" => "ISO 9000" } + opts = Relaton::Api.send(:extract_opts, params) + expect(opts).to eq(all_parts: "true") + end + + it "extracts keep_year" do + params = { "keep_year" => "false", "code" => "ISO 9000" } + opts = Relaton::Api.send(:extract_opts, params) + expect(opts).to eq(keep_year: "false") + end + + it "returns empty hash for no opts" do + opts = Relaton::Api.send(:extract_opts, "code" => "ISO 9000") + expect(opts).to eq({}) + end + end + + context "response helpers" do + it "bad_request" do + resp = Relaton::Api.send(:bad_request, "nope") + expect(resp[:statusCode]).to eq 400 + expect(resp[:body]).to eq "Bad request. nope" + end + + it "not_found" do + resp = Relaton::Api.send(:not_found, "gone") + expect(resp[:statusCode]).to eq 404 + expect(resp[:body]).to eq "gone" + end + + it "service_unavailable" do + resp = Relaton::Api.send(:service_unavailable, "retry later") + expect(resp[:statusCode]).to eq 503 + expect(resp[:body]).to eq "retry later" + end + + it "internal_error" do + resp = Relaton::Api.send(:internal_error, "boom") + expect(resp[:statusCode]).to eq 500 + expect(resp[:body]).to eq "Internal error. boom" + end + + it "includes CORS headers" do + resp = Relaton::Api.send(:response, "ok") + expect(resp[:headers]["Access-Control-Allow-Origin"]).to eq "*" + expect(resp[:headers]["Access-Control-Allow-Headers"]).to include "Content-Type" + expect(resp[:headers]["Access-Control-Allow-Methods"]).to eq "GET, POST, OPTIONS" + end end end diff --git a/spec/relaton_calconnect/hit_collection_spec.rb b/spec/relaton_calconnect/hit_collection_spec.rb deleted file mode 100644 index 898ade0..0000000 --- a/spec/relaton_calconnect/hit_collection_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -describe RelatonCalconnect::HitCollection do - it "fetch document from GH" do - VCR.use_cassette "cc_18011_2018" do - hc = RelatonCalconnect::HitCollection.new "CC 18011:2018" - expect(hc.first).to be_instance_of RelatonCalconnect::Hit - expect(hc.first.fetch).to be_instance_of RelatonCalconnect::CcBibliographicItem - end - end -end diff --git a/spec/storage_spec.rb b/spec/storage_spec.rb index 8d90312..5aed0fd 100644 --- a/spec/storage_spec.rb +++ b/spec/storage_spec.rb @@ -3,6 +3,7 @@ let(:s3) { storage.instance_variable_get :@s3 } before(:each) do + allow(ENV).to receive(:fetch).with("AWS_NEW_RETRIES_2026", any_args).and_return "false" Singleton.__init__ Relaton::Storage end From efa19d01e41b78491ac73944bdcf807405525918 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 11 Jun 2026 10:01:33 +0800 Subject: [PATCH 2/4] Fix #26: normalize em-dash, en-dash, and Unicode spaces in API input --- lib/relaton/api.rb | 3 ++- spec/relaton/api_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/relaton/api.rb b/lib/relaton/api.rb index 7810cdf..f10fe3b 100644 --- a/lib/relaton/api.rb +++ b/lib/relaton/api.rb @@ -53,7 +53,8 @@ def fetch(event) end def normalize(code) - code.gsub(/[\s\u00a0]+/, " ").strip + code.gsub("\u2014", "-").gsub("\u2013", "-") + .gsub(/[\p{Z}\u00a0]+/, " ").strip end def extract_opts(params) diff --git a/spec/relaton/api_spec.rb b/spec/relaton/api_spec.rb index c58636b..52be117 100644 --- a/spec/relaton/api_spec.rb +++ b/spec/relaton/api_spec.rb @@ -154,6 +154,15 @@ resp = described_class.send(:fetch, event) expect(resp[:statusCode]).to eq 200 end + + it "normalizes em-dash in code" do + event = { "queryStringParameters" => { "code" => "ISO 19160\u20144" } } + item = double("item") + expect(item).to receive(:to_xml).with(bibdata: true).and_return "" + expect(finder).to receive(:fetch).with("ISO 19160-4", nil, {}).and_return item + resp = described_class.send(:fetch, event) + expect(resp[:statusCode]).to eq 200 + end end end @@ -169,6 +178,22 @@ it "leaves clean input unchanged" do expect(Relaton::Api.send(:normalize, "ISO 9000:2015")).to eq "ISO 9000:2015" end + + it "normalizes em-dash to hyphen" do + expect(Relaton::Api.send(:normalize, "ISO 19160\u20144")).to eq "ISO 19160-4" + end + + it "normalizes en-dash to hyphen" do + expect(Relaton::Api.send(:normalize, "ISO 19160\u20134")).to eq "ISO 19160-4" + end + + it "normalizes thin space to regular space" do + expect(Relaton::Api.send(:normalize, "ISO\u200919115-2")).to eq "ISO 19115-2" + end + + it "handles mixed dashes and spaces" do + expect(Relaton::Api.send(:normalize, "\u00A0ISO\u201419115\u20132\u00A0")).to eq "ISO-19115-2" + end end context ".extract_opts" do From fb7622e016b1a57f2420b23e4946238cf2371b36 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 11 Jun 2026 10:03:09 +0800 Subject: [PATCH 3/4] Normalize year parameter: Unicode spaces, blank-to-nil --- lib/relaton/api.rb | 4 +++- spec/relaton/api_spec.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/relaton/api.rb b/lib/relaton/api.rb index f10fe3b..6f2e8ae 100644 --- a/lib/relaton/api.rb +++ b/lib/relaton/api.rb @@ -42,7 +42,9 @@ def fetch(event) code = params["code"]&.strip return bad_request("Parameter 'code' is required.") if code.nil? || code.empty? - item = Finder.instance.fetch(normalize(code), params["year"]&.strip, extract_opts(params)) + year = params.key?("year") ? normalize(params["year"]) : nil + year = nil if year&.empty? + item = Finder.instance.fetch(normalize(code), year, extract_opts(params)) return not_found("Document not found.") unless item response item.to_xml(bibdata: true), type: "text/xml" diff --git a/spec/relaton/api_spec.rb b/spec/relaton/api_spec.rb index 52be117..da604af 100644 --- a/spec/relaton/api_spec.rb +++ b/spec/relaton/api_spec.rb @@ -163,6 +163,24 @@ resp = described_class.send(:fetch, event) expect(resp[:statusCode]).to eq 200 end + + it "normalizes whitespace in year" do + event = { "queryStringParameters" => { "code" => "ISO 19115-2", "year" => "\u00A02019\u00A0" } } + item = double("item") + expect(item).to receive(:to_xml).with(bibdata: true).and_return "" + expect(finder).to receive(:fetch).with("ISO 19115-2", "2019", {}).and_return item + resp = described_class.send(:fetch, event) + expect(resp[:statusCode]).to eq 200 + end + + it "converts blank year to nil" do + event = { "queryStringParameters" => { "code" => "ISO 19115-2", "year" => " " } } + item = double("item") + expect(item).to receive(:to_xml).with(bibdata: true).and_return "" + expect(finder).to receive(:fetch).with("ISO 19115-2", nil, {}).and_return item + resp = described_class.send(:fetch, event) + expect(resp[:statusCode]).to eq 200 + end end end From 6ed9acce75986441b9634e49a9584f1c3b7e63ab Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 11 Jun 2026 10:13:54 +0800 Subject: [PATCH 4/4] Fix #26: let pubid handle code year parsing in Finder - Finder now uses pubid 2.0 to detect if code already has an embedded year - When both code has an embedded year and separate year param is provided, the separate year is dropped to avoid double-year cache keys in std_id - Uses Processor-based pubid module mapping for ISO, IEC, IEEE, ITU, NIST, IHO, BSI, CEN, JIS, CCSDS, ETSI, PLATEAU --- lib/relaton/api/finder.rb | 43 ++++++++++++++++++++++++++++++++++++++ spec/finder_spec.rb | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/lib/relaton/api/finder.rb b/lib/relaton/api/finder.rb index 3061746..05ff1e9 100644 --- a/lib/relaton/api/finder.rb +++ b/lib/relaton/api/finder.rb @@ -12,14 +12,57 @@ class Api class Finder include Singleton + PUBID_MAP = { + relaton_iso: "pubid/iso", + relaton_iec: "pubid/iec", + relaton_ieee: "pubid/ieee", + relaton_itu: "pubid/itu", + relaton_nist: "pubid/nist", + relaton_iho: "pubid/iho", + relaton_bsi: "pubid/bsi", + relaton_cen: "pubid/cen", + relaton_jis: "pubid/jis", + relaton_ccsds: "pubid/ccsds", + relaton_etsi: "pubid/etsi", + relaton_plateau: "pubid/plateau", + }.freeze + def initialize Relaton.configure { |config| config.use_api = false } + @registry = Relaton::Registry.instance @db = Relaton::Db.init_bib_caches global_cache: true end def fetch(code, year = nil, opts = {}) + year = nil if year && embedded_year?(code) @db.fetch(code, year, opts) end + + private + + def embedded_year?(code) + stdclass = @registry.class_by_ref(code) + return false unless stdclass + + path = PUBID_MAP[stdclass] + return false unless path + + require path + mod = pubid_module(stdclass) + return false unless mod + + parsed = mod::Identifier.parse(code) + !parsed.year.nil? + rescue StandardError + false + end + + def pubid_module(stdclass) + name = stdclass.to_s.sub("relaton_", "").capitalize + Pubid.const_get(name, false) + rescue NameError + nil + end end end end diff --git a/spec/finder_spec.rb b/spec/finder_spec.rb index cdbf405..e16123b 100644 --- a/spec/finder_spec.rb +++ b/spec/finder_spec.rb @@ -18,4 +18,48 @@ expect(db).to receive(:fetch).with("ISO 9000", "2015", {}) described_class.instance.fetch "ISO 9000", "2015", {} end + + context "embedded year detection" do + it "passes year when code has no embedded year" do + expect(db).to receive(:fetch).with("ISO 9001", "2015", {}) + described_class.instance.fetch "ISO 9001", "2015", {} + end + + it "drops year when code has embedded year" do + expect(db).to receive(:fetch).with("ISO 9001:2005", nil, {}) + described_class.instance.fetch "ISO 9001:2005", "2011", {} + end + + it "handles no year param correctly" do + expect(db).to receive(:fetch).with("ISO 9001:2005", nil, {}) + described_class.instance.fetch "ISO 9001:2005", nil, {} + end + + it "passes year when pubid module encounters NameError" do + finder = described_class.instance + expect(finder.send(:pubid_module, :relaton_unknown)).to be_nil + end + + it "handles codes with part numbers but no year" do + expect(db).to receive(:fetch).with("ISO 19115-2", "2019", {}) + described_class.instance.fetch "ISO 19115-2", "2019", {} + end + + it "drops year for IEC codes with embedded year" do + expect(db).to receive(:fetch).with("IEC 60068-2-1:2007", nil, {}) + described_class.instance.fetch "IEC 60068-2-1:2007", "2020", {} + end + + it "passes year when code has no matching pubid module" do + expect(db).to receive(:fetch).with("GB/T 12345", "2020", {}) + described_class.instance.fetch "GB/T 12345", "2020", {} + end + + it "passes year when pubid parse raises" do + require "pubid/iso" + allow(Pubid::Iso::Identifier).to receive(:parse).and_raise(StandardError) + expect(db).to receive(:fetch).with("ISO 9001:2005", "2020", {}) + described_class.instance.fetch "ISO 9001:2005", "2020", {} + end + end end