From 7dcf13000b5bbe40397393d1b3d660689e5c2d1f Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 21 May 2024 14:18:05 -0400 Subject: [PATCH 01/15] Added OpenAPI documentation. --- Gemfile.lock | 4 +++ Rakefile | 7 ++++ document-transfer-service.gemspec | 1 + lib/api/api.rb | 10 ++++++ openapi.yaml | 59 +++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 openapi.yaml diff --git a/Gemfile.lock b/Gemfile.lock index 2c18e36..0c4872c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: document-transfer-service (0.1.0) grape (~> 2.0) + grape-swagger (~> 2.1) rack (~> 3.0) rackup (~> 2.1) statsd-instrument (~> 3.7) @@ -53,6 +54,9 @@ GEM mustermann-grape (~> 1.0.0) rack (>= 1.3.0) rack-accept + grape-swagger (2.1.0) + grape (>= 1.7, < 3.0) + rack-test (~> 2) i18n (1.14.5) concurrent-ruby (~> 1.0) json (2.7.2) diff --git a/Rakefile b/Rakefile index 444527c..a10c8f0 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 + require_relative 'lib/api/api' +end + +GrapeSwagger::Rake::OapiTasks.new('::DocumentService::API') + RuboCop::RakeTask.new(:rubocop) do |task| task.requires << 'rubocop' end diff --git a/document-transfer-service.gemspec b/document-transfer-service.gemspec index 8d8b427..838497a 100644 --- a/document-transfer-service.gemspec +++ b/document-transfer-service.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |s| # Add runtime dependencies. s.add_runtime_dependency 'grape', '~> 2.0' + s.add_runtime_dependency 'grape-swagger', '~> 2.1' 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..45d68bf 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'grape' +require 'grape-swagger' require_relative 'health' @@ -10,5 +11,14 @@ class API < Grape::API format :json mount DocumentService::Health + + 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 diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..02a6379 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,59 @@ +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: health + description: Operations about healths + - name: api + description: Operations about apis +paths: + /health: + get: + produces: + - application/json + responses: + "200": + description: get Health(s) + 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 From a5fee3b0de224f547a82d31cc65a0893ed748a2a Mon Sep 17 00:00:00 2001 From: James Armes Date: Tue, 21 May 2024 14:28:47 -0400 Subject: [PATCH 02/15] Updated class and module structure to be more accurate. --- Rakefile | 2 +- config.ru | 2 +- lib/api/api.rb | 28 ++++++++++--------- lib/api/health.rb | 14 ++++++---- ...cument_service.rb => document_transfer.rb} | 2 +- 5 files changed, 26 insertions(+), 22 deletions(-) rename lib/{document_service.rb => document_transfer.rb} (87%) diff --git a/Rakefile b/Rakefile index a10c8f0..64c41fb 100644 --- a/Rakefile +++ b/Rakefile @@ -10,7 +10,7 @@ task :environment do require_relative 'lib/api/api' end -GrapeSwagger::Rake::OapiTasks.new('::DocumentService::API') +GrapeSwagger::Rake::OapiTasks.new('::DocumentTransfer::API::API') RuboCop::RakeTask.new(:rubocop) do |task| task.requires << 'rubocop' diff --git a/config.ru b/config.ru index 539aa7e..f02e772 100644 --- a/config.ru +++ b/config.ru @@ -2,4 +2,4 @@ require_relative 'lib/api/api' -run DocumentService::API +run DocumentTransfer::API::API diff --git a/lib/api/api.rb b/lib/api/api.rb index 45d68bf..9b1760c 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -5,20 +5,22 @@ require_relative 'health' -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 - 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' - } + 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..72cb3bb 100644 --- a/lib/api/health.rb +++ b/lib/api/health.rb @@ -3,12 +3,14 @@ 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' } +module DocumentTransfer + module API + # Health check endpoint for the API. + class Health < Grape::API + get :health do + StatsD.increment('health_check') + { status: 'ok' } + 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 From 39d6d40dcf76e3db7e49148c4aad00e71d47ec70 Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 5 Jun 2024 12:34:01 -0400 Subject: [PATCH 03/15] Added document transfer endpoint with support for url sources and onedrive destinations. --- Gemfile | 4 ++ Gemfile.lock | 65 ++++++++++++++++++++----- config.ru | 2 + document-transfer-service.gemspec | 3 ++ lib/api/api.rb | 2 + lib/api/transfer.rb | 44 +++++++++++++++++ lib/config/base.rb | 22 +++++++++ lib/config/destination.rb | 14 ++++++ lib/config/dsl.rb | 42 ++++++++++++++++ lib/config/source.rb | 14 ++++++ lib/config/validation.rb | 29 +++++++++++ lib/destination.rb | 25 ++++++++++ lib/destination/base.rb | 22 +++++++++ lib/destination/one_drive.rb | 21 ++++++++ lib/service/one_drive.rb | 80 +++++++++++++++++++++++++++++++ lib/source/base.rb | 29 +++++++++++ lib/source/url.rb | 34 +++++++++++++ openapi.yaml | 63 ++++++++++++++++++++++++ 18 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 lib/api/transfer.rb create mode 100644 lib/config/base.rb create mode 100644 lib/config/destination.rb create mode 100644 lib/config/dsl.rb create mode 100644 lib/config/source.rb create mode 100644 lib/config/validation.rb create mode 100644 lib/destination.rb create mode 100644 lib/destination/base.rb create mode 100644 lib/destination/one_drive.rb create mode 100644 lib/service/one_drive.rb create mode 100644 lib/source/base.rb create mode 100644 lib/source/url.rb 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 0c4872c..59a45b3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,20 @@ +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-swagger (~> 2.1) + httparty (~> 0.22) rack (~> 3.0) rackup (~> 2.1) statsd-instrument (~> 3.7) @@ -11,7 +22,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) @@ -21,12 +32,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) + 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) @@ -47,6 +63,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 @@ -57,22 +77,38 @@ GEM grape-swagger (2.1.0) grape (>= 1.7, < 3.0) rack-test (~> 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_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) @@ -82,8 +118,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) @@ -95,11 +132,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) @@ -118,7 +155,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) @@ -134,11 +171,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 @@ -147,6 +187,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/config.ru b/config.ru index f02e772..857c3a6 100644 --- a/config.ru +++ b/config.ru @@ -2,4 +2,6 @@ require_relative 'lib/api/api' +use Rack::RewindableInput::Middleware + run DocumentTransfer::API::API diff --git a/document-transfer-service.gemspec b/document-transfer-service.gemspec index 838497a..be67e04 100644 --- a/document-transfer-service.gemspec +++ b/document-transfer-service.gemspec @@ -21,8 +21,11 @@ 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-swagger', '~> 2.1' + 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 9b1760c..6972c83 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -4,6 +4,7 @@ require 'grape-swagger' require_relative 'health' +require_relative 'transfer' module DocumentTransfer module API @@ -12,6 +13,7 @@ class API < Grape::API format :json mount DocumentTransfer::API::Health + mount DocumentTransfer::API::Transfer add_swagger_documentation \ hide_documentation_path: false, diff --git a/lib/api/transfer.rb b/lib/api/transfer.rb new file mode 100644 index 0000000..8cde2c4 --- /dev/null +++ b/lib/api/transfer.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative '../config/destination' +require_relative '../config/source' +require_relative '../destination' +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.' + 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) + + destination.transfer(source) + + { status: 'ok', source: source_config.type, destination: dest_config.type } + 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..008e797 --- /dev/null +++ b/lib/config/dsl.rb @@ -0,0 +1,42 @@ +# 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, &block) + super unless options.key?(name) + + @params[name] = format_value(name, args.first) if args.any? + @params[name] || options[name]&.[](:default) + 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. + module ClassMethods + def option(name, opts = {}) + class_variable_set(:@@options, options.merge({ name => opts })) + end + + def options + class_variable_defined?(:@@options) ? class_variable_get(:@@options) : {} + end + 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..d4b2007 --- /dev/null +++ b/lib/destination/base.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Destination + # Base class for destinations. + 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. + 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..2b858b4 --- /dev/null +++ b/lib/destination/one_drive.rb @@ -0,0 +1,21 @@ +# 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) + service.upload(source, path: @config.path, filename: @config.filename) + end + + private + + def service + @service ||= Service::OneDrive.new + end + end + end +end diff --git a/lib/service/one_drive.rb b/lib/service/one_drive.rb new file mode 100644 index 0000000..f469ffe --- /dev/null +++ b/lib/service/one_drive.rb @@ -0,0 +1,80 @@ +# 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) + puts "Getting items for #{item_id}" + + items = client.get("/drives/#{drive_id}/items/#{item_id}/children") + items.value || [] + end + + def get_items_recursive(item_id = 'root') + results = [] + items = get_items(item_id) + items.each do |item| + puts "Found Item: #{item.name} (#{item.id})" + results << { + id: item.id, + name: item.name, + type: item.file ? :file : :folder, + mime_type: item.file ? item.file.mime_type : nil, + children: get_items_recursive(item.id), + # children: get_items_recursive("#{item_id}:/#{item.name}"), + parent: item.parent_reference ? item.parent_reference.path : nil + } + end + + results + 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 + + def client + return @client if @client + + auth_ctx = ADAL::AuthenticationContext.new(AUTH_AUTHORITY, + ENV['ONEDRIVE_TENANT_ID']) + client_cred = ADAL::ClientCredential.new(ENV['ONEDRIVE_CLIENT_ID'], + ENV['ONEDRIVE_CLIENT_SECRET']) + token = auth_ctx.acquire_token_for_client(AUTH_RESOURCE, client_cred) + + @client = Microsoft::Graph.new(token: token.access_token) + end + + def drive_id + ENV['ONEDRIVE_DRIVE_ID'] + end + + def client_depr + return @client if @client + + context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( + ENV['ONEDRIVE_TENANT_ID'], ENV['ONEDRIVE_CLIENT_ID'], ENV['ONEDRIVE_CLIENT_SECRET']) + + auth = MicrosoftGraphCore::Authentication::OAuthAuthenticationProvider.new( + context, nil, ["https://graph.microsoft.com/.default"] + ) + + adapter = MicrosoftGraph::GraphRequestAdapter.new(auth) + @client = MicrosoftGraph::GraphServiceClient.new(adapter) + end + end + end +end diff --git a/lib/source/base.rb b/lib/source/base.rb new file mode 100644 index 0000000..85fa1c0 --- /dev/null +++ b/lib/source/base.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DocumentTransfer + module Source + # Base class for sources. + 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] + def filename + raise NotImplementedError + end + + # Returns the mime type of the document. + # + # @return [String] + 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..2ffc04b --- /dev/null +++ b/lib/source/url.rb @@ -0,0 +1,34 @@ +# 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 + Faraday.get(@config.url).body + end + + def size + head.headers['content-length'] + end + + private + + def head + @head ||= Faraday.head(@config.url) + end + end + end +end diff --git a/openapi.yaml b/openapi.yaml index 02a6379..f144700 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9,11 +9,32 @@ 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. + tags: + - transfer + operationId: postTransfer /health: get: produces: @@ -57,3 +78,45 @@ paths: 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. From f6008885457d0766ab8677eb5ccf0fa82689986c Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 5 Jun 2024 13:46:00 -0400 Subject: [PATCH 04/15] Fixed linting errors. --- lib/config/dsl.rb | 10 +++++++++- lib/service/one_drive.rb | 40 ++++++++++++---------------------------- lib/source.rb | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 29 deletions(-) create mode 100644 lib/source.rb diff --git a/lib/config/dsl.rb b/lib/config/dsl.rb index 008e797..7399ccf 100644 --- a/lib/config/dsl.rb +++ b/lib/config/dsl.rb @@ -12,13 +12,17 @@ def options self.class.options end - def method_missing(name, *args, &block) + 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 @@ -28,7 +32,10 @@ def format_value(option, value) 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 @@ -36,6 +43,7 @@ def option(name, opts = {}) def options class_variable_defined?(:@@options) ? class_variable_get(:@@options) : {} end + # rubocop:enable Style/ClassVars end end end diff --git a/lib/service/one_drive.rb b/lib/service/one_drive.rb index f469ffe..75d3b3f 100644 --- a/lib/service/one_drive.rb +++ b/lib/service/one_drive.rb @@ -18,22 +18,16 @@ def get_items(item_id) end def get_items_recursive(item_id = 'root') - results = [] - items = get_items(item_id) - items.each do |item| - puts "Found Item: #{item.name} (#{item.id})" - results << { + get_items(item_id).map do |item| + { id: item.id, name: item.name, type: item.file ? :file : :folder, - mime_type: item.file ? item.file.mime_type : nil, + mime_type: item.file&.mime_type, children: get_items_recursive(item.id), - # children: get_items_recursive("#{item_id}:/#{item.name}"), - parent: item.parent_reference ? item.parent_reference.path : nil + parent: item.parent_reference&.path } end - - results end def upload(source, path: '', filename: nil) @@ -46,34 +40,24 @@ def upload(source, path: '', filename: nil) 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['ONEDRIVE_TENANT_ID']) - client_cred = ADAL::ClientCredential.new(ENV['ONEDRIVE_CLIENT_ID'], - ENV['ONEDRIVE_CLIENT_SECRET']) + 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['ONEDRIVE_DRIVE_ID'] - end - - def client_depr - return @client if @client - - context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new( - ENV['ONEDRIVE_TENANT_ID'], ENV['ONEDRIVE_CLIENT_ID'], ENV['ONEDRIVE_CLIENT_SECRET']) - - auth = MicrosoftGraphCore::Authentication::OAuthAuthenticationProvider.new( - context, nil, ["https://graph.microsoft.com/.default"] - ) - - adapter = MicrosoftGraph::GraphRequestAdapter.new(auth) - @client = MicrosoftGraph::GraphServiceClient.new(adapter) + ENV.fetch('ONEDRIVE_DRIVE_ID', nil) 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 From 4deaac5d5e47b6c33015f4c9db512a4045926103 Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 5 Jun 2024 17:18:07 -0400 Subject: [PATCH 05/15] Added test for URL source type. --- .gitignore | 1 + .rspec | 4 ++ .rubocop.yml | 4 ++ Rakefile | 2 +- lib/source/url.rb | 8 ++- spec/spec_helper.rb | 22 ++++++++ spec/support/factories.rb | 8 +++ .../factories/config/source_factory.rb | 12 ++++ .../api/health_spec.rb} | 9 +-- .../unit/document_transfer/source/url_spec.rb | 55 +++++++++++++++++++ 10 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 .rspec create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/factories.rb create mode 100644 spec/support/factories/config/source_factory.rb rename spec/unit/{api/api_spec.rb => document_transfer/api/health_spec.rb} (71%) create mode 100644 spec/unit/document_transfer/source/url_spec.rb diff --git a/.gitignore b/.gitignore index 80fbc7b..0ff19bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore local environment files .env *.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..045787f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,7 @@ +require: + - rubocop-rake + - rubocop-rspec + AllCops: NewCops: enable SuggestExtensions: true diff --git a/Rakefile b/Rakefile index 64c41fb..94aaec6 100644 --- a/Rakefile +++ b/Rakefile @@ -6,7 +6,7 @@ require 'rubocop/rake_task' task default: %i[spec rubocop] -task :environment do +task :environment do # rubocop:disable Rake/Desc require_relative 'lib/api/api' end diff --git a/lib/source/url.rb b/lib/source/url.rb index 2ffc04b..b19ec17 100644 --- a/lib/source/url.rb +++ b/lib/source/url.rb @@ -17,7 +17,7 @@ def mime_type end def fetch - Faraday.get(@config.url).body + client.get(@config.url).body end def size @@ -26,8 +26,12 @@ def size private + def client + @client ||= Faraday.new(@config.url) + end + def head - @head ||= Faraday.head(@config.url) + @head ||= client.head(@config.url) end end end 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..e415169 --- /dev/null +++ b/spec/support/factories.rb @@ -0,0 +1,8 @@ +# 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/source_factory' diff --git a/spec/support/factories/config/source_factory.rb b/spec/support/factories/config/source_factory.rb new file mode 100644 index 0000000..bcb1934 --- /dev/null +++ b/spec/support/factories/config/source_factory.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +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/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/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 From 4dae7773fbf4727a6cda16cfe7139fb90b18f2c0 Mon Sep 17 00:00:00 2001 From: James Armes Date: Wed, 5 Jun 2024 17:51:50 -0400 Subject: [PATCH 06/15] Added test for OneDrive destination type. --- .rubocop.yml | 5 ++++ spec/support/factories.rb | 3 +++ .../factories/config/destination_factory.rb | 14 +++++++++++ .../factories/config/source_factory.rb | 4 ++- .../factories/service/one_drive_factory.rb | 11 ++++++++ spec/support/factories/source/url_factory.rb | 13 ++++++++++ .../destination/one_drive_spec.rb | 25 +++++++++++++++++++ 7 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 spec/support/factories/config/destination_factory.rb create mode 100644 spec/support/factories/service/one_drive_factory.rb create mode 100644 spec/support/factories/source/url_factory.rb create mode 100644 spec/unit/document_transfer/destination/one_drive_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 045787f..27e7408 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,3 +5,8 @@ require: 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 diff --git a/spec/support/factories.rb b/spec/support/factories.rb index e415169..650daa0 100644 --- a/spec/support/factories.rb +++ b/spec/support/factories.rb @@ -5,4 +5,7 @@ include RSpec::Mocks::ExampleMethods end +require_relative 'factories/config/destination_factory' require_relative 'factories/config/source_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 index bcb1934..2569e16 100644 --- a/spec/support/factories/config/source_factory.rb +++ b/spec/support/factories/config/source_factory.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require_relative '../../../../lib/config/source' + FactoryBot.define do - factory :config_source, class: 'DocumentTransfer::Config::Source' do + factory :config_source, class: DocumentTransfer::Config::Source do transient do type { :url } url { 'https://example.com/file.pdf' } 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/document_transfer/destination/one_drive_spec.rb b/spec/unit/document_transfer/destination/one_drive_spec.rb new file mode 100644 index 0000000..a7c4603 --- /dev/null +++ b/spec/unit/document_transfer/destination/one_drive_spec.rb @@ -0,0 +1,25 @@ +# 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) + 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 From 060425cf9975d6b1aabd0afbfcc1c18d07d4c4cc Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 14:31:19 -0400 Subject: [PATCH 07/15] Added test for the OneDrive service. --- .rubocop.yml | 6 + lib/service/one_drive.rb | 4 +- .../service/one_drive_spec.rb | 165 ++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 spec/unit/document_transfer/service/one_drive_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 27e7408..756c0de 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,3 +10,9 @@ AllCops: # 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 diff --git a/lib/service/one_drive.rb b/lib/service/one_drive.rb index 75d3b3f..2b5db9c 100644 --- a/lib/service/one_drive.rb +++ b/lib/service/one_drive.rb @@ -11,8 +11,6 @@ class OneDrive AUTH_RESOURCE = 'https://graph.microsoft.com' def get_items(item_id) - puts "Getting items for #{item_id}" - items = client.get("/drives/#{drive_id}/items/#{item_id}/children") items.value || [] end @@ -24,7 +22,7 @@ def get_items_recursive(item_id = 'root') name: item.name, type: item.file ? :file : :folder, mime_type: item.file&.mime_type, - children: get_items_recursive(item.id), + children: item.folder&.child_count.to_i.positive? ? get_items_recursive(item.id) : [], parent: item.parent_reference&.path } 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 From 8f291c9dbc1f76875bba15c84846e86b40d0beac Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 15:00:21 -0400 Subject: [PATCH 08/15] Added tests for source and destination factories. --- spec/support/factories.rb | 1 + .../destination/one_drive_factory.rb | 13 ++++++++ .../document_transfer/destination_spec.rb | 32 +++++++++++++++++++ spec/unit/document_transfer/source_spec.rb | 32 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 spec/support/factories/destination/one_drive_factory.rb create mode 100644 spec/unit/document_transfer/destination_spec.rb create mode 100644 spec/unit/document_transfer/source_spec.rb diff --git a/spec/support/factories.rb b/spec/support/factories.rb index 650daa0..3c16553 100644 --- a/spec/support/factories.rb +++ b/spec/support/factories.rb @@ -7,5 +7,6 @@ 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/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/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/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 From 8561c8bd1a81103d88a90f72e790e030fe48cf71 Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 15:29:42 -0400 Subject: [PATCH 09/15] Added /transfer tests. --- lib/api/transfer.rb | 4 +- lib/destination/base.rb | 5 ++ lib/destination/one_drive.rb | 4 +- lib/source/base.rb | 6 ++ .../document_transfer/api/transfer_spec.rb | 60 +++++++++++++++++++ .../destination/one_drive_spec.rb | 2 + 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 spec/unit/document_transfer/api/transfer_spec.rb diff --git a/lib/api/transfer.rb b/lib/api/transfer.rb index 8cde2c4..a6fb289 100644 --- a/lib/api/transfer.rb +++ b/lib/api/transfer.rb @@ -34,9 +34,9 @@ class Transfer < Grape::API source = DocumentTransfer::Source.load(source_config) destination = DocumentTransfer::Destination.load(dest_config) - destination.transfer(source) + result = destination.transfer(source) - { status: 'ok', source: source_config.type, destination: dest_config.type } + { status: 'ok', destination: dest_config.type }.merge(result) end end end diff --git a/lib/destination/base.rb b/lib/destination/base.rb index d4b2007..0ff8552 100644 --- a/lib/destination/base.rb +++ b/lib/destination/base.rb @@ -3,6 +3,8 @@ module DocumentTransfer module Destination # Base class for destinations. + # + # @abstract Subclass and override {#transfer} to implement a destination. class Base # Initializes the destination. # @@ -14,6 +16,9 @@ def initialize(config) # 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 diff --git a/lib/destination/one_drive.rb b/lib/destination/one_drive.rb index 2b858b4..4388072 100644 --- a/lib/destination/one_drive.rb +++ b/lib/destination/one_drive.rb @@ -8,7 +8,9 @@ module Destination # Microsoft OneDrive destination. class OneDrive < Base def transfer(source) - service.upload(source, path: @config.path, filename: @config.filename) + result = service.upload(source, path: @config.path, filename: @config.filename) + + { path: File.join(@config.path, result.name) } end private diff --git a/lib/source/base.rb b/lib/source/base.rb index 85fa1c0..e30cf0e 100644 --- a/lib/source/base.rb +++ b/lib/source/base.rb @@ -3,6 +3,8 @@ 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. # @@ -14,6 +16,8 @@ def initialize(config) # Returns the name of the document. # # @return [String] + # + # @raise [NotImplementedError] If the method is not implemented by the subclass. def filename raise NotImplementedError end @@ -21,6 +25,8 @@ def filename # 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 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 index a7c4603..ecb7f92 100644 --- a/spec/unit/document_transfer/destination/one_drive_spec.rb +++ b/spec/unit/document_transfer/destination/one_drive_spec.rb @@ -11,6 +11,8 @@ 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 From 67bd50792dad2c31c0828d920fd38770215cb295 Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 15:32:19 -0400 Subject: [PATCH 10/15] Fixed linting errors. --- .rubocop.yml | 1 + spec/unit/document_transfer/destination/one_drive_spec.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 756c0de..749adc8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,5 @@ require: + - rubocop-factory_bot - rubocop-rake - rubocop-rspec diff --git a/spec/unit/document_transfer/destination/one_drive_spec.rb b/spec/unit/document_transfer/destination/one_drive_spec.rb index ecb7f92..765cc8a 100644 --- a/spec/unit/document_transfer/destination/one_drive_spec.rb +++ b/spec/unit/document_transfer/destination/one_drive_spec.rb @@ -12,7 +12,7 @@ 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')) + .and_return(Microsoft::Graph::JSONStruct.new(name: 'rspec.pdf')) end describe '#transfer' do From 1506ef5171bf88da3459238a326da8246398e54a Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 17:07:09 -0400 Subject: [PATCH 11/15] Added reusable response entities. --- .rubocop.yml | 5 +++++ Gemfile.lock | 11 ++++++++++- document-transfer-service.gemspec | 2 ++ lib/api/api.rb | 1 + lib/api/health.rb | 6 +++++- lib/api/transfer.rb | 8 ++++++-- lib/response/base.rb | 14 ++++++++++++++ lib/response/health_status.rb | 12 ++++++++++++ lib/response/transfer_success.rb | 15 +++++++++++++++ openapi.yaml | 27 ++++++++++++++++++++++++++- 10 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 lib/response/base.rb create mode 100644 lib/response/health_status.rb create mode 100644 lib/response/transfer_success.rb diff --git a/.rubocop.yml b/.rubocop.yml index 749adc8..ef31c45 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,3 +17,8 @@ RSpec/ExampleLength: CountAsOne: - array - hash + +Metrics/BlockLength: + AllowedMethods: + # Exclude grape `resource` blocks. + - resource diff --git a/Gemfile.lock b/Gemfile.lock index 59a45b3..30f3755 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,7 +13,9 @@ PATH 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) @@ -39,7 +41,7 @@ GEM ast (2.4.2) base64 (0.2.0) bigdecimal (3.1.8) - builder (3.2.4) + builder (3.3.0) concurrent-ruby (1.3.1) connection_pool (2.4.1) csv (3.3.0) @@ -74,9 +76,15 @@ 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) @@ -89,6 +97,7 @@ GEM 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) diff --git a/document-transfer-service.gemspec b/document-transfer-service.gemspec index be67e04..78d0429 100644 --- a/document-transfer-service.gemspec +++ b/document-transfer-service.gemspec @@ -24,7 +24,9 @@ Gem::Specification.new do |s| 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' diff --git a/lib/api/api.rb b/lib/api/api.rb index 6972c83..caf4d22 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -2,6 +2,7 @@ require 'grape' require 'grape-swagger' +require 'grape-swagger-entity' require_relative 'health' require_relative 'transfer' diff --git a/lib/api/health.rb b/lib/api/health.rb index 72cb3bb..1d5cf8b 100644 --- a/lib/api/health.rb +++ b/lib/api/health.rb @@ -3,13 +3,17 @@ require 'grape' require 'statsd-instrument' +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') - { status: 'ok' } + + present DocumentTransfer::Response::HealthStatus.new(status: 'ok') end end end diff --git a/lib/api/transfer.rb b/lib/api/transfer.rb index a6fb289..fa5c308 100644 --- a/lib/api/transfer.rb +++ b/lib/api/transfer.rb @@ -3,6 +3,7 @@ require_relative '../config/destination' require_relative '../config/source' require_relative '../destination' +require_relative '../response/transfer_success' require_relative '../source' module DocumentTransfer @@ -10,7 +11,7 @@ module API # Document transfer endpoint and resources for the API. class Transfer < Grape::API resource :transfer do - desc 'Initiate a new transfer.' + 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.' @@ -36,7 +37,10 @@ class Transfer < Grape::API result = destination.transfer(source) - { status: 'ok', destination: dest_config.type }.merge(result) + present DocumentTransfer::Response::TransferSuccess.new({ + status: 'ok', + destination: dest_config.type + }.merge(result)) end end 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/openapi.yaml b/openapi.yaml index f144700..64a3141 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -32,16 +32,21 @@ paths: 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: get Health(s) + description: Check system health + schema: + $ref: '#/definitions/HealthStatus' tags: - health operationId: getHealth @@ -120,3 +125,23 @@ definitions: - 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 From b3a4f67773ae6afd0025bd7db8bcdfa77fa43fe4 Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 18:08:03 -0400 Subject: [PATCH 12/15] Added additional documentation for the API. --- README.md | 6 ++++ doc/api.md | 59 +++++++++++++++++++++++++++++++++++++ doc/destination.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++ doc/source.md | 33 +++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 doc/api.md create mode 100644 doc/destination.md create mode 100644 doc/source.md diff --git a/README.md b/README.md index 98e36f4..6e3b327 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ 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. + +[api]: ./doc/api.md [Dockerfile]: ./Dockerfile [docker compose]: ./docker-compose.yaml [Docker Desktop]: https://docs.docker.com/desktop/ diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..bbea7e9 --- /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]: destination.md +[source]: source.md +[spec]: ../openapi.yaml diff --git a/doc/destination.md b/doc/destination.md new file mode 100644 index 0000000..ccf9b59 --- /dev/null +++ b/doc/destination.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/source.md b/doc/source.md new file mode 100644 index 0000000..8eb4f6b --- /dev/null +++ b/doc/source.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": { + ... + } +} +``` From 8bcd65cad77ab08ebba3b806e3dd2063bc0ffaf1 Mon Sep 17 00:00:00 2001 From: James Armes Date: Thu, 6 Jun 2024 18:10:41 -0400 Subject: [PATCH 13/15] Added OneDrive environment variables to Docker compose. --- docker-compose.yaml | 5 +++++ 1 file changed, 5 insertions(+) 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: From 5a63e4e365fcf06c8811c7450fc31265efd0d2a7 Mon Sep 17 00:00:00 2001 From: James Armes Date: Mon, 17 Jun 2024 12:55:29 -0400 Subject: [PATCH 14/15] Added more documentation around configuration. --- .gitignore | 1 + README.md | 25 +++++++++++++++++++++++++ doc/api.md | 4 ++-- doc/{destination.md => destinations.md} | 0 doc/{source.md => sources.md} | 0 sample.env | 11 +++++++++++ 6 files changed, 39 insertions(+), 2 deletions(-) rename doc/{destination.md => destinations.md} (100%) rename doc/{source.md => sources.md} (100%) create mode 100644 sample.env diff --git a/.gitignore b/.gitignore index 0ff19bd..7ae3bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore local environment files .env *.env +!sample.env coverage/ # Ignore Byebug command history file. diff --git a/README.md b/README.md index 6e3b327..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 @@ -40,7 +61,11 @@ The service should now be available at `http://localhost:9292`. 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/doc/api.md b/doc/api.md index bbea7e9..6a0f273 100644 --- a/doc/api.md +++ b/doc/api.md @@ -54,6 +54,6 @@ Additional fields may be included based on the destination type. } ``` -[destination]: destination.md -[source]: source.md +[destination]: destinations.md +[source]: sources.md [spec]: ../openapi.yaml diff --git a/doc/destination.md b/doc/destinations.md similarity index 100% rename from doc/destination.md rename to doc/destinations.md diff --git a/doc/source.md b/doc/sources.md similarity index 100% rename from doc/source.md rename to doc/sources.md 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= From f6d23b467507cf3b9723d1b64ce62a7c6c3b6830 Mon Sep 17 00:00:00 2001 From: James Armes Date: Mon, 17 Jun 2024 13:44:46 -0400 Subject: [PATCH 15/15] Fixed error in linter actions. --- .github/config/rubocop_linter_action.yml | 1 + 1 file changed, 1 insertion(+) 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.