sul-dlss/sdr-client

View on GitHub
lib/sdr_client/cli.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require 'launchy'
require 'thor'
require_relative 'cli/config'

module SdrClient
  # The SDR command-line interface
  class CLI < Thor
    include Thor::Actions

    # Make sure Thor commands preserve exit statuses
    # @see https://github.com/rails/thor/wiki/Making-An-Executable
    def self.exit_on_failure?
      true
    end

    # Print out help and exit with error code if command not found
    def self.handle_no_command_error(command)
      puts "Command '#{command}' not found, displaying help:"
      puts
      puts help
      exit(1)
    end

    def self.default_url
      'https://sdr-api-prod.stanford.edu'
    end

    package_name 'sdr'

    class_option :url, desc: 'URL of SDR API endpoint', type: :string, default: default_url

    desc 'get DRUID', 'Retrieve an object from the SDR'
    def get(druid)
      rescue_expected_exceptions do
        say Find.run(druid, url: options[:url], logger: Logger.new($stderr))
      end
    end

    desc 'login', 'Open authentication proxy UI, or prompt for username and password, and then prompt for token (saved in ~/.sdr/credentials)'
    def login
      authentication_proxy_url ? login_via_proxy : login_via_credentials
    end

    desc 'version', 'Display the SDR CLI version'
    def version
      say VERSION
    end

    desc 'update DRUID', 'Update an object in the SDR'
    option :skip_polling, type: :boolean, default: false, aliases: '-s', desc: 'Print out job ID instead of polling for result'
    option :apo, desc: 'Druid identifier of the admin policy object', aliases: '--admin-policy'
    option :collection, desc: 'Druid identifier of the collection object'
    option :copyright, desc: 'Copyright statement'
    option :use_and_reproduction, desc: 'Use and reproduction statement'
    option :license, desc: 'License URI'
    option :view, enum: %w[world stanford location-based citation-only dark], desc: 'Access view level for the object'
    option :download, enum: %w[world stanford location-based none], desc: 'Access download level for the object'
    option :location, enum: %w[spec music ars art hoover m&m], desc: 'Access location for the object'
    option :cdl, type: :boolean, default: false, desc: 'Controlled digital lending'
    option :cocina_file, desc: 'Path to a file containing Cocina JSON'
    option :cocina_pipe, type: :boolean, default: false, desc: 'Indicate Cocina JSON is being piped in'
    option :basepath, default: Dir.getwd, desc: 'Base path for the files'
    def update(druid)
      rescue_expected_exceptions do
        validate_druid!(druid)
        job_id = Update.run(druid, **options)
        poll_for_job_complete(job_id: job_id, url: options[:url])
      end
    end

    desc 'deposit OPTIONAL_FILES', 'Deposit (accession) an object into the SDR'
    option :skip_polling, type: :boolean, default: false, aliases: '-s', desc: 'Print out job ID instead of polling for result'
    option :apo, required: true, desc: 'Druid identifier of the admin policy object', aliases: '--admin-policy'
    option :source_id, required: true, desc: 'Source ID for this object'
    option :label, desc: 'Object label'
    option :type, enum: %w[image book document map manuscript media three_dimensional object collection admin_policy], desc: 'The object type'
    option :collection, desc: 'Druid identifier of the collection object'
    option :catkey, desc: 'Symphony catkey for this item'
    option :folio_instance_hrid, desc: 'Folio instance HRID for this item'
    option :copyright, desc: 'Copyright statement'
    option :use_and_reproduction, desc: 'Use and reproduction statement'
    option :viewing_direction, enum: %w[left-to-right right-to-left], desc: 'Viewing direction (if a book)'
    option :view, enum: %w[world stanford location-based citation-only dark], desc: 'Access view level for the object'
    option :download, enum: %w[world stanford location-based none], desc: 'Access download level for the object'
    option :location, enum: %w[spec music ars art hoover m&m], desc: 'Access location for the object'
    option :files_metadata, desc: 'JSON string representing per-file metadata'
    option :grouping_strategy, enum: %w[default filename], desc: 'Strategy for grouping files into filesets'
    option :basepath, default: Dir.getwd, desc: 'Base path for the files'
    def deposit(*files)
      register_or_deposit(files: files, accession: true)
    end

    desc 'register OPTIONAL_FILES', 'Create a draft object in the SDR and retrieve a Druid identifier'
    option :skip_polling, type: :boolean, default: false, aliases: '-s', desc: 'Print out job ID instead of polling for result'
    option :apo, required: true, desc: 'Druid identifier of the admin policy object', aliases: '--admin-policy'
    option :source_id, required: true, desc: 'Source ID for this object'
    option :label, desc: 'Object label'
    option :type, enum: %w[image book document map manuscript media three_dimensional object collection admin_policy], desc: 'The object type'
    option :collection, desc: 'Druid identifier of the collection object'
    option :catkey, desc: 'Symphony catkey for this item'
    option :folio_instance_hrid, desc: 'Folio instance HRID for this item'
    option :copyright, desc: 'Copyright statement'
    option :use_and_reproduction, desc: 'Use and reproduction statement'
    option :viewing_direction, enum: %w[left-to-right right-to-left], desc: 'Viewing direction (if a book)'
    option :view, enum: %w[world stanford location-based citation-only dark], desc: 'Access view level for the object'
    option :download, enum: %w[world stanford location-based none], desc: 'Access download level for the object'
    option :location, enum: %w[spec music ars art hoover m&m], desc: 'Access location for the object'
    option :files_metadata, desc: 'JSON string representing per-file metadata'
    option :grouping_strategy, enum: %w[default filename], desc: 'Strategy for grouping files into filesets'
    option :basepath, default: Dir.getwd, desc: 'Base path for the files'
    def register(*files)
      register_or_deposit(files: files, accession: false)
    end

    private

    def rescue_expected_exceptions
      yield
    rescue UnexpectedResponse::TokenExpired
      say_error 'Token has expired! Please log in again.'
      exit(1)
    rescue Credentials::NoCredentialsError
      say_error 'No token found! Please log in first.'
      exit(1)
    end

    def login_via_proxy
      say 'Opened the configured authentication proxy in your browser. Once there, generate a new token and copy the entire value.'
      Launchy.open(authentication_proxy_url)
      # Some CLI environments will pop up a message about opening the URL in
      # an existing browse. Since this is OS-dependency, and not something
      # we can control via Launchy, just wait a bit before rendering the
      # `ask` prompt so it's clearer to the user what's happening
      sleep 0.5
      token_string = ask('Paste token here:')
      Credentials.write(token_string)
      expiry = JSON.parse(token_string)['exp']
      say "You are now authenticated for #{options[:url]} until #{expiry}"
    rescue StandardError => e
      say_error "Error logging in via proxy: #{e}"
      exit(1)
    end

    def login_via_credentials
      status = Login.run(
        url: options[:url],
        login_service: lambda do
          {
            email: ask('Email:'),
            password: ask('Password:', echo: false)
          }
        end
      )

      return puts unless status.failure?

      say_error status.failure
      exit(1)
    end

    def authentication_proxy_url
      @authentication_proxy_url ||= Settings.authentication_proxy_url[options[:url]]
    end

    def register_or_deposit(files:, accession:)
      rescue_expected_exceptions do
        opts = munge_options(options, files)
        skip_polling = opts.delete(:skip_polling)
        job_id = Deposit.run(accession: accession, **opts)
        return if skip_polling

        poll_for_job_complete(job_id: job_id, url: opts[:url])
      end
    end

    def munge_options(options, files)
      options.to_h.symbolize_keys.tap do |opts|
        opts[:files] = expand_files(files) if files.present?
        opts[:type] = Cocina::Models::ObjectType.public_send(options[:type]) if options[:type]
        opts[:files_metadata] = JSON.parse(options[:files_metadata]) if options[:files_metadata]
        if options[:grouping_strategy]
          opts[:grouping_strategy] = if options[:grouping_strategy] == 'filename'
                                       Deposit::MatchingFileGroupingStrategy
                                     else
                                       Deposit::SingleFileGroupingStrategy
                                     end
        end
      end
    end

    def expand_files(files)
      files.map do |file|
        if Dir.exist?(file)
          Dir.glob("#{file}/**/*").select { |f| File.file?(f) }
        else
          file
        end
      end.flatten
    end

    def validate_druid!(druid)
      return if druid.present?

      say_error "Not a valid druid: #{druid.inspect}"
      exit(1)
    end

    def poll_for_job_complete(job_id:, url:)
      # the extra args to `say` prevent appending a newline
      say('SDR is processing your request.', nil, false)
      result = nil
      1.upto(60) do
        result = BackgroundJobResults.show(url: url, job_id: job_id)
        break unless %w[pending processing].include?(result['status'])

        # the extra args to `say` prevent appending a newline
        say('.', nil, false)
        sleep 1
      end

      if result['status'] == 'complete'
        if (errors = result.dig('output', 'errors'))
          say_error " errored! #{errors}"
        else
          say " success! (druid: #{result.dig('output', 'druid')})"
        end
      else
        say_error " job #{job_id} did not complete\n#{result.inspect}"
      end
    end
  end
end