diff --git a/.github/config/rubocop_linter_action.yml b/.github/config/rubocop_linter_action.yml index 77a9167..ea2b8b5 100644 --- a/.github/config/rubocop_linter_action.yml +++ b/.github/config/rubocop_linter_action.yml @@ -15,6 +15,7 @@ versions: - rubocop: 'latest' - rubocop-rake: 'latest' - rubocop-rspec: 'latest' + - rubocop-factory_bot: 'latest' # Description: RuboCop configuration file path relative to the workspace. # Valid options: A valid file path inside of the workspace. diff --git a/.gitignore b/.gitignore index 80fbc7b..7ae3bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Ignore local environment files .env *.env +!sample.env +coverage/ # Ignore Byebug command history file. .byebug_history diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..b521ced --- /dev/null +++ b/.rspec @@ -0,0 +1,4 @@ +--require spec_helper.rb +--color +--format RSpec::Github::Formatter +--format documentation diff --git a/.rubocop.yml b/.rubocop.yml index fe20fcb..ef31c45 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,24 @@ +require: + - rubocop-factory_bot + - rubocop-rake + - rubocop-rspec + AllCops: NewCops: enable SuggestExtensions: true + +# Transient properties get mistaken for associations in FactoryBot. +# See https://github.com/rubocop/rubocop-factory_bot/issues/73 +FactoryBot/FactoryAssociationWithStrategy: + Enabled: false + +# Count multi-line hashes and arrays in examples as one line. +RSpec/ExampleLength: + CountAsOne: + - array + - hash + +Metrics/BlockLength: + AllowedMethods: + # Exclude grape `resource` blocks. + - resource diff --git a/Gemfile b/Gemfile index 98429ba..f5c6ffb 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,10 @@ source 'https://rubygems.org' gemspec +# TODO: Move to gemspec once a new release has been cut. +# See https://github.com/kklimuk/microsoft-graph-client/pull/4 +gem 'microsoft-graph-client', git: 'https://github.com/jamesiarmes/microsoft-graph-client.git', branch: 'non-hash-body' + group :development do gem 'rake', '~> 13.0' gem 'rubocop', '~> 1.48' diff --git a/Gemfile.lock b/Gemfile.lock index 2c18e36..30f3755 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,22 @@ +GIT + remote: https://github.com/jamesiarmes/microsoft-graph-client.git + revision: 206aa96ce1c4073b5c570accf1559af9c2e2e509 + branch: non-hash-body + specs: + microsoft-graph-client (0.1.3) + httparty + PATH remote: . specs: document-transfer-service (0.1.0) + adal (~> 1.0) + faraday (~> 2.9) grape (~> 2.0) + grape-entity (~> 1.0) + grape-swagger (~> 2.1) + grape-swagger-entity (~> 0.5) + httparty (~> 0.22) rack (~> 3.0) rackup (~> 2.1) statsd-instrument (~> 3.7) @@ -10,7 +24,7 @@ PATH GEM remote: https://rubygems.org/ specs: - activesupport (7.1.3.2) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -20,12 +34,17 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) + adal (1.0.0) + jwt (~> 1.5) + nokogiri (~> 1.6) + uri_template (~> 0.7) ast (2.4.2) base64 (0.2.0) bigdecimal (3.1.8) - builder (3.2.4) - concurrent-ruby (1.2.3) + builder (3.3.0) + concurrent-ruby (1.3.1) connection_pool (2.4.1) + csv (3.3.0) diff-lcs (1.5.1) docile (1.4.0) drb (2.2.1) @@ -46,6 +65,10 @@ GEM zeitwerk (~> 2.6) factory_bot (6.4.6) activesupport (>= 5.0.0) + faraday (2.9.1) + faraday-net_http (>= 2.0, < 3.2) + faraday-net_http (3.1.0) + net-http grape (2.0.0) activesupport (>= 5) builder @@ -53,22 +76,48 @@ GEM mustermann-grape (~> 1.0.0) rack (>= 1.3.0) rack-accept + grape-entity (1.0.1) + activesupport (>= 3.0.0) + multi_json (>= 1.3.2) + grape-swagger (2.1.0) + grape (>= 1.7, < 3.0) + rack-test (~> 2) + grape-swagger-entity (0.5.4) + grape-entity (~> 1) + grape-swagger (~> 2) + httparty (0.22.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) i18n (1.14.5) concurrent-ruby (~> 1.0) json (2.7.2) + jwt (1.5.6) language_server-protocol (3.17.0.3) - minitest (5.22.3) + mini_mime (1.1.5) + mini_portile2 (2.8.7) + minitest (5.23.1) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) mustermann (3.0.0) ruby2_keywords (~> 0.0.1) mustermann-grape (1.0.2) mustermann (>= 1.0.0) mutex_m (0.2.0) + net-http (0.4.1) + uri + nokogiri (1.16.5) + mini_portile2 (~> 2.8.2) + racc (~> 1.4) + nokogiri (1.16.5-arm64-darwin) + racc (~> 1.4) parallel (1.24.0) - parser (3.3.1.0) + parser (3.3.2.0) ast (~> 2.4.1) racc - racc (1.7.3) - rack (3.0.10) + racc (1.8.0) + rack (3.0.11) rack-accept (0.4.5) rack (>= 0.4) rack-test (2.1.0) @@ -78,8 +127,9 @@ GEM webrick (~> 1.8) rainbow (3.1.1) rake (13.2.1) - regexp_parser (2.9.0) - rexml (3.2.6) + regexp_parser (2.9.2) + rexml (3.2.8) + strscan (>= 3.0.9) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -91,11 +141,11 @@ GEM rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.13.0) + rspec-mocks (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.1) - rubocop (1.63.4) + rubocop (1.64.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -114,7 +164,7 @@ GEM rubocop (~> 1.41) rubocop-rake (0.6.0) rubocop (~> 1.0) - rubocop-rspec (2.29.2) + rubocop-rspec (2.30.0) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) @@ -130,11 +180,14 @@ GEM simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) statsd-instrument (3.7.0) + strscan (3.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + uri (0.13.0) + uri_template (0.7.0) webrick (1.8.1) - zeitwerk (2.6.13) + zeitwerk (2.6.15) PLATFORMS arm64-darwin-23 @@ -143,6 +196,7 @@ PLATFORMS DEPENDENCIES document-transfer-service! factory_bot (~> 6.2) + microsoft-graph-client! rack-test (~> 2.1) rake (~> 13.0) rspec (~> 3.12) diff --git a/README.md b/README.md index 98e36f4..1485ce5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,27 @@ A microservice to securely transfer documents. +## Configuration + +The service is configured using a series of environment variables. The actual +variables required will depend on the [source] and [destination] types you wish +to support. + +A [sample `.env` file][.env] has been included to help you get started. Simply +copy this file to `.env` and update the values as needed. + +Make sure this file is then loaded into your environment before running the +service. This can be done automatically through various shell tools (such as +[Oh My Zsh][omz]), or manually by running the following: + +```bash +source .env +``` + +_Note: Your `.env` file will contain sensitive information. This file is ignored +by git, but it is your responsibility to ensure this file remains safe and +secure._ + ## Running ### Docker @@ -35,6 +56,16 @@ bundle exec rackup The service should now be available at `http://localhost:9292`. +## Usage + +See the [API documentation][api] for information on how to interact with the +service. + +[.env]: ./sample.env +[api]: ./doc/api.md +[destination]: ./doc/destinations.md [Dockerfile]: ./Dockerfile [docker compose]: ./docker-compose.yaml [Docker Desktop]: https://docs.docker.com/desktop/ +[omz]: https://ohmyz.sh/ +[source]: ./doc/sources.md diff --git a/Rakefile b/Rakefile index 444527c..94aaec6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,10 +1,17 @@ # frozen_string_literal: true +require 'grape-swagger/rake/oapi_tasks' require 'rspec/core/rake_task' require 'rubocop/rake_task' task default: %i[spec rubocop] +task :environment do # rubocop:disable Rake/Desc + require_relative 'lib/api/api' +end + +GrapeSwagger::Rake::OapiTasks.new('::DocumentTransfer::API::API') + RuboCop::RakeTask.new(:rubocop) do |task| task.requires << 'rubocop' end diff --git a/config.ru b/config.ru index 539aa7e..857c3a6 100644 --- a/config.ru +++ b/config.ru @@ -2,4 +2,6 @@ require_relative 'lib/api/api' -run DocumentService::API +use Rack::RewindableInput::Middleware + +run DocumentTransfer::API::API diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..6a0f273 --- /dev/null +++ b/doc/api.md @@ -0,0 +1,59 @@ +# Document Transfer Service API + +Interacting with the Document Transfer Service is done through a RESTful API. +All requests and responses should be in JSON format, unless otherwise indicated. + +Full API documentation can be found in the [OpenAPI specification][spec]. + +## GET /health + +A basic health endpoint that will return a 200 status code if the API is +running. + +_Note: This endpoint does not require authentication._ + +Example response: + +```json +{ "status": "ok" } +``` + +## POST /transfer + +Initiate a document transfer. This is a synchronous request that will return +once the transfer is complete or a failure occurs. + +The required parameters for this request will vary based on the [source] and +[destination] types. + +Successful requests will always include a `status` and `destination` field. +Additional fields may be included based on the destination type. + +### Example request + +```json +{ + "source": { + "type": "url", + "url": "https://example.com/document.pdf" + }, + "destination": { + "type": "onedrive", + "path": "/document/path" + } +} +``` + +### Example response: + +```json +{ + "status": "success", + "destination": "onedrive", + "path": "/document/path/document.pdf" +} +``` + +[destination]: destinations.md +[source]: sources.md +[spec]: ../openapi.yaml diff --git a/doc/destinations.md b/doc/destinations.md new file mode 100644 index 0000000..ccf9b59 --- /dev/null +++ b/doc/destinations.md @@ -0,0 +1,73 @@ +# Document Destinations + +Document destinations are the final location for a document transfer. When +making a transfer request, the parameters you provide will depend on the source +type. + +Unlike sources, the selected destination will also influence the response. + +_Note: Some destination types may require additional configuration of the +service._ + +## OneDrive + +With the OneDrive destination, the service will upload the document to a +configured [Microsoft OneDrive][onedrive] account. + +### Configuration + +_**The OneDrive destination requires additional configuration of the service in +order to function properly.**_ + +Before you can use the OneDrive destination, you must create a new application +in the Azure Portal, with read and write permissions to the appropriate OneDrive +account. Created a client id and secret for the application to use for +authenticating. + +The following environment variables must be set on the service: + +| Name | Description | +|------------------------|---------------------------------------------------------------------| +| ONEDRIVE_CLIENT_ID | The client ID of the OneDrive application. | +| ONEDRIVE_CLIENT_SECRET | The client secret of the OneDrive application. | +| ONEDRIVE_DRIVE_ID | The drive ID of the OneDrive account documents will be uploaded to. | +| ONEDRIVE_TENANT_ID | The tenant ID of the OneDrive application. | + +### Request Parameters + +| Name | Description | Type | Default | Required | +|----------|----------------------------------------------|----------|-------------------|----------| +| filename | The path in the drive to upload the file to. | `string` | `source.filename` | NO | +| path | The path in the drive to upload the file to. | `string` | `""` | NO | + +### Example request + +```json +{ + "source": { + ... + }, + "destination": { + "type": "onedrive", + "path": "/document/path" + } +} +``` + +### Response parameters + +| Name | Description | Type | +|------|---------------------------------------------------------------|----------| +| path | The path, including filename, of the file on the destination. | `string` | + +### Example response + +```json +{ + "status": "success", + "destination": "onedrive", + "path": "/document/path/document.pdf" +} +``` + +[onedrive]: https://www.microsoft.com/en-us/microsoft-365/onedrive/onedrive-for-business diff --git a/doc/sources.md b/doc/sources.md new file mode 100644 index 0000000..8eb4f6b --- /dev/null +++ b/doc/sources.md @@ -0,0 +1,33 @@ +# Document Sources + +Document sources are the starting point for a document transfer. The source +document must be accessible by the Document Transfer Service. When making a +transfer request, the parameters you provide will depend on the source type. + +_Note: Some source types may require additional configuration of the service._ + +## URL + +Using a URL as a source, the service will retrieve the document from the +specified url. If your source document is in an Amazon S3 bucket, or something +similar, it's recommended that you use a short-lived pre-signed URL. + +### Parameters + +| Name | Description | Type | Default | Required | +|------|---------------------------------|----------|---------|----------| +| url | The URL of the source document. | `string` | | YES | + +### Example request + +```json +{ + "source": { + "type": "url", + "url": "https://example.com/document.pdf" + }, + "destination": { + ... + } +} +``` diff --git a/docker-compose.yaml b/docker-compose.yaml index 7e52703..ba96e05 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,11 @@ services: api: build: . + environment: + ONEDRIVE_CLIENT_ID: ${ONEDRIVE_CLIENT_ID} + ONEDRIVE_CLIENT_SECRET: ${ONEDRIVE_CLIENT_SECRET} + ONEDRIVE_DRIVE_ID: ${ONEDRIVE_DRIVE_ID} + ONEDRIVE_TENANT_ID: ${ONEDRIVE_TENANT_ID} volumes: - .:/opt/app ports: diff --git a/document-transfer-service.gemspec b/document-transfer-service.gemspec index 8d8b427..78d0429 100644 --- a/document-transfer-service.gemspec +++ b/document-transfer-service.gemspec @@ -21,7 +21,13 @@ Gem::Specification.new do |s| s.required_ruby_version = '>= 3.3' # Add runtime dependencies. + s.add_runtime_dependency 'adal', '~> 1.0' + s.add_runtime_dependency 'faraday', '~> 2.9' s.add_runtime_dependency 'grape', '~> 2.0' + s.add_runtime_dependency 'grape-entity', '~> 1.0' + s.add_runtime_dependency 'grape-swagger', '~> 2.1' + s.add_runtime_dependency 'grape-swagger-entity', '~> 0.5' + s.add_runtime_dependency 'httparty', '~> 0.22' s.add_runtime_dependency 'rack', '~> 3.0' s.add_runtime_dependency 'rackup', '~> 2.1' s.add_runtime_dependency 'statsd-instrument', '~> 3.7' diff --git a/lib/api/api.rb b/lib/api/api.rb index d95628f..caf4d22 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -1,14 +1,29 @@ # frozen_string_literal: true require 'grape' +require 'grape-swagger' +require 'grape-swagger-entity' require_relative 'health' +require_relative 'transfer' -module DocumentService - # Base API class for the document transfer service. - class API < Grape::API - format :json +module DocumentTransfer + module API + # Base API class for the document transfer service. + class API < Grape::API + format :json - mount DocumentService::Health + mount DocumentTransfer::API::Health + mount DocumentTransfer::API::Transfer + + add_swagger_documentation \ + hide_documentation_path: false, + mount_path: '/api', + info: { + license: 'MIT', + license_url: 'https://github.com/codeforamerica/document-transfer-service/blob/main/LICENSE', + title: 'Document Transfer Service API' + } + end end end diff --git a/lib/api/health.rb b/lib/api/health.rb index 0c5685a..1d5cf8b 100644 --- a/lib/api/health.rb +++ b/lib/api/health.rb @@ -3,12 +3,18 @@ require 'grape' require 'statsd-instrument' -module DocumentService - # Health check endpoint for the API. - class Health < Grape::API - get :health do - StatsD.increment('health_check') - { status: 'ok' } +require_relative '../response/health_status' + +module DocumentTransfer + module API + # Health check endpoint for the API. + class Health < Grape::API + desc 'Check system health', success: DocumentTransfer::Response::HealthStatus + get :health do + StatsD.increment('health_check') + + present DocumentTransfer::Response::HealthStatus.new(status: 'ok') + end end end end diff --git a/lib/api/transfer.rb b/lib/api/transfer.rb new file mode 100644 index 0000000..fa5c308 --- /dev/null +++ b/lib/api/transfer.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative '../config/destination' +require_relative '../config/source' +require_relative '../destination' +require_relative '../response/transfer_success' +require_relative '../source' + +module DocumentTransfer + module API + # Document transfer endpoint and resources for the API. + class Transfer < Grape::API + resource :transfer do + desc 'Initiate a new transfer.', success: DocumentTransfer::Response::TransferSuccess + params do + requires :source, type: Hash, desc: 'The source document.' do + requires :type, type: Symbol, values: [:url], desc: 'The type of the source document.' + requires :url, type: String, desc: 'The URL of the document to be ' \ + 'transferred. Required when the ' \ + 'source type is "url".', + documentation: { format: :uri } + end + + requires :destination, type: Hash, desc: 'The destination for the document.' do + requires :type, type: Symbol, values: [:onedrive], desc: 'The document destination type.' + optional :path, type: String, default: '', + desc: 'The path to store the document in the destination.' + optional :filename, type: String, desc: 'The filename to store the document as in the ' \ + 'destination, if different from the source.' + end + end + post do + source_config = DocumentTransfer::Config::Source.new(params[:source]) + dest_config = DocumentTransfer::Config::Destination.new(params[:destination]) + source = DocumentTransfer::Source.load(source_config) + destination = DocumentTransfer::Destination.load(dest_config) + + result = destination.transfer(source) + + present DocumentTransfer::Response::TransferSuccess.new({ + status: 'ok', + destination: dest_config.type + }.merge(result)) + end + end + end + end +end diff --git a/lib/config/base.rb b/lib/config/base.rb new file mode 100644 index 0000000..11804a2 --- /dev/null +++ b/lib/config/base.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative 'dsl' +require_relative 'validation' + +module DocumentTransfer + module Config + class InvalidConfigurationError < ArgumentError; end + + # Base class for configuration. + class Base + include DSL + include Validation + + def initialize(params = {}) + @params = params + + validate + end + end + end +end diff --git a/lib/config/destination.rb b/lib/config/destination.rb new file mode 100644 index 0000000..35ab711 --- /dev/null +++ b/lib/config/destination.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DocumentTransfer + module Config + # Configuration for a document destination. + class Destination < Base + option :type, type: Symbol, enum: [:onedrive], required: true + option :path, type: String, default: '' + option :filename, type: String + end + end +end diff --git a/lib/config/dsl.rb b/lib/config/dsl.rb new file mode 100644 index 0000000..7399ccf --- /dev/null +++ b/lib/config/dsl.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Config + # DSL for configuration + module DSL + def self.included(base) + base.extend ClassMethods + end + + def options + self.class.options + end + + def method_missing(name, *args, &) + super unless options.key?(name) + + @params[name] = format_value(name, args.first) if args.any? + @params[name] || options[name]&.[](:default) + end + + def respond_to_missing?(name, include_private = false) + options.key?(name) || super + end + + def format_value(option, value) + case options[option][:type] + when Symbol then value.to_sym + when String then value.to_s + else value + end + end + + # Required class methods for the config DSL. + # + # @todo Can we do this without using class variables? + module ClassMethods + # rubocop:disable Style/ClassVars + def option(name, opts = {}) + class_variable_set(:@@options, options.merge({ name => opts })) + end + + def options + class_variable_defined?(:@@options) ? class_variable_get(:@@options) : {} + end + # rubocop:enable Style/ClassVars + end + end + end +end diff --git a/lib/config/source.rb b/lib/config/source.rb new file mode 100644 index 0000000..5cde4ef --- /dev/null +++ b/lib/config/source.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DocumentTransfer + module Config + # Configuration for a document source. + class Source < Base + option :type, type: Symbol, enum: [:url], required: true + # TODO: Make url required only when type requires it. + option :url, type: String, required: true + end + end +end diff --git a/lib/config/validation.rb b/lib/config/validation.rb new file mode 100644 index 0000000..95120c7 --- /dev/null +++ b/lib/config/validation.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Config + # Validator for configuration. + module Validation + def validate + errors = validate_required + validate_values + raise InvalidConfigurationError, errors.join("\n") unless errors.empty? + end + + def validate_required + errors = options.select do |name, opts| + opts[:required] && @params[name].nil? + end + + errors.empty? ? [] : ["Missing required options: #{errors.keys.join(', ')}"] + end + + def validate_values + options.each_with_object([]) do |(name, opts), errors| + next unless opts[:enum] + + errors << "Invalid value for #{name}: #{@params[name]}" unless opts[:enum].include?(send(name)) + end + end + end + end +end diff --git a/lib/destination.rb b/lib/destination.rb new file mode 100644 index 0000000..85bc37d --- /dev/null +++ b/lib/destination.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative 'destination/one_drive' + +module DocumentTransfer + # Destination base module. + module Destination + class InvalidDestinationError < ArgumentError; end + + # Load the appropriate destination based on the configuration. + # + # @param config [DocumentTransfer::Config::Destination] The configuration for the destination. + # @return [DocumentTransfer::Destination::Base] The destination object. + # + # @todo Make this method more dynamic rather than using a simple switch. + def self.load(config) + case config.type + when :onedrive + OneDrive.new(config) + else + raise InvalidDestinationError, "Unknown destination type: #{config.type}" + end + end + end +end diff --git a/lib/destination/base.rb b/lib/destination/base.rb new file mode 100644 index 0000000..0ff8552 --- /dev/null +++ b/lib/destination/base.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Destination + # Base class for destinations. + # + # @abstract Subclass and override {#transfer} to implement a destination. + class Base + # Initializes the destination. + # + # @param config [DocumentTransfer::Config::Source] The configuration for the destination. + def initialize(config) + @config = config + end + + # Transfers a document to the destination. + # + # @param source [DocumentTransfer::Source::Base] The source document. + # @return [Hash] The result of the transfer. + # + # @raise [NotImplementedError] If the method is not implemented by the subclass. + def transfer(source) + raise NotImplementedError + end + end + end +end diff --git a/lib/destination/one_drive.rb b/lib/destination/one_drive.rb new file mode 100644 index 0000000..4388072 --- /dev/null +++ b/lib/destination/one_drive.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative 'base' +require_relative '../service/one_drive' + +module DocumentTransfer + module Destination + # Microsoft OneDrive destination. + class OneDrive < Base + def transfer(source) + result = service.upload(source, path: @config.path, filename: @config.filename) + + { path: File.join(@config.path, result.name) } + end + + private + + def service + @service ||= Service::OneDrive.new + end + end + end +end diff --git a/lib/document_service.rb b/lib/document_transfer.rb similarity index 87% rename from lib/document_service.rb rename to lib/document_transfer.rb index e3be29b..496d5b0 100644 --- a/lib/document_service.rb +++ b/lib/document_transfer.rb @@ -2,5 +2,5 @@ # Base module for the document transfer service. All classes and modules in this # service should be nested under this module. -module DocumentService +module DocumentTransfer end diff --git a/lib/response/base.rb b/lib/response/base.rb new file mode 100644 index 0000000..5129961 --- /dev/null +++ b/lib/response/base.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'grape-entity' + +module DocumentTransfer + module Response + # Base class for response entities. + class Base < Grape::Entity + def self.entity_name + name.split('::').last + end + end + end +end diff --git a/lib/response/health_status.rb b/lib/response/health_status.rb new file mode 100644 index 0000000..6b287aa --- /dev/null +++ b/lib/response/health_status.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DocumentTransfer + module Response + # Represents the health status of the application. + class HealthStatus < Base + expose :status, documentation: { type: String, desc: 'The current application health status.' } + end + end +end diff --git a/lib/response/transfer_success.rb b/lib/response/transfer_success.rb new file mode 100644 index 0000000..e981c25 --- /dev/null +++ b/lib/response/transfer_success.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DocumentTransfer + module Response + # Represents a successful transfer document transfer. + class TransferSuccess < Base + expose :status, documentation: { type: String, desc: 'The status of the transfer.' } + expose :destination, documentation: { type: Symbol, desc: 'The destination type.' } + expose :path, documentation: { type: String, + desc: 'The path withing the destination where the file was sent.' } + end + end +end diff --git a/lib/service/one_drive.rb b/lib/service/one_drive.rb new file mode 100644 index 0000000..2b5db9c --- /dev/null +++ b/lib/service/one_drive.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'adal' +require 'microsoft-graph-client' + +module DocumentTransfer + module Service + # Service interface for Microsoft OneDrive. + class OneDrive + AUTH_AUTHORITY = 'login.microsoftonline.com' + AUTH_RESOURCE = 'https://graph.microsoft.com' + + def get_items(item_id) + items = client.get("/drives/#{drive_id}/items/#{item_id}/children") + items.value || [] + end + + def get_items_recursive(item_id = 'root') + get_items(item_id).map do |item| + { + id: item.id, + name: item.name, + type: item.file ? :file : :folder, + mime_type: item.file&.mime_type, + children: item.folder&.child_count.to_i.positive? ? get_items_recursive(item.id) : [], + parent: item.parent_reference&.path + } + end + end + + def upload(source, path: '', filename: nil) + path += '/' unless path.empty? || path.end_with?('/') + filename ||= source.filename + endpoint = "/drives/#{drive_id}/items/root:/#{path}#{filename}:/content" + + client.put(endpoint, body: source.fetch, headers: { 'Content-Type' => source.mime_type }) + end + + private + + # Creates a new client object once. + # + # @todo Get credentials as part of a configuration, rather than + # environment variables. + def client + return @client if @client + + auth_ctx = ADAL::AuthenticationContext.new(AUTH_AUTHORITY, + ENV.fetch('ONEDRIVE_TENANT_ID', nil)) + client_cred = ADAL::ClientCredential.new(ENV.fetch('ONEDRIVE_CLIENT_ID', nil), + ENV.fetch('ONEDRIVE_CLIENT_SECRET', nil)) + token = auth_ctx.acquire_token_for_client(AUTH_RESOURCE, client_cred) + + @client = Microsoft::Graph.new(token: token.access_token) + end + + def drive_id + ENV.fetch('ONEDRIVE_DRIVE_ID', nil) + end + end + end +end diff --git a/lib/source.rb b/lib/source.rb new file mode 100644 index 0000000..f458123 --- /dev/null +++ b/lib/source.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative 'source/url' + +module DocumentTransfer + # Source base module. + module Source + class InvalidSourceError < ArgumentError; end + + # Load the appropriate source based on the configuration. + # + # @param config [DocumentTransfer::Config::Source] The configuration for the source. + # @return [DocumentTransfer::Source::Base] The source object. + # + # @todo Make this method more dynamic rather than using a simple switch. + def self.load(config) + case config.type + when :url + Url.new(config) + else + raise InvalidSourceError, "Unknown source type: #{config.type}" + end + end + end +end diff --git a/lib/source/base.rb b/lib/source/base.rb new file mode 100644 index 0000000..e30cf0e --- /dev/null +++ b/lib/source/base.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Source + # Base class for sources. + # + # @abstract Subclass and override {#filename} and {#mime_type} to implement a source. + class Base + # Initializes the source. + # + # @param config [DocumentTransfer::Config::Source] Configuration for the source. + def initialize(config) + @config = config + end + + # Returns the name of the document. + # + # @return [String] + # + # @raise [NotImplementedError] If the method is not implemented by the subclass. + def filename + raise NotImplementedError + end + + # Returns the mime type of the document. + # + # @return [String] + # + # @raise [NotImplementedError] If the method is not implemented by the subclass. + def mime_type + raise NotImplementedError + end + end + end +end diff --git a/lib/source/url.rb b/lib/source/url.rb new file mode 100644 index 0000000..b19ec17 --- /dev/null +++ b/lib/source/url.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'faraday' + +require_relative 'base' + +module DocumentTransfer + module Source + # Source documents from a provided URL. + class Url < Base + def filename + File.basename(@config.url) + end + + def mime_type + head.headers['content-type'] + end + + def fetch + client.get(@config.url).body + end + + def size + head.headers['content-length'] + end + + private + + def client + @client ||= Faraday.new(@config.url) + end + + def head + @head ||= client.head(@config.url) + end + end + end +end diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..64a3141 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,147 @@ +info: + title: Document Transfer Service API + license: + name: MIT + url: https://github.com/codeforamerica/document-transfer-service/blob/main/LICENSE + version: 0.0.1 +swagger: "2.0" +produces: + - application/json +host: example.org +tags: + - name: transfer + description: Operations about transfers + - name: health + description: Operations about healths + - name: api + description: Operations about apis +paths: + /transfer: + post: + description: Initiate a new transfer. + produces: + - application/json + consumes: + - application/json + parameters: + - name: postTransfer + in: body + required: true + schema: + $ref: '#/definitions/postTransfer' + responses: + "201": + description: Initiate a new transfer. + schema: + $ref: '#/definitions/TransferSuccess' + tags: + - transfer + operationId: postTransfer + /health: + get: + description: Check system health + produces: + - application/json + responses: + "200": + description: Check system health + schema: + $ref: '#/definitions/HealthStatus' + tags: + - health + operationId: getHealth + /api: + get: + description: Swagger compatible API description + produces: + - application/json + responses: + "200": + description: Swagger compatible API description + tags: + - api + operationId: getApi + /api/{name}: + get: + description: Swagger compatible API description for specific API + produces: + - application/json + parameters: + - in: path + name: name + description: Resource name of mounted API + type: string + required: true + - in: query + name: locale + description: Locale of API documentation + type: string + required: false + responses: + "200": + description: Swagger compatible API description for specific API + tags: + - api + operationId: getApiName +definitions: + postTransfer: + type: object + properties: + source: + type: object + description: The source document. + properties: + type: + type: string + description: The type of the source document. + enum: + - url + url: + type: string + format: uri + description: The URL of the document to be transferred. Required when the source type is "url". + required: + - type + - url + destination: + type: object + description: The destination for the document. + properties: + type: + type: string + description: The document destination type. + enum: + - onedrive + path: + type: string + description: The path to store the document in the destination. + default: "" + filename: + type: string + description: The filename to store the document as in the destination, if different from the source. + required: + - type + required: + - source + - destination + description: Initiate a new transfer. + TransferSuccess: + type: object + properties: + status: + type: string + description: The status of the transfer. + destination: + type: string + description: The destination type. + path: + type: string + description: The path withing the destination where the file was sent. + description: TransferSuccess model + HealthStatus: + type: object + properties: + status: + type: string + description: The current application health status. + description: HealthStatus model diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..41f8add --- /dev/null +++ b/sample.env @@ -0,0 +1,11 @@ +export RACK_ENV=development +export COVERAGE=1 + +# Uncomment and set to the name of your AWS profile to use the aws cli. +#export AWS_PROFILE= + +# Uncomment and set for OneDrive support. +#export ONEDRIVE_CLIENT_ID= +#export ONEDRIVE_CLIENT_SECRET= +#export ONEDRIVE_DRIVE_ID= +#export ONEDRIVE_TENANT_ID= diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..8af985a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'factory_bot' + +# Configure code coverage reporting. +if ENV.fetch('COVERAGE', false) + require 'simplecov' + + SimpleCov.minimum_coverage 95 + SimpleCov.start do + add_filter '/spec/' + + track_files 'lib/**/*.rb' + end +end + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end + +# Include factories. +require_relative 'support/factories' diff --git a/spec/support/factories.rb b/spec/support/factories.rb new file mode 100644 index 0000000..3c16553 --- /dev/null +++ b/spec/support/factories.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Allow rspec mocks to be used by factories. +FactoryBot::SyntaxRunner.class_eval do + include RSpec::Mocks::ExampleMethods +end + +require_relative 'factories/config/destination_factory' +require_relative 'factories/config/source_factory' +require_relative 'factories/destination/one_drive_factory' +require_relative 'factories/service/one_drive_factory' +require_relative 'factories/source/url_factory' diff --git a/spec/support/factories/config/destination_factory.rb b/spec/support/factories/config/destination_factory.rb new file mode 100644 index 0000000..dc8d892 --- /dev/null +++ b/spec/support/factories/config/destination_factory.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/config/destination' + +FactoryBot.define do + factory :config_destination, class: DocumentTransfer::Config::Destination do + transient do + type { :onedrive } + path { 'rspec/path' } + end + + initialize_with { new(attributes.merge(type:, path:)) } + end +end diff --git a/spec/support/factories/config/source_factory.rb b/spec/support/factories/config/source_factory.rb new file mode 100644 index 0000000..2569e16 --- /dev/null +++ b/spec/support/factories/config/source_factory.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/config/source' + +FactoryBot.define do + factory :config_source, class: DocumentTransfer::Config::Source do + transient do + type { :url } + url { 'https://example.com/file.pdf' } + end + + initialize_with { new(attributes.merge(type:, url:)) } + end +end diff --git a/spec/support/factories/destination/one_drive_factory.rb b/spec/support/factories/destination/one_drive_factory.rb new file mode 100644 index 0000000..ac281ee --- /dev/null +++ b/spec/support/factories/destination/one_drive_factory.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/destination/one_drive' + +FactoryBot.define do + factory :destination_one_drive, class: DocumentTransfer::Destination::OneDrive do + transient do + config { build(:config_destination, type: :onedrive, path: 'rspec-docs') } + end + + initialize_with { new(config) } + end +end diff --git a/spec/support/factories/service/one_drive_factory.rb b/spec/support/factories/service/one_drive_factory.rb new file mode 100644 index 0000000..ec5671d --- /dev/null +++ b/spec/support/factories/service/one_drive_factory.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/service/one_drive' + +FactoryBot.define do + factory :service_one_drive, class: DocumentTransfer::Service::OneDrive + + after(:build) do |factory| + allow(factory).to receive(:upload) + end +end diff --git a/spec/support/factories/source/url_factory.rb b/spec/support/factories/source/url_factory.rb new file mode 100644 index 0000000..c5289b0 --- /dev/null +++ b/spec/support/factories/source/url_factory.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/source/url' + +FactoryBot.define do + factory :source_url, class: DocumentTransfer::Source::Url do + transient do + config { build(:config_source, type: :url, url: 'https://example.com/file.pdf') } + end + + initialize_with { new(config) } + end +end diff --git a/spec/unit/api/api_spec.rb b/spec/unit/document_transfer/api/health_spec.rb similarity index 71% rename from spec/unit/api/api_spec.rb rename to spec/unit/document_transfer/api/health_spec.rb index 1d6a29b..5f83342 100644 --- a/spec/unit/api/api_spec.rb +++ b/spec/unit/document_transfer/api/health_spec.rb @@ -2,20 +2,21 @@ require 'rack/test' -require_relative '../../../lib/api/api' +require_relative '../../../../lib/api/api' +require_relative '../../../../lib/api/health' -describe DocumentService::API do +describe DocumentTransfer::API::Health do include Rack::Test::Methods include StatsD::Instrument::Matchers def app - DocumentService::API + DocumentTransfer::API::API end describe 'GET /health' do it 'returns 200' do get '/health' - expect(last_response.status).to eq(200) + expect(last_response).to be_ok end it 'includes a status message' do diff --git a/spec/unit/document_transfer/api/transfer_spec.rb b/spec/unit/document_transfer/api/transfer_spec.rb new file mode 100644 index 0000000..3b11683 --- /dev/null +++ b/spec/unit/document_transfer/api/transfer_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rack/test' + +require_relative '../../../../lib/api/api' +require_relative '../../../../lib/api/transfer' + +describe DocumentTransfer::API::Transfer do + include Rack::Test::Methods + include StatsD::Instrument::Matchers + + def app + DocumentTransfer::API::API + end + + describe 'POST /transfer' do + let(:source) { build(:source_url) } + let(:destination) { build(:destination_one_drive) } + let(:params) do + { + source: { + type: 'url', + url: 'https://example.com/rspec.pdf' + }, + destination: { + type: 'onedrive', + path: 'rspec-folder' + } + } + end + + before do + allow(DocumentTransfer::Source::Url).to receive(:new).and_return(source) + allow(DocumentTransfer::Destination::OneDrive).to receive(:new).and_return(destination) + allow(destination).to receive(:transfer).and_return({ path: 'rspec-folder/rspec.pdf' }) + end + + it 'succeeds' do + post '/transfer', params + + expect(last_response).to be_created + end + + it 'transfers the document' do + post '/transfer', params + + expect(destination).to have_received(:transfer).with(source) + end + + it 'returns a success message' do + post '/transfer', params + + expect(last_response.body).to eq({ + status: 'ok', + destination: 'onedrive', + path: 'rspec-folder/rspec.pdf' + }.to_json) + end + end +end diff --git a/spec/unit/document_transfer/destination/one_drive_spec.rb b/spec/unit/document_transfer/destination/one_drive_spec.rb new file mode 100644 index 0000000..765cc8a --- /dev/null +++ b/spec/unit/document_transfer/destination/one_drive_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/destination/one_drive' + +RSpec.describe DocumentTransfer::Destination::OneDrive do + subject(:dest) { described_class.new(config) } + + let(:config) { build(:config_destination) } + let(:service) { build(:service_one_drive) } + let(:source) { build(:source_url) } + + before do + allow(DocumentTransfer::Service::OneDrive).to receive(:new).and_return(service) + allow(service).to receive(:upload) + .and_return(Microsoft::Graph::JSONStruct.new(name: 'rspec.pdf')) + end + + describe '#transfer' do + it 'uploads the document' do + dest.transfer(source) + + expect(service).to have_received(:upload).with( + source, path: config.path, filename: config.filename + ) + end + end +end diff --git a/spec/unit/document_transfer/destination_spec.rb b/spec/unit/document_transfer/destination_spec.rb new file mode 100644 index 0000000..f743e6a --- /dev/null +++ b/spec/unit/document_transfer/destination_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative '../../../lib/destination' + +RSpec.describe DocumentTransfer::Destination do + describe '.load' do + let(:config) { build(:config_destination, type: destination_type) } + let(:destination) { build(:destination_one_drive) } + let(:destination_type) { :onedrive } + + before do + allow(destination.class).to receive(:new).and_return(destination) + end + + it 'returns the proper destination' do + expect(described_class.load(config)).to eq(destination) + end + + context 'when an invalid destination type is provided' do + before do + # If we try to set an invalid type directly on the config object, it + # will raise an error. + allow(config).to receive(:type).and_return(:invalid) + end + + it 'raises an exception' do + expect { described_class.load(config) }.to \ + raise_error(DocumentTransfer::Destination::InvalidDestinationError) + end + end + end +end diff --git a/spec/unit/document_transfer/service/one_drive_spec.rb b/spec/unit/document_transfer/service/one_drive_spec.rb new file mode 100644 index 0000000..b327e53 --- /dev/null +++ b/spec/unit/document_transfer/service/one_drive_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/service/one_drive' + +RSpec.describe DocumentTransfer::Service::OneDrive do + subject(:service) { described_class.new } + + let(:client) { instance_double(Microsoft::Graph) } + + before do + stub_const('ENV', ENV.to_h.merge( + 'ONEDRIVE_CLIENT_ID' => 'client_id', + 'ONEDRIVE_CLIENT_SECRET' => 'client_secret', + 'ONEDRIVE_DRIVE_ID' => 'drive_id', + 'ONEDRIVE_TENANT_ID' => 'tenant_id' + )) + + # Stub the client and its authentication mechanisms. + auth_context = instance_double(ADAL::AuthenticationContext) + client_cred = instance_double(ADAL::ClientCredential) + token = instance_double(ADAL::SuccessResponse, access_token: 'access_token') + allow(ADAL::AuthenticationContext).to receive(:new).and_return(auth_context) + allow(ADAL::ClientCredential).to receive(:new).and_return(client_cred) + allow(auth_context).to receive(:acquire_token_for_client).and_return(token) + allow(Microsoft::Graph).to receive(:new).and_return(client) + end + + describe '#get_items' do + let(:response) { Microsoft::Graph::JSONStruct.new(value: items) } + let(:items) do + [ + Microsoft::Graph::JSONStruct.new( + id: 'rspec1', name: 'rspec.pdf', + file: Microsoft::Graph::JSONStruct.new(mime_type: 'application/pdf') + ), + Microsoft::Graph::JSONStruct.new( + id: 'rspec2', name: 'rspec-folder', + folder: Microsoft::Graph::JSONStruct.new(child_count: 1) + ) + ] + end + + before do + allow(client).to receive(:get).and_return(response) + end + + it 'gets the items for the specified item ID' do + service.get_items('rspec_items') + + expect(client).to have_received(:get).with('/drives/drive_id/items/rspec_items/children') + end + + it 'returns the items' do + expect(service.get_items('rspec_items')).to eq(items) + end + end + + describe '#get_items_recursive' do + let(:children_response) { Microsoft::Graph::JSONStruct.new(value: children) } + let(:parent_response) { Microsoft::Graph::JSONStruct.new(value: parents) } + let(:children) do + [ + Microsoft::Graph::JSONStruct.new( + id: 'rspec1', name: 'rspec.pdf', + file: Microsoft::Graph::JSONStruct.new(mime_type: 'application/pdf'), + parent_reference: Microsoft::Graph::JSONStruct.new(path: '/drives/drive_id/root:/rspec-parent-folder') + ), + Microsoft::Graph::JSONStruct.new( + id: 'rspec2', name: 'rspec-folder', + folder: Microsoft::Graph::JSONStruct.new(child_count: 1), + parent_reference: Microsoft::Graph::JSONStruct.new(path: '/drives/drive_id/root:/rspec-parent-folder') + ) + ] + end + let(:parents) do + [ + Microsoft::Graph::JSONStruct.new( + id: 'rspec-parent', name: 'rspec-parent-folder', + folder: Microsoft::Graph::JSONStruct.new(child_count: 2), + parent_reference: Microsoft::Graph::JSONStruct.new(path: '/drives/drive_id/root:') + ), + Microsoft::Graph::JSONStruct.new( + id: 'rspec-empty', name: 'rspec-empty-folder', + folder: Microsoft::Graph::JSONStruct.new(child_count: 0), + parent_reference: Microsoft::Graph::JSONStruct.new(path: '/drives/drive_id/root:') + ) + ] + end + + before do + allow(client).to receive(:get).and_return(Microsoft::Graph::JSONStruct.new(value: [])) + allow(client).to receive(:get).with('/drives/drive_id/items/rspec-parent/children') + .and_return(children_response).once + allow(client).to receive(:get).with('/drives/drive_id/items/root/children') + .and_return(parent_response).once + end + + it 'gets the items for each non-empty folder' do + service.get_items_recursive + + %w[root rspec-parent rspec2].each do |item_id| + expect(client).to have_received(:get).with("/drives/drive_id/items/#{item_id}/children") + end + end + + it 'does not get children for files' do + service.get_items_recursive + + %w[rspec1].each do |item_id| + expect(client).not_to have_received(:get).with("/drives/drive_id/items/#{item_id}/children") + end + end + + it 'does not get children for empty folders' do + service.get_items_recursive + + %w[rspec-empty].each do |item_id| + expect(client).not_to have_received(:get).with("/drives/drive_id/items/#{item_id}/children") + end + end + + it 'returns all items' do + items = service.get_items_recursive + + expect(items).to eq([ + { id: parents[0].id, name: parents[0].name, type: :folder, + mime_type: nil, children: [ + { id: children[0].id, name: children[0].name, type: :file, + mime_type: children[0].file.mime_type, children: [], + parent: children[0].parent_reference.path }, + { id: children[1].id, name: children[1].name, type: :folder, + mime_type: nil, children: [], + parent: children[1].parent_reference.path } + ], + parent: parents[0].parent_reference.path }, + { id: parents[1].id, name: parents[1].name, type: :folder, + mime_type: nil, children: [], parent: parents[1].parent_reference.path } + ]) + end + end + + describe '#upload' do + let(:source) { build(:source_url) } + let(:response) { Microsoft::Graph::JSONStruct.new } + + before do + allow(source).to receive_messages( + fetch: 'file_contents', + filename: 'rspec.pdf', + mime_type: 'application/pdf' + ) + allow(client).to receive(:put).and_return(response) + end + + it 'uploads the source to the specified path' do + service.upload(source, path: 'rspec-folder') + + expect(client).to have_received(:put).with( + '/drives/drive_id/items/root:/rspec-folder/rspec.pdf:/content', + body: 'file_contents', + headers: { 'Content-Type' => 'application/pdf' } + ) + end + end +end diff --git a/spec/unit/document_transfer/source/url_spec.rb b/spec/unit/document_transfer/source/url_spec.rb new file mode 100644 index 0000000..05f18c1 --- /dev/null +++ b/spec/unit/document_transfer/source/url_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative '../../../../lib/source/url' +require_relative '../../../../lib/config/source' + +describe DocumentTransfer::Source::Url do + subject(:source) { described_class.new(config) } + + let(:config) { build(:config_source) } + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:conn) { Faraday.new { |c| c.adapter(:test, stubs) } } + + before do + allow(Faraday).to receive(:new).and_return(conn) + stubs.head(config.url) do + [200, { 'Content-Type' => 'application/pdf', 'Content-Length' => '1024' }, ''] + end + end + + # Clear default connection to prevent it from being cached between different + # tests. This allows for each test to have its own set of stubs. + after do + Faraday.default_connection = nil + end + + describe '#filename' do + it 'returns the filename from the url' do + expect(source.filename).to eq('file.pdf') + end + end + + describe '#mime_type' do + it 'returns the mime type of the file' do + expect(source.mime_type).to eq('application/pdf') + end + end + + describe '#fetch' do + before do + stubs.get(config.url) do + [200, {}, 'This would be binary data'] + end + end + + it 'returns the file contents' do + expect(source.fetch).to eq('This would be binary data') + end + end + + describe '#size' do + it 'returns the size of the file' do + expect(source.size).to eq('1024') + end + end +end diff --git a/spec/unit/document_transfer/source_spec.rb b/spec/unit/document_transfer/source_spec.rb new file mode 100644 index 0000000..d5fe021 --- /dev/null +++ b/spec/unit/document_transfer/source_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative '../../../lib/source' + +RSpec.describe DocumentTransfer::Source do + describe '.load' do + let(:config) { build(:config_source, type: source_type) } + let(:source) { build(:source_url) } + let(:source_type) { :url } + + before do + allow(source.class).to receive(:new).and_return(source) + end + + it 'returns the proper source' do + expect(described_class.load(config)).to eq(source) + end + + context 'when an invalid source type is provided' do + before do + # If we try to set an invalid type directly on the config object, it + # will raise an error. + allow(config).to receive(:type).and_return(:invalid) + end + + it 'raises an exception' do + expect { described_class.load(config) }.to \ + raise_error(DocumentTransfer::Source::InvalidSourceError) + end + end + end +end