diff --git a/README.md b/README.md index a30e832..0e3af71 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Archivesspace Client +# ArchivesSpace Client Interact with ArchivesSpace via the API. @@ -6,17 +6,18 @@ Interact with ArchivesSpace via the API. * [Installation](#installation) * [Usage](#usage) - + [Configuring a client](#configuring-a-client) - - [Default configuration](#default-configuration) - - [Custom configuration, on the fly](#custom-configuration-on-the-fly) - - [Custom configuration, stored for use with CLI or console](#custom-configuration-stored-for-use-with-cli-or-console) - + [Making basic requests](#making-basic-requests) - + [Setting a repository context](#setting-a-repository-context) + * [Configuring a client](#configuring-a-client) + * [Default configuration](#default-configuration) + * [Custom configuration, on the fly](#custom-configuration-on-the-fly) + * [Custom configuration, stored for use with CLI or console](#custom-configuration-stored-for-use-with-cli-or-console) + * [Making basic requests](#making-basic-requests) + * [Setting a repository context](#setting-a-repository-context) * [Templates](#templates) * [CLI](#cli) * [Console usage](#console-usage) * [Development](#development) * [Publishing](#publishing) +* [Changelog](#changelog) * [Contributing](#contributing) * [License](#license) @@ -56,7 +57,7 @@ Create client with default settings (`localhost:8089`, `admin`, `admin`): client = ArchivesSpace::Client.new.login ``` -#### Custom configuration, on the fly +#### Custom configuration ```ruby config = ArchivesSpace::Configuration.new({ @@ -72,8 +73,6 @@ config = ArchivesSpace::Configuration.new({ client = ArchivesSpace::Client.new(config).login ``` -**NOTE:** `ArchivesSpace::Configuration` allows you to set a `base_repo` value as well, but if this value is set in your config at the start, calls to API endpoints that do not include a repository id in the URI may not work correctly. It is recommended you set/unset the client repository as needed during use via the `#repository` method as described below. If you must set `base_repo` in the config used to create your client, note that the value should be like: "repositories/2", and not just the integer repository id. - #### Custom configuration, stored for use with CLI or console Create a file containing JSON config data like: @@ -138,20 +137,33 @@ client.get('repositories/2/digital_objects', query: {page: 1}) You can do: ```ruby -client.repository(2) -client.get('digital_objects', query: {page: 1}) +client.repository(2) do + client.get('digital_objects', query: {page: 1}) +end + +# now back in the global scope ``` -To reset: +Scopes restore the previous context on exit (even if the block raises) and can +be nested: ```ruby -client.repository(nil) +client.repository(2) do + client.repository(3) do + client.resources # scoped to repo 3 + end + client.resources # back to repo 2 +end + +# now back in the global scope ``` -Or: +You can also set the scope persistently (without a block) and clear it later: ```ruby -client.use_global_repository +client.repository(2) +client.resources +client.use_global_repository # or: client.repository(nil) ``` ## Templates @@ -246,9 +258,26 @@ bundle exec rake When an updated version (`lib/archivesspace/client/version.rb`) is merged into the main/master branch a new release will be built and published. +## Changelog + +### 0.5.0 + +Breaking changes: + +* Removed the `base_repo` configuration option. Use `client.repository(id)` to + scope requests to a repository instead, either persistently or with a + block that auto-restores the previous scope. +* `client.repository(id)` with a block now saves and restores the previous + context (including when nested or when the block raises), instead of always + resetting to the global scope. +* Login failure now raises `ArchivesSpace::AuthenticationError` (was + `ConnectionError`). `ConnectionError` has been removed. +* `Client.new` raises `ArchivesSpace::ConfigurationError` (was `RuntimeError`) + when given a non-`Configuration` argument. + ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/lyrasis/archivesspace-client. +Bug reports and pull requests are welcome on GitHub at . ## License diff --git a/examples/export.rb b/examples/export.rb index 08298be..4e17127 100644 --- a/examples/export.rb +++ b/examples/export.rb @@ -8,7 +8,6 @@ config = ArchivesSpace::Configuration.new( { base_uri: "https://test.archivesspace.org/staff/api", - base_repo: "", username: "admin", password: "admin", page_size: 50, @@ -19,17 +18,18 @@ client = ArchivesSpace::Client.new(config).login client.config.throttle = 0.5 -client.config.base_repo = "repositories/2" begin - # date -d '2021-02-01 00:00:00' +'%s' # 1612166400 - client.resources(query: {modified_since: "1612166400"}).each do |resource| - # for now we are just printing ... - # but you would actually write to a zip file or whatever - id = resource["uri"].split("/")[-1] - opts = {include_unpublished: false} - response = client.get("resource_descriptions/#{id}.xml", opts) - puts Nokogiri::XML(response.body).to_xml + client.repository 2 do + # date -d '2021-02-01 00:00:00' +'%s' # 1612166400 + client.resources(query: {modified_since: "1612166400"}).each do |resource| + # for now we are just printing ... + # but you would actually write to a zip file or whatever + id = resource["uri"].split("/")[-1] + opts = {include_unpublished: false} + response = client.get("resource_descriptions/#{id}.xml", opts) + puts Nokogiri::XML(response.body).to_xml + end end rescue ArchivesSpace::RequestError => e puts e.message diff --git a/examples/repo_and_user.rb b/examples/repo_and_user.rb index 0d69b37..198c883 100644 --- a/examples/repo_and_user.rb +++ b/examples/repo_and_user.rb @@ -7,7 +7,6 @@ config = ArchivesSpace::Configuration.new( { base_uri: "https://sandbox.archivesspace.org/staff/api", - base_repo: "", username: "admin", password: "admin", page_size: 50, diff --git a/examples/test_connection.rb b/examples/test_connection.rb index 6e3ff55..584c4d9 100644 --- a/examples/test_connection.rb +++ b/examples/test_connection.rb @@ -6,8 +6,7 @@ # official sandbox config = ArchivesSpace::Configuration.new( { - base_uri: "https://sandbox.archivesspace.org/staff/api", - base_repo: "", + base_uri: "https://test.archivesspace.org/staff/api", username: "admin", password: "admin", page_size: 50, @@ -17,4 +16,16 @@ ) client = ArchivesSpace::Client.new(config).login + +# globally scoped puts client.get("version").body +puts client.get("repositories").body +puts client.all("users").map { |u| u["username"] }.to_a + +# repo scoped +client.repository 2 do + puts client.get("accessions", query: {page: 1}).parsed["results"].map { |a| a["uri"] }.to_a +end + +# globally scoped, full path arg +puts client.get("repositories/2/resources", query: {all_ids: true}).body diff --git a/examples/update_feed.rb b/examples/update_feed.rb index 3e06c63..43c9f56 100644 --- a/examples/update_feed.rb +++ b/examples/update_feed.rb @@ -6,7 +6,6 @@ config = ArchivesSpace::Configuration.new( { base_uri: "https://test.archivesspace.org/staff/api", - base_repo: "", username: "admin", password: "admin", page_size: 50, diff --git a/examples/user_groups.rb b/examples/user_groups.rb index 9b9c95f..6494057 100644 --- a/examples/user_groups.rb +++ b/examples/user_groups.rb @@ -7,7 +7,6 @@ config = ArchivesSpace::Configuration.new( { base_uri: "https://sandbox.archivesspace.org/staff/api", - base_repo: "", username: "admin", password: "admin", page_size: 50, @@ -31,9 +30,10 @@ } begin - client.config.base_repo = "repositories/2" - results = client.group_user_assignment users_with_roles - puts results.map(&:parsed) + client.repository 2 do + results = client.group_user_assignment users_with_roles + puts results.map(&:parsed) + end rescue ArchivesSpace::RequestError => e puts e.message end diff --git a/lib/archivesspace/client.rb b/lib/archivesspace/client.rb index 4c80719..16fa2b7 100644 --- a/lib/archivesspace/client.rb +++ b/lib/archivesspace/client.rb @@ -22,7 +22,9 @@ require "archivesspace/client/cli" # load the registry last module ArchivesSpace - class ConnectionError < StandardError; end + class AuthenticationError < StandardError; end + + class ConfigurationError < StandardError; end class RepositoryIdError < StandardError; end diff --git a/lib/archivesspace/client/client.rb b/lib/archivesspace/client/client.rb index 97fb999..2684cb8 100644 --- a/lib/archivesspace/client/client.rb +++ b/lib/archivesspace/client/client.rb @@ -6,14 +6,18 @@ class Client include Task attr_accessor :token - attr_reader :config + attr_reader :config, :context NAME = "ArchivesSpaceClient" + TOKEN = "X-ArchivesSpace-Session" def initialize(config = Configuration.new) - raise "Invalid configuration object" unless config.is_a? ArchivesSpace::Configuration + unless config.is_a? ArchivesSpace::Configuration + raise ConfigurationError, "expected ArchivesSpace::Configuration, got #{config.class}" + end @config = config + @context = nil @token = nil end @@ -39,10 +43,7 @@ def delete(path) # Scoping requests def repository(id) - if id.nil? - use_global_repository - return - end + return use_global_repository if id.nil? begin Integer(id) @@ -50,20 +51,28 @@ def repository(id) raise RepositoryIdError, "Invalid Repository id: #{id}" end - @config.base_repo = "repositories/#{id}" + new_context = "repositories/#{id}" + return @context = new_context unless block_given? + + previous = @context + @context = new_context + begin + yield + ensure + @context = previous + end end def use_global_repository - @config.base_repo = "" + @context = nil end private def request(method, path, options = {}) sleep config.throttle - options[:headers] = {"X-ArchivesSpace-Session" => token} if token - result = Request.new(config, method, path, options).execute - Response.new result + options[:headers] = {TOKEN => token} if token + Request.new(context, config, method, path, options).execute end end end diff --git a/lib/archivesspace/client/configuration.rb b/lib/archivesspace/client/configuration.rb index f2fe0b3..93261d1 100644 --- a/lib/archivesspace/client/configuration.rb +++ b/lib/archivesspace/client/configuration.rb @@ -2,19 +2,18 @@ module ArchivesSpace class Configuration - attr_accessor :base_uri, :base_repo, :debug, :username, :password, - :page_size, :throttle, :verify_ssl, :timeout + attr_accessor :base_uri, :debug, :username, :password, + :page_size, :throttle, :timeout, :verify_ssl DEFAULTS = { base_uri: "http://localhost:8089", - base_repo: "", debug: false, username: "admin", password: "admin", page_size: 50, throttle: 0, - verify_ssl: true, - timeout: 60 + timeout: 60, + verify_ssl: true }.freeze def initialize(settings = {}) diff --git a/lib/archivesspace/client/request.rb b/lib/archivesspace/client/request.rb index 2da71ad..dd3b81d 100644 --- a/lib/archivesspace/client/request.rb +++ b/lib/archivesspace/client/request.rb @@ -6,40 +6,29 @@ class Request attr_reader :config, :headers, :method, :path, :options - def default_headers(method = :get) - headers = { - delete: {}, - get: {}, - post: { - "Content-Type" => "application/json" - }, - put: { - "Content-Type" => "application/json" - } - } - headers[method] - end + DEFAULT_HEADERS = {"Content-Type" => "application/json"}.freeze - def initialize(config, method = "GET", path = "", options = {}) + def initialize(context, config, method = "GET", path = "", options = {}) @config = config + @method = method.downcase.to_sym @path = path.gsub(%r{^/+}, "") + @options = options - @options[:headers] = - options[:headers] ? default_headers(@method).merge(options[:headers]) : default_headers(@method) + + @options[:headers] = DEFAULT_HEADERS.merge(@options.fetch(:headers, {})) @options[:headers]["User-Agent"] = "#{Client::NAME}/#{Client::VERSION}" + @options[:verify] = config.verify_ssl @options[:timeout] = config.timeout @options[:query] = {} unless options.key? :query self.class.debug_output($stdout) if @config.debug - - base_uri = config.base_repo&.length&.positive? ? File.join(config.base_uri, config.base_repo) : config.base_uri - self.class.base_uri base_uri + self.class.base_uri context ? "#{config.base_uri}/#{context}" : config.base_uri end def execute - self.class.send method, "/#{path}", options + Response.new(self.class.send(method, "/#{path}", options)) end end end diff --git a/lib/archivesspace/client/task.rb b/lib/archivesspace/client/task.rb index a6722a6..1e4a209 100644 --- a/lib/archivesspace/client/task.rb +++ b/lib/archivesspace/client/task.rb @@ -34,16 +34,20 @@ def group_user_assignment(users_with_roles) def login username = config.username password = config.password - base_repo = config.base_repo - use_global_repository # ensure we're in the global scope to login - result = request("POST", "/users/#{username}/login", {query: {password: password}}) - unless result.parsed["session"] - raise ConnectionError, "API client login failed as user [#{username}], check username and password are correct" - end - config.base_repo = base_repo # reset repo as set by the cfg - @token = result.parsed["session"] - self + previous_context = @context + @context = nil + begin + result = request("POST", "/users/#{username}/login", {query: {password: password}}) + unless result.parsed["session"] + raise AuthenticationError, "Login failed as user [#{username}] (status #{result.status_code}); check username and password" + end + + @token = result.parsed["session"] + self + ensure + @context = previous_context + end end def password_reset(username, password) diff --git a/lib/archivesspace/client/version.rb b/lib/archivesspace/client/version.rb index b57e6e0..e08ac3f 100644 --- a/lib/archivesspace/client/version.rb +++ b/lib/archivesspace/client/version.rb @@ -2,6 +2,6 @@ module ArchivesSpace class Client - VERSION = "0.4.2" + VERSION = "0.5.0" end end diff --git a/spec/archivesspace/client_spec.rb b/spec/archivesspace/client_spec.rb index f55710c..1535fa1 100644 --- a/spec/archivesspace/client_spec.rb +++ b/spec/archivesspace/client_spec.rb @@ -11,7 +11,7 @@ end it "will raise an error if supplied configuration is of invalid type" do - expect { ArchivesSpace::Client.new({base_uri: CUSTOM_BASE_URI}) }.to raise_error(RuntimeError) + expect { ArchivesSpace::Client.new({base_uri: CUSTOM_BASE_URI}) }.to raise_error(ArchivesSpace::ConfigurationError) end it "will allow a configuration object to be provided" do @@ -20,15 +20,86 @@ end end + describe "Login" do + it "sends the login request at global scope even when a repo context is set" do + client.repository 2 + expect(ArchivesSpace::Request).to receive(:new) + .with(nil, client.config, "POST", "/users/admin/login", anything) + .and_wrap_original do |_orig, *_args| + double("request", execute: double("response", parsed: {"session" => "token"}, status_code: 200)) + end + client.login + end + + it "restores the previous repo context after login" do + client.repository 2 + allow(ArchivesSpace::Request).to receive(:new).and_return( + double("request", execute: double("response", parsed: {"session" => "token"}, status_code: 200)) + ) + client.login + expect(client.context).to eq "repositories/2" + end + + it "raises AuthenticationError and restores context when credentials are rejected" do + client.repository 2 + allow(ArchivesSpace::Request).to receive(:new).and_return( + double("request", execute: double("response", parsed: {}, status_code: 403)) + ) + expect { client.login }.to raise_error(ArchivesSpace::AuthenticationError, /status 403/) + expect(client.context).to eq "repositories/2" + end + + it "raises AuthenticationError for a real 403 response (VCR)" do + bad_client = ArchivesSpace::Client.new( + ArchivesSpace::Configuration.new(password: "wrong") + ) + VCR.use_cassette("login_failure") do + expect { bad_client.login }.to raise_error( + ArchivesSpace::AuthenticationError, /admin.*status 403/ + ) + end + end + end + + describe "Pagination" do + it "will have a method for defined paginated record types" do + ArchivesSpace::Pagination::ENDPOINTS.each do |e| + next if e.match?("/") + + expect(client.respond_to?(e.to_sym)).to be true + end + end + + it "will have a method for defined paginated record types with multipart path" do + expect(client.respond_to?(:people)).to be true + end + + it "will pass page_size from configuration to the query" do + response = double("response", parsed: {"results" => []}) + allow(client).to receive(:get).and_return(response) + client.all("resources").first + expect(client).to have_received(:get).with("resources", hash_including(query: hash_including(page_size: 50))) + end + end + + describe "Password reset" do + it "will raise an error if the user is not found" do + allow(client).to receive(:all).with("users").and_return([].lazy) + expect { client.password_reset("nonexistent", "newpass") }.to raise_error( + ArchivesSpace::RequestError, "User not found: nonexistent" + ) + end + end + describe "Repository scoping" do - it "will set the repository with an integer id" do + it "will set the context with an integer id" do client.repository 2 - expect(client.config.base_repo).to eq "repositories/2" + expect(client.context).to eq "repositories/2" end - it "will set the repository with a string id cast to integer" do + it "will set the context with a string id cast to integer" do client.repository "2" - expect(client.config.base_repo).to eq "repositories/2" + expect(client.context).to eq "repositories/2" end it "will fail if the id cannot be cast to integer" do @@ -43,53 +114,75 @@ ) end - it "will use the global repo if repository is passed nil" do + it "will clear the context when passed nil" do client.repository 2 client.repository nil - expect(client.config.base_repo).to eq "" + expect(client.context).to be_nil end - it "will use the global repo when the method is called" do + it "will clear the context when use_global_repository is called" do client.repository 2 client.use_global_repository - expect(client.config.base_repo).to eq "" + expect(client.context).to be_nil + end + + it "scopes the context to a block and restores afterwards" do + client.repository(2) do + expect(client.context).to eq "repositories/2" + end + expect(client.context).to be_nil + end + + it "restores the previous context when nested" do + client.repository 2 + client.repository(3) do + expect(client.context).to eq "repositories/3" + end + expect(client.context).to eq "repositories/2" + end + + it "restores the context even if the block raises" do + expect { client.repository(2) { raise "boom" } }.to raise_error("boom") + expect(client.context).to be_nil + end + + it "does not rebrand ArgumentError raised from within the block" do + expect { + client.repository(2) { raise ArgumentError, "from caller" } + }.to raise_error(ArgumentError, "from caller") end end describe "Requests" do it "will have an identifiable user agent" do - request = ArchivesSpace::Request.new(client.config) + request = ArchivesSpace::Request.new(nil, client.config) expect(request.options[:headers]["User-Agent"]).to eq "#{ArchivesSpace::Client::NAME}/#{ArchivesSpace::Client::VERSION}" end end - describe "Pagination" do - it "will have a method for defined paginated record types" do - ArchivesSpace::Pagination::ENDPOINTS.each do |e| - next if e.match?("/") + describe "URL construction" do + let(:config) { ArchivesSpace::Configuration.new(base_uri: "http://localhost:8089") } - expect(client.respond_to?(e.to_sym)).to be true - end + it "uses the bare base_uri when context is nil" do + ArchivesSpace::Request.new(nil, config, "GET", "repositories") + expect(ArchivesSpace::Request.base_uri).to eq "http://localhost:8089" end - it "will have a method for defined paginated record types with multipart path" do - expect(client.respond_to?(:people)).to be true + it "appends a repository context to the base_uri" do + ArchivesSpace::Request.new("repositories/2", config, "GET", "resources") + expect(ArchivesSpace::Request.base_uri).to eq "http://localhost:8089/repositories/2" end - it "will pass page_size from configuration to the query" do - response = double("response", parsed: {"results" => []}) - allow(client).to receive(:get).and_return(response) - client.all("resources").first - expect(client).to have_received(:get).with("resources", hash_including(query: hash_including(page_size: 50))) + it "preserves a path prefix in base_uri when context is nil" do + config.base_uri = "https://example.org/staff/api" + ArchivesSpace::Request.new(nil, config, "GET", "repositories") + expect(ArchivesSpace::Request.base_uri).to eq "https://example.org/staff/api" end - end - describe "Password reset" do - it "will raise an error if the user is not found" do - allow(client).to receive(:all).with("users").and_return([].lazy) - expect { client.password_reset("nonexistent", "newpass") }.to raise_error( - ArchivesSpace::RequestError, "User not found: nonexistent" - ) + it "preserves a path prefix in base_uri when scoped to a repository" do + config.base_uri = "https://example.org/staff/api" + ArchivesSpace::Request.new("repositories/2", config, "GET", "resources") + expect(ArchivesSpace::Request.base_uri).to eq "https://example.org/staff/api/repositories/2" end end diff --git a/spec/fixtures/cassettes/login_failure.yml b/spec/fixtures/cassettes/login_failure.yml new file mode 100644 index 0000000..4c2e28c --- /dev/null +++ b/spec/fixtures/cassettes/login_failure.yml @@ -0,0 +1,37 @@ +--- +http_interactions: +- request: + method: post + uri: http://localhost:8089/users/admin/login?password=wrong + body: + encoding: UTF-8 + string: '' + headers: + Content-Type: + - application/json + User-Agent: + - ArchivesSpaceClient/0.3.0 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 403 + message: Forbidden + headers: + Cache-Control: + - private, must-revalidate, max-age=0 + Content-Type: + - application/json + X-Content-Type-Options: + - nosniff + Content-Length: + - '24' + Server: + - Jetty(9.4.44.v20210927) + body: + encoding: UTF-8 + string: '{"error":"Login failed"}' + recorded_at: Tue, 22 Apr 2026 12:00:00 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 29c3451..f40f4bb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,8 +6,8 @@ require "webmock/rspec" # GLOBAL VALUES FOR SPECS -DEFAULT_BASE_URI = "http://localhost:8089" CUSTOM_BASE_URI = "https://archives.university.edu/api" +DEFAULT_BASE_URI = "http://localhost:8089" VCR.configure do |c| c.cassette_library_dir = "spec/fixtures/cassettes"