diff --git a/Gemfile b/Gemfile index ffaf52edbf..907a5e32a9 100644 --- a/Gemfile +++ b/Gemfile @@ -117,4 +117,6 @@ group :test do # code coverage gem 'simplecov', require: false gem 'coveralls', require: false + # api + gem 'apivore', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 84b9a31540..2b45e7e96a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,7 @@ GEM railties (>= 4.0) activejob (4.2.6) activesupport (= 4.2.6) + globalid (>= 0.3.0) activemodel (4.2.6) activesupport (= 4.2.6) builder (~> 3.1) @@ -74,6 +75,13 @@ GEM acts_as_tree (2.4.0) activerecord (>= 3.0.0) addressable (2.4.0) + apivore (1.6.0) + actionpack (~> 4) + hashie (~> 3.3) + json-schema (~> 2.5) + rspec (~> 3) + rspec-expectations (~> 3.1) + rspec-mocks (~> 3.1) arel (6.0.3) attribute_normalizer (1.2.0) base32 (0.3.2) @@ -166,6 +174,8 @@ GEM gaffe (1.1.0) rails (>= 4.0.0) git-version-bump (0.15.1) + globalid (0.3.6) + activesupport (>= 4.1.0) haml (4.0.7) tilt haml-rails (0.9.0) @@ -177,6 +187,7 @@ GEM has_scope (0.6.0) actionpack (>= 3.2, < 5) activesupport (>= 3.2, < 5) + hashie (3.4.4) html2haml (2.0.0) erubis (~> 2.7.0) haml (~> 4.0.0) @@ -202,6 +213,8 @@ GEM railties (>= 4.2.0) thor (>= 0.14, < 2.0) json (1.8.3) + json-schema (2.5.0) + addressable (~> 2.3) kaminari (0.16.3) actionpack (>= 3.0.0) activesupport (>= 3.0.0) @@ -474,6 +487,7 @@ DEPENDENCIES active_model_serializers (~> 0.10.0.rc4) acts_as_tree acts_as_versioned! + apivore attribute_normalizer better_errors binding_of_caller diff --git a/app/controllers/api/v1/article_categories_controller.rb b/app/controllers/api/v1/article_categories_controller.rb index 05f248e7b7..ce29cb0e85 100644 --- a/app/controllers/api/v1/article_categories_controller.rb +++ b/app/controllers/api/v1/article_categories_controller.rb @@ -5,6 +5,10 @@ def index render json: search_scope end + def show + render json: scope.find(params.require(:id)) + end + private def max_per_page diff --git a/config/routes.rb b/config/routes.rb index 514da59b6b..3682aa4123 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -204,7 +204,7 @@ resources :orders, only: [:index, :show] resources :order_articles, only: [:index, :show] resources :group_order_articles - resources :article_categories, only: [:index] + resources :article_categories, only: [:index, :show] end end diff --git a/doc/swagger.v1.yml b/doc/swagger.v1.yml new file mode 100644 index 0000000000..a00ee8a9cd --- /dev/null +++ b/doc/swagger.v1.yml @@ -0,0 +1,501 @@ +swagger: '2.0' +info: + title: Foodsoft API v1 + version: '1.0.0' + +# development url with default scope +host: localhost:3002 +schemes: + - 'http' +basePath: /f/api/v1 + +produces: + - 'application/json' + +paths: + /user: + get: + summary: info about the currently logged-in user + tags: + - 1. User + responses: + 200: + description: user data + schema: + type: object + properties: + data: + $ref: '#/definitions/User' + 401: + description: not logged-in + schema: + $ref: '#/definitions/Error401' + security: + - foodsoft_auth: ['all'] + + /article_categories: + get: + summary: article categories + tags: + - 2. Category + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + responses: + 200: + description: article categories + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/ArticleCategory' + meta: + $ref: '#/definitions/Meta' + security: + - foodsoft_auth: ['all'] + /article_categories/{id}: + parameters: + - $ref: '#/parameters/idQ' + get: + summary: find article category by id + tags: + - 2. Category + responses: + 200: + description: article category + schema: + type: object + properties: + data: + $ref: '#/definitions/ArticleCategory' + 404: + description: not found + schema: + $ref: '#/definitions/Error404' + security: + - foodsoft_auth: ['all'] + + /orders: + get: + summary: currently open orders + tags: + - 3. Order + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + responses: + 200: + description: orders + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/Order' + meta: + $ref: '#/definitions/Meta' + security: + - foodsoft_auth: ['all'] + /orders/{id}: + parameters: + - $ref: '#/parameters/idQ' + get: + summary: find order by id + tags: + - 3. Order + responses: + 200: + description: order + schema: + type: object + properties: + data: + $ref: '#/definitions/Order' + 404: + description: not found + schema: + $ref: '#/definitions/Error404' + security: + - foodsoft_auth: ['all'] + + /order_articles: + get: + summary: articles in open orders + tags: + - 4. OrderArticle + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + responses: + 200: + description: order articles + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/OrderArticle' + meta: + $ref: '#/definitions/Meta' + security: + - foodsoft_auth: ['all'] + /order_articles/{id}: + parameters: + - $ref: '#/parameters/idQ' + get: + summary: find order article by id + tags: + - 4. OrderArticle + responses: + 200: + description: order article + schema: + type: object + properties: + data: + $ref: '#/definitions/OrderArticle' + 404: + description: not found + schema: + $ref: '#/definitions/Error404' + security: + - foodsoft_auth: ['all'] + + /group_order_articles: + get: + summary: a member's group articles in open orders + tags: + - 5. GroupOrderArticle + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + responses: + 200: + description: order articles + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/GroupOrderArticle' + meta: + $ref: '#/definitions/Meta' + 403: + description: user has no ordergroup + schema: + $ref: '#/definitions/Error403' + security: + - foodsoft_auth: ['all'] + /group_order_articles/{id}: + parameters: + - $ref: '#/parameters/idQ' + get: + summary: find a member's group order article by id + tags: + - 5. GroupOrderArticle + responses: + 200: + description: order article + schema: + type: object + properties: + data: + $ref: '#/definitions/GroupOrderArticle' + 403: + description: user has no ordergroup + schema: + $ref: '#/definitions/Error403' + 404: + description: not found + schema: + $ref: '#/definitions/Error404' + security: + - foodsoft_auth: ['all'] + + /financial_transactions: + get: + summary: financial transactions of the member's ordergroup + tags: + - 6. Financial Transaction + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + responses: + 200: + description: financial transactions + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/definitions/FinancialTransaction' + meta: + $ref: '#/definitions/Meta' + 403: + description: user has no ordergroup + schema: + $ref: '#/definitions/Error403' + security: + - foodsoft_auth: ['all'] + /financial_transactions/{id}: + parameters: + - $ref: '#/parameters/idQ' + get: + summary: find financial transaction by id + tags: + - 6. Financial Transaction + responses: + 200: + description: financial transaction + schema: + type: object + properties: + data: + $ref: '#/definitions/FinancialTransaction' + 403: + description: user has no ordergroup + schema: + $ref: '#/definitions/Error403' + 404: + description: not found + schema: + $ref: '#/definitions/Error404' + security: + - foodsoft_auth: ['all'] + +parameters: + # url parameters + idQ: + name: id + type: integer + in: path + minimum: 1 + required: true + + # query parameters + page: + name: page + type: integer + in: query + description: page number + minimum: 0 + default: 0 + per_page: + name: per_page + type: integer + in: query + description: items per page + minimum: 0 + default: 20 + +definitions: + # models + User: + type: object + properties: + id: + type: integer + name: + type: string + description: full name + email: + type: string + description: email address + required: ['id', 'name', 'email'] + Article: + type: object + properties: + id: + type: integer + name: + type: string + description: article name + unit: + type: string + description: unit of single items + unit_quantity: + type: integer + description: number of units per box + minimum: 1 + default: 1 + note: + type: ['string', 'null'] + manufacturer: + type: ['string', 'null'] + origin: + type: ['string', 'null'] + article_category_id: + type: integer + quantity_available: + type: integer + required: ['id', 'name', 'unit', 'unit_quantity', 'article_category_id'] + ArticleCategory: + type: object + properties: + id: + type: integer + name: + type: string + required: ['id', 'name'] + Order: + type: object + properties: + id: + type: integer + name: + type: string + description: name of the supplier + starts: + type: string + format: date-time + description: opening date + ends: + type: ['string', 'null'] + format: date-time + description: closing date + boxfill: + type: ['string', 'null'] + format: date-time + description: when boxfill period begins + is_open: + type: boolean + description: whether the order is currently open (right now always true) + is_boxfill: + type: ['boolean', 'null'] + description: whether the order is currently in the boxfill period (null is not currently used, but may be in the future) + required: ['id', 'name', 'starts', 'is_open'] + OrderArticle: + type: object + properties: + id: + type: integer + order_id: + type: integer + description: id of order this order article belongs to + price: + type: number + description: foodcoop price + quantity: + type: integer + description: number of articles ordered by all members as quantity + tolerance: + type: integer + description: number of articles ordered by all members as tolerance + units_to_order: + type: integer + description: number of full boxes ordered if the order would close now + article: + $ref: '#/definitions/Article' + required: ['id', 'order_id', 'price', 'quantity', 'tolerance', 'units_to_order'] + GroupOrderArticle: + type: object + properties: + id: + type: integer + order_article_id: + type: integer + quantity: + type: integer + tolerance: + type: integer + result: + type: integer + total_price: + type: number + required: ['id', 'order_article_id', 'quantity', 'tolerance', 'result', 'total_price'] + FinancialTransaction: + type: object + properties: + id: + type: integer + user_id: + type: ['integer', 'null'] + description: id of user who entered the transaction (may be null for deleted users or 0 for a system user) + user_name: + type: ['string', 'null'] + description: name of user who entered the transaction (may be null or empty string for deleted users or system users) + amount: + type: number + description: amount credited (negative for a debit transaction) + note: + type: string + description: note entered with the transaction + created_on: + type: string + format: date-time + description: when the transaction was entered + required: ['id', 'user_id', 'user_name', 'amount', 'note', 'created_on'] + + # collection meta object in root of a response + Meta: + type: object + properties: + page: + type: integer + description: page number of the returned collection + per_page: + type: integer + description: number of items per page + total_pages: + type: integer + description: total number of pages + total_count: + type: integer + description: total number of items in the collection + required: ['page', 'per_page', 'total_pages', 'total_count'] + + Error: + type: object + properties: + error: + type: string + description: error code + error_description: + type: string + description: human-readable error message (localized) + Error404: + type: object + properties: + error: + type: string + description: 'not_found' + error_description: + $ref: '#/definitions/Error/properties/error_description' + Error401: + type: object + properties: + error: + type: string + description: 'unauthorized' + error_description: + $ref: '#/definitions/Error/properties/error_description' + Error403: + type: object + properties: + error: + type: string + description: 'forbidden' + error_description: + $ref: '#/definitions/Error/properties/error_description' + Error422: + type: object + properties: + error: + type: string + description: 'not_acceptable' + error_description: + $ref: '#/definitions/Error/properties/error_description' + +securityDefinitions: + foodsoft_auth: + type: oauth2 + flow: implicit + authorizationUrl: http://localhost:3002/f/oauth/authorize + scopes: + all: full access to user functions diff --git a/spec/api/v1/swagger_spec.rb b/spec/api/v1/swagger_spec.rb new file mode 100644 index 0000000000..593070d3ae --- /dev/null +++ b/spec/api/v1/swagger_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' +require 'apivore' + +# we want to load a local file in YAML-format instead of a served JSON file +class SwaggerCheckerFile < Apivore::SwaggerChecker + def fetch_swagger! + YAML.load(File.read(swagger_path)) + end +end + +describe 'API v1', type: :apivore, order: :defined do + include ApiHelper + + subject { SwaggerCheckerFile.instance_for Rails.root.join('doc/swagger.v1.yml') } + + context 'has valid paths' do + + # users + context do + # create multiple users to make sure we're getting the authenticated user, not just any + let!(:other_user_1) { create :user } + let!(:user) { create :user } + let!(:other_user_2) { create :user } + + it { is_expected.to validate(:get, '/user', 200, auth) } + it { is_expected.to validate(:get, '/user', 401) } + + context 'with invalid access token' do + let(:access_token) { 'abc' } + it { is_expected.to validate(:get, '/user', 401, auth) } + end + end + + # article_categories + context do + let!(:cat_1) { create :article_category } + let!(:cat_2) { create :article_category } + let!(:cat_3) { create :article_category } + + it { is_expected.to validate(:get, '/article_categories', 200, auth) } + it { is_expected.to validate(:get, '/article_categories/{id}', 200, auth({'id' => cat_2.id})) } + it { is_expected.to validate(:get, '/article_categories/{id}', 404, auth({'id' => cat_3.id + 1})) } + end + + # orders + context do + let!(:order_1) { create :order, article_count: 1 } + let!(:order_2) { create :order, article_count: 1 } + let!(:order_3) { create :stock_order, article_count: 1 } + let!(:order_4) { create :order, state: 'finished', article_count: 1 } + let!(:order_5) { create :order, state: 'closed', article_count: 1 } + + it { is_expected.to validate(:get, '/orders', 200, auth) } + it { is_expected.to validate(:get, '/orders/{id}', 200, auth({'id' => order_2.id})) } + it { is_expected.to validate(:get, '/orders/{id}', 404, auth({'id' => order_4.id})) } + it { is_expected.to validate(:get, '/orders/{id}', 404, auth({'id' => order_5.id})) } + it { is_expected.to validate(:get, '/orders/{id}', 404, auth({'id' => Order.last.id + 1})) } + end + + # order_articles + context do + let!(:order) { create :order, article_count: 3 + rand(10) } + let(:oa_1) { order.order_articles[0] } + let(:oa_2) { order.order_articles[1] } + + it { is_expected.to validate(:get, '/order_articles', 200, auth) } + it { is_expected.to validate(:get, '/order_articles/{id}', 200, auth({'id' => oa_2.id})) } + it { is_expected.to validate(:get, '/order_articles/{id}', 404, auth({'id' => OrderArticle.last.id + 1})) } + end + + # group_order_articles + context do + let!(:order) { create :order, article_count: 3 } + let(:oa_1) { order.order_articles[0] } + let(:oa_2) { order.order_articles[1] } + + let(:other_go) { create :group_order, order: order } + let!(:other_goa_1) { create :group_order_article, group_order: other_go, order_article: oa_1 } + + context 'without ordergroup' do + it { is_expected.to validate(:get, '/group_order_articles', 403, auth) } + it { is_expected.to validate(:get, '/group_order_articles/{id}', 403, auth({'id' => other_goa_1.id})) } + end + + context 'in ordergroup' do + let(:user) { create :user, :ordergroup } + let(:go) { create :group_order, order: order, ordergroup: user.ordergroup } + let!(:goa_1) { create :group_order_article, group_order: go, order_article: oa_1 } + let!(:goa_2) { create :group_order_article, group_order: go, order_article: oa_2 } + + it { is_expected.to validate(:get, '/group_order_articles', 200, auth) } + it { is_expected.to validate(:get, '/group_order_articles/{id}', 200, auth({'id' => goa_2.id})) } + it { is_expected.to validate(:get, '/group_order_articles/{id}', 404, auth({'id' => other_goa_1.id})) } + it { is_expected.to validate(:get, '/group_order_articles/{id}', 404, auth({'id' => GroupOrderArticle.last.id + 1})) } + end + end + + # @todo finish + end + + # financial_transactions + context do + let(:other_user) { create :user, :ordergroup } + let!(:other_ft_1) { create :financial_transaction, ordergroup: other_user.ordergroup } + + context 'without ordergroup' do + it { is_expected.to validate(:get, '/financial_transactions', 403, auth) } + it { is_expected.to validate(:get, '/financial_transactions/{id}', 403, auth({'id' => other_ft_1.id})) } + end + + context 'in ordergroup' do + let(:user) { create :user, :ordergroup } + let!(:ft_1) { create :financial_transaction, ordergroup: user.ordergroup } + let!(:ft_2) { create :financial_transaction, ordergroup: user.ordergroup } + let!(:ft_3) { create :financial_transaction, ordergroup: user.ordergroup } + + it { is_expected.to validate(:get, '/financial_transactions', 200, auth) } + it { is_expected.to validate(:get, '/financial_transactions/{id}', 200, auth({'id' => ft_2.id})) } + it { is_expected.to validate(:get, '/financial_transactions/{id}', 404, auth({'id' => other_ft_1.id})) } + it { is_expected.to validate(:get, '/financial_transactions/{id}', 404, auth({'id' => FinancialTransaction.last.id + 1})) } + end + end + + context 'and' do + it 'tests all documented routes' do + is_expected.to validate_all_paths + end + end +end diff --git a/spec/factories/article.rb b/spec/factories/article.rb index d627110fe1..a8b74ae43e 100644 --- a/spec/factories/article.rb +++ b/spec/factories/article.rb @@ -3,22 +3,26 @@ FactoryGirl.define do factory :_article do + sequence(:name) { |n| Faker::Lorem.words(rand(2..4)).join(' ') + " ##{n}" } unit { Faker::Unit.unit } price { rand(2600) / 100 } tax { [6, 21].sample } deposit { rand(10) < 8 ? 0 : [0.0, 0.80, 1.20, 12.00].sample } unit_quantity { rand(5) < 3 ? 1 : rand(1..20) } + article_category factory :article do - sequence(:name) { |n| Faker::Lorem.words(rand(2..4)).join(' ') + " ##{n}" } - supplier { create :supplier } - article_category { create :article_category } + supplier end factory :shared_article, class: SharedArticle do - sequence(:name) { |n| Faker::Lorem.words(rand(2..4)).join(' ') + " s##{n}" } order_number { Faker::Lorem.characters(rand(1..12)) } - supplier { create :shared_supplier } + supplier factory: :shared_supplier + end + + factory :stock_article, class: StockArticle do + supplier_id 0 + quantity { rand(20) + 1 } end end diff --git a/spec/factories/doorkeeper.rb b/spec/factories/doorkeeper.rb new file mode 100644 index 0000000000..d75b48c72c --- /dev/null +++ b/spec/factories/doorkeeper.rb @@ -0,0 +1,15 @@ +require 'factory_girl' +require 'doorkeeper' + +FactoryGirl.define do + + factory :oauth2_application, class: Doorkeeper::Application do + name { Faker::App.name } + redirect_uri 'https://example.com:1234/app' + end + + factory :oauth2_access_token, class: Doorkeeper::AccessToken do + application factory: :oauth2_application + end + +end diff --git a/spec/factories/financial_transaction.rb b/spec/factories/financial_transaction.rb new file mode 100644 index 0000000000..4b0cd5146e --- /dev/null +++ b/spec/factories/financial_transaction.rb @@ -0,0 +1,10 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :financial_transaction do + user + ordergroup + amount { rand(-99_999.00..99_999.00) } + note { Faker::Lorem.sentence } + end +end diff --git a/spec/factories/group_order.rb b/spec/factories/group_order.rb index 1a665ca4b7..774c9cda45 100644 --- a/spec/factories/group_order.rb +++ b/spec/factories/group_order.rb @@ -4,7 +4,7 @@ # requires order factory :group_order do - ordergroup { create(:user, groups: [FactoryGirl.create(:ordergroup)]).ordergroup } + ordergroup { create(:user, :ordergroup).ordergroup } end end diff --git a/spec/factories/order.rb b/spec/factories/order.rb index 619ceb8b80..c67ef31ec4 100644 --- a/spec/factories/order.rb +++ b/spec/factories/order.rb @@ -11,10 +11,14 @@ article_count true end - # for an order from stock; need to add articles + # for an order from stock factory :stock_order do supplier_id 0 - # article_ids needs to be supplied + after :create do |order, evaluator| + article_count = evaluator.article_count + article_count = rand(1..99) if article_count == true + create_list :stock_article, article_count + end end # In the order's after_save callback order articles are created, so diff --git a/spec/factories/user.rb b/spec/factories/user.rb index f70c48c8aa..9b98c535bd 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -15,6 +15,12 @@ create :workgroup, role_admin: true, user_ids: [user.id] end end + + trait :ordergroup do + after :create do |user, evaluator| + create :ordergroup, user_ids: [user.id] + end + end end factory :group do diff --git a/spec/support/api_helper.rb b/spec/support/api_helper.rb new file mode 100644 index 0000000000..2013d41076 --- /dev/null +++ b/spec/support/api_helper.rb @@ -0,0 +1,18 @@ +module ApiHelper + extend ActiveSupport::Concern + + included do + let(:user) { create :user } + let(:access_token) { create(:oauth2_access_token, resource_owner_id: user.id).token } + let(:authorization) { "Bearer #{token}" } + end + + # Add authentication to parameters for {Swagger::RspecHelpers#validate} + # @param params [Hash] Query parameters + # @return Query parameters with authentication header + # @see Swagger::RspecHelpers#validate + def auth(params = {}) + {'_headers' => {'Authorization' => "Bearer #{access_token}" }}.deep_merge(params) + end + +end