diff --git a/.rubocop_styleguide.yml b/.rubocop_styleguide.yml index 31e65d089b..500b642c70 100644 --- a/.rubocop_styleguide.yml +++ b/.rubocop_styleguide.yml @@ -27,11 +27,16 @@ Metrics/BlockLength: "class_eval", "collection", "context", + "delete", "describe", "feature", + "get", "it", "member", "namespace", + "path", + "post", + "put", "resource", "resources", "scenario", diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 2815da9648..d796055b5f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -466,6 +466,7 @@ Metrics/BlockLength: - 'spec/lib/open_food_network/group_buy_report_spec.rb' - 'spec/requests/api/orders_spec.rb' - 'spec/spec_helper.rb' + - 'spec/swagger_helper.rb' - 'spec/support/cancan_helper.rb' - 'spec/support/matchers/select2_matchers.rb' - 'spec/support/matchers/table_matchers.rb' @@ -582,6 +583,7 @@ Metrics/MethodLength: - 'app/controllers/spree/orders_controller.rb' - 'app/helpers/checkout_helper.rb' - 'app/helpers/spree/admin/navigation_helper.rb' + - "app/json_schemas/json_api_schema.rb" - 'app/models/spree/ability.rb' - 'app/models/spree/gateway/pay_pal_express.rb' - 'app/models/spree/order/checkout.rb' diff --git a/Gemfile b/Gemfile index 72210c3408..c7fe245943 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,7 @@ gem 'devise-token_authenticatable' gem 'jwt', '~> 2.3' gem 'oauth2', '~> 1.4.7' # Used for Stripe Connect +gem 'jsonapi-serializer' gem 'pagy', '~> 5.1' gem 'rswag-api' diff --git a/Gemfile.lock b/Gemfile.lock index c5950fa8b2..a3a6b9e440 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -346,6 +346,8 @@ GEM json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) + jsonapi-serializer (2.2.0) + activesupport (>= 4.2) jwt (2.3.0) knapsack (4.0.0) rake @@ -739,6 +741,7 @@ DEPENDENCIES jquery-ui-rails (~> 4.2) json json_spec (~> 1.1.4) + jsonapi-serializer jwt (~> 2.3) knapsack letter_opener (>= 1.4.1) diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 0000000000..aca1c09e55 --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Api + module V1 + class BaseController < ActionController::API + include CanCan::ControllerAdditions + include RequestTimeouts + include Pagy::Backend + include JsonApiPagination + + check_authorization + + attr_accessor :current_api_user + + before_action :authenticate_user + + rescue_from StandardError, with: :error_during_processing + rescue_from CanCan::AccessDenied, with: :unauthorized + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from Pagy::VariableError, with: :invalid_pagination + + private + + def authenticate_user + return if (@current_api_user = request.env['warden'].user) + + if api_key.blank? + # An anonymous user + @current_api_user = Spree::User.new + return + end + + return if (@current_api_user = Spree::User.find_by(spree_api_key: api_key.to_s)) + + invalid_api_key + end + + def current_ability + Spree::Ability.new(current_api_user) + end + + def api_key + request.headers["X-Api-Token"] || params[:token] + end + + def error_during_processing(exception) + Bugsnag.notify(exception) + + if Rails.env.development? || Rails.env.test? + render status: :unprocessable_entity, + json: json_api_error(exception.message, meta: exception.backtrace) + else + render status: :unprocessable_entity, + json: json_api_error(I18n.t(:unknown_error, scope: "api")) + end + end + + def invalid_pagination(exception) + render status: :unprocessable_entity, + json: json_api_error(exception.message) + end + + def invalid_resource!(resource = nil) + render status: :unprocessable_entity, + json: json_api_invalid( + I18n.t(:invalid_resource, scope: "api"), + resource&.errors + ) + end + + def invalid_api_key + render status: :unauthorized, + json: json_api_error(I18n.t(:invalid_api_key, key: api_key, scope: "api")) + end + + def unauthorized + render status: :unauthorized, + json: json_api_error(I18n.t(:unauthorized, scope: "api")) + end + + def not_found + render status: :not_found, + json: json_api_error(I18n.t(:resource_not_found, scope: "api")) + end + + def json_api_error(message, **options) + { errors: [{ detail: message }] }.merge(options) + end + + def json_api_invalid(message, errors) + error_response = { errors: [{ detail: message }] } + error_response.merge!(meta: { validation_errors: errors.to_a }) if errors.any? + error_response + end + end + end +end diff --git a/app/controllers/api/v1/customers_controller.rb b/app/controllers/api/v1/customers_controller.rb new file mode 100644 index 0000000000..6c403ce2d4 --- /dev/null +++ b/app/controllers/api/v1/customers_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'open_food_network/permissions' + +module Api + module V1 + class CustomersController < Api::V1::BaseController + skip_authorization_check only: :index + + before_action :set_customer, only: [:show, :update, :destroy] + before_action :authorize_action, only: [:show, :update, :destroy] + + def index + @pagy, customers = pagy(search_customers, pagy_options) + + render json: Api::V1::CustomerSerializer.new(customers, pagination_options) + end + + def show + render json: Api::V1::CustomerSerializer.new(@customer) + end + + def create + authorize! :update, Enterprise.find(customer_params[:enterprise_id]) + @customer = Customer.new(customer_params) + + if @customer.save + render json: Api::V1::CustomerSerializer.new(@customer), status: :created + else + invalid_resource! @customer + end + end + + def update + if @customer.update(customer_params) + render json: Api::V1::CustomerSerializer.new(@customer) + else + invalid_resource! @customer + end + end + + def destroy + if @customer.destroy + render json: Api::V1::CustomerSerializer.new(@customer) + else + invalid_resource! @customer + end + end + + private + + def set_customer + @customer = Customer.find(params[:id]) + end + + def authorize_action + authorize! action_name.to_sym, @customer + end + + def search_customers + customers = visible_customers + customers = customers.where(enterprise_id: params[:enterprise_id]) if params[:enterprise_id] + customers.ransack(params[:q]).result + end + + def visible_customers + current_api_user.customers.or( + Customer.where(enterprise_id: editable_enterprises) + ) + end + + def customer_params + params.require(:customer).permit(:email, :enterprise_id) + end + + def editable_enterprises + OpenFoodNetwork::Permissions.new(current_api_user).editable_enterprises.select(:id) + end + end + end +end diff --git a/app/controllers/concerns/json_api_pagination.rb b/app/controllers/concerns/json_api_pagination.rb new file mode 100644 index 0000000000..b014e3119b --- /dev/null +++ b/app/controllers/concerns/json_api_pagination.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module JsonApiPagination + extend ActiveSupport::Concern + + DEFAULT_PER_PAGE = 50 + MAX_PER_PAGE = 200 + + def pagination_options + { + is_collection: true, + meta: meta_options, + links: links_options, + } + end + + def pagy_options + { items: final_per_page_value } + end + + private + + def meta_options + { + pagination: { + results: @pagy.count, + pages: total_pages, + page: current_page, + per_page: final_per_page_value + } + } + end + + def links_options + { + self: pagination_url(current_page), + first: pagination_url(1), + prev: pagination_url(previous_page), + next: pagination_url(next_page), + last: pagination_url(total_pages) + } + end + + def pagination_url(page_number) + return if page_number.nil? + + url_for(only_path: false, params: request.query_parameters.merge(page: page_number)) + end + + # User-specified value, or DEFAULT_PER_PAGE, capped at MAX_PER_PAGE + def final_per_page_value + (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, MAX_PER_PAGE) + end + + def current_page + (params[:page] || 1).to_i + end + + def total_pages + @pagy.pages + end + + def previous_page + return nil if current_page < 2 + + current_page - 1 + end + + def next_page + return nil if current_page >= total_pages + + current_page + 1 + end +end diff --git a/app/json_schemas/customer_schema.rb b/app/json_schemas/customer_schema.rb new file mode 100644 index 0000000000..4652ae3bbf --- /dev/null +++ b/app/json_schemas/customer_schema.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CustomerSchema < JsonApiSchema + def self.object_name + "customer" + end + + def self.attributes + { + id: { type: :integer, example: 1 }, + enterprise_id: { type: :integer, example: 2 }, + first_name: { type: :string, nullable: true, example: "Alice" }, + last_name: { type: :string, nullable: true, example: "Springs" }, + code: { type: :string, nullable: true, example: "BUYER1" }, + email: { type: :string, example: "alice@example.com" } + } + end + + def self.required_attributes + [:enterprise_id, :email] + end + + def self.relationships + [:enterprise] + end +end diff --git a/app/json_schemas/errors_schema.rb b/app/json_schemas/errors_schema.rb new file mode 100644 index 0000000000..590dda849d --- /dev/null +++ b/app/json_schemas/errors_schema.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ErrorsSchema + def self.schema + { + type: :object, + properties: { + errors: { + type: :array, + items: { + type: :object, + properties: { + title: { type: :string }, + detail: { type: :string }, + source: { type: :object } + }, + required: [:detail] + } + } + }, + required: [:errors] + } + end +end diff --git a/app/json_schemas/json_api_schema.rb b/app/json_schemas/json_api_schema.rb new file mode 100644 index 0000000000..069de6633d --- /dev/null +++ b/app/json_schemas/json_api_schema.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class JsonApiSchema + class << self + def attributes + {} + end + + def required_attributes + [] + end + + def relationships + [] + end + + def all_attributes + attributes.keys + end + + def schema(options = {}) + { + type: :object, + properties: { + data: { + type: :object, + properties: data_properties(**options) + }, + meta: { type: :object }, + links: { type: :object } + }, + required: [:data] + } + end + + def collection(options) + { + type: :object, + properties: { + data: { + type: :array, + items: { + type: :object, + properties: data_properties(**options) + } + }, + meta: { + type: :object, + properties: { + pagination: { + type: :object, + properties: { + results: { type: :integer, example: 250 }, + pages: { type: :integer, example: 5 }, + page: { type: :integer, example: 2 }, + per_page: { type: :integer, example: 50 }, + } + } + }, + required: [:pagination] + }, + links: { + type: :object, + properties: { + self: { type: :string }, + first: { type: :string }, + prev: { type: :string, nullable: true }, + next: { type: :string, nullable: true }, + last: { type: :string } + } + } + }, + required: [:data, :meta, :links] + } + end + + private + + def data_properties(require_all: false) + required = require_all ? all_attributes : required_attributes + + { + id: { type: :string, example: "1" }, + type: { type: :string, example: object_name }, + attributes: { + type: :object, + properties: attributes, + required: required + }, + relationships: { + type: :object, + properties: relationships.to_h do |name| + [ + name, + relationship_schema(name) + ] + end + } + } + end + + def relationship_schema(name) + if is_singular?(name) + RelationshipSchema.schema(name) + else + RelationshipSchema.collection(name) + end + end + + def is_singular?(name) + name.to_s.singularize == name.to_s + end + end +end diff --git a/app/json_schemas/relationship_schema.rb b/app/json_schemas/relationship_schema.rb new file mode 100644 index 0000000000..a223fd390f --- /dev/null +++ b/app/json_schemas/relationship_schema.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class RelationshipSchema + def self.schema(resource_name = nil) + { + type: :object, + properties: { + data: { + type: :object, + properties: { + id: { type: :string }, + type: { type: :string, example: resource_name } + } + }, + links: { + type: :object, + properties: { + related: { type: :string } + } + } + } + } + end + + def self.collection(resource_name = nil) + { + type: :object, + properties: { + data: { + type: :array, + items: { + type: :object, + properties: { + id: { type: :string }, + type: { type: :string, example: resource_name } + } + } + }, + links: { + type: :object, + properties: { + related: { type: :string } + } + } + } + } + end +end diff --git a/app/serializers/api/v1/base_serializer.rb b/app/serializers/api/v1/base_serializer.rb new file mode 100644 index 0000000000..4689406779 --- /dev/null +++ b/app/serializers/api/v1/base_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Api + module V1 + class BaseSerializer + include JSONAPI::Serializer + + def self.url_helpers + Rails.application.routes.url_helpers + end + end + end +end diff --git a/app/serializers/api/v1/customer_serializer.rb b/app/serializers/api/v1/customer_serializer.rb new file mode 100644 index 0000000000..6f71b39159 --- /dev/null +++ b/app/serializers/api/v1/customer_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Api + module V1 + class CustomerSerializer < BaseSerializer + attributes :id, :enterprise_id, :first_name, :last_name, :code, :email + + belongs_to :enterprise, links: { + related: ->(object) { + url_helpers.api_v1_enterprise_url(id: object.enterprise_id) + } + } + end + end +end diff --git a/app/serializers/api/v1/enterprise_serializer.rb b/app/serializers/api/v1/enterprise_serializer.rb new file mode 100644 index 0000000000..abbb9cdd3c --- /dev/null +++ b/app/serializers/api/v1/enterprise_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Api + module V1 + class EnterpriseSerializer < BaseSerializer + attributes :id, :name + + has_many :customers, links: { + related: ->(object) { + url_helpers.api_v1_enterprise_customers_url(enterprise_id: object.id) + } + } + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index e031fa6c61..7bee8133c7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -165,7 +165,6 @@ en: no_default_card: "^No default card available for this customer" shipping_method: not_available_to_shop: "is not available to %{shop}" - card_details: "Card details" card_type: "Card type" cardholder_name: "Cardholder name" @@ -1410,6 +1409,11 @@ en: # API # api: + unknown_error: "Something went wrong. Our team has been notified." + invalid_api_key: "Invalid API key (%{key}) specified." + unauthorized: "You are not authorized to perform that action." + invalid_resource: "Invalid resource. Please fix errors and try again." + resource_not_found: "The resource you were looking for could not be found." enterprise_logo: destroy_attachment_does_not_exist: "Logo does not exist" enterprise_promo_image: diff --git a/config/routes/api.rb b/config/routes/api.rb index 062958c495..9fa70e1d13 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -87,6 +87,16 @@ Openfoodnetwork::Application.routes.draw do constraints: lambda { |_| Flipper.enabled?(:api_reports) } end + unless Rails.env.production? + namespace :v1 do + resources :customers + + resources :enterprises do + resources :customers, only: :index + end + end + end + match '*path', to: redirect(path: "/api/v0/%{path}"), via: :all, constraints: { path: /(?!v[0-9]).+/ } end end diff --git a/spec/requests/api/v1/customers_spec.rb b/spec/requests/api/v1/customers_spec.rb new file mode 100644 index 0000000000..7c8519a4b0 --- /dev/null +++ b/spec/requests/api/v1/customers_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require "swagger_helper" + +describe "Customers", type: :request do + let!(:enterprise1) { create(:enterprise) } + let!(:enterprise2) { create(:enterprise) } + let!(:customer1) { create(:customer, enterprise: enterprise1) } + let!(:customer2) { create(:customer, enterprise: enterprise1) } + let!(:customer3) { create(:customer, enterprise: enterprise2) } + + before { login_as enterprise1.owner } + + path "/api/v1/customers" do + get "List customers" do + tags "Customers" + parameter name: :enterprise_id, in: :query, type: :string + produces "application/json" + + response "200", "Customers list" do + param(:enterprise_id) { enterprise1.id } + schema "$ref": "#/components/schemas/resources/customers_collection" + + run_test! + end + end + + describe "returning results based on permissions" do + context "as guest user" do + before { login_as nil } + + it "returns no customers" do + get "/api/v1/customers" + expect(json_response_ids).to eq [] + end + + it "returns not even customers without user id" do + customer3.update!(user_id: nil) + + get "/api/v1/customers" + expect(json_response_ids).to eq [] + end + end + + context "as an enterprise owner" do + before { login_as enterprise1.owner } + + it "returns customers of enterprises the user manages" do + get "/api/v1/customers" + expect(json_response_ids).to eq [customer1.id.to_s, customer2.id.to_s] + end + end + + context "as another enterprise owner" do + before { login_as enterprise2.owner } + + it "returns customers of enterprises the user manages" do + get "/api/v1/customers" + expect(json_response_ids).to eq [customer3.id.to_s] + end + end + + context "with ransack params searching for specific customers" do + before { login_as enterprise2.owner } + + it "does not show results the user doesn't have permissions to view" do + get "/api/v1/customers", params: { q: { id_eq: customer2.id } } + + expect(json_response_ids).to eq [] + end + end + end + + describe "pagination" do + it "renders the first page" do + get "/api/v1/customers", params: { page: "1" } + expect(json_response_ids).to eq [customer1.id.to_s, customer2.id.to_s] + end + + it "renders the second page" do + get "/api/v1/customers", params: { page: "2", per_page: "1" } + expect(json_response_ids).to eq [customer2.id.to_s] + end + + it "renders beyond the available pages" do + get "/api/v1/customers", params: { page: "2" } + expect(json_response_ids).to eq [] + end + + it "informs about invalid pages" do + get "/api/v1/customers", params: { page: "0" } + expect(json_response_ids).to eq nil + expect(json_error_detail).to eq 'expected :page >= 1; got "0"' + end + end + + post "Create customer" do + tags "Customers" + consumes "application/json" + produces "application/json" + + parameter name: :customer, in: :body, schema: { + type: :object, + properties: CustomerSchema.attributes.except(:id), + required: CustomerSchema.required_attributes + } + + response "201", "Customer created" do + param(:customer) do + { + email: "test@example.com", + enterprise_id: enterprise1.id.to_s + } + end + schema "$ref": "#/components/schemas/resources/customer" + + run_test! + end + + response "422", "Unprocessable entity" do + param(:customer) { {} } + schema "$ref": "#/components/schemas/error_response" + + run_test! + end + end + end + + path "/api/v1/customers/{id}" do + get "Show customer" do + tags "Customers" + parameter name: :id, in: :path, type: :string + produces "application/json" + + response "200", "Customer" do + param(:id) { customer1.id } + schema "$ref": "#/components/schemas/resources/customer" + + run_test! + end + + response "404", "Not found" do + param(:id) { 0 } + schema "$ref": "#/components/schemas/error_response" + + run_test! do + expect(json_error_detail).to eq "The resource you were looking for could not be found." + end + end + + context "without authentication" do + before { logout } + + response "401", "Unauthorized" do + param(:id) { customer1.id } + schema "$ref": "#/components/schemas/error_response" + + run_test! do + expect(json_error_detail).to eq "You are not authorized to perform that action." + end + end + end + + describe "related records" do + it "serializes the enterprise relationship" do + expected_enterprise_data = { + "data" => { + "id" => customer1.enterprise_id.to_s, + "type" => "enterprise" + }, + "links" => { + "related" => "http://test.host/api/v1/enterprises/#{customer1.enterprise_id}" + } + } + + get "/api/v1/customers/#{customer1.id}" + expect(json_response[:data][:relationships][:enterprise]).to eq(expected_enterprise_data) + end + end + end + + put "Update customer" do + tags "Customers" + parameter name: :id, in: :path, type: :string + consumes "application/json" + produces "application/json" + + parameter name: :customer, in: :body, schema: { + type: :object, + properties: CustomerSchema.attributes, + required: CustomerSchema.required_attributes + } + + response "200", "Customer updated" do + param(:id) { customer1.id } + param(:customer) do + { + id: customer1.id.to_s, + email: "test@example.com", + enterprise_id: enterprise1.id.to_s + } + end + schema "$ref": "#/components/schemas/resources/customer" + + run_test! + end + + response "422", "Unprocessable entity" do + param(:id) { customer1.id } + param(:customer) { {} } + schema "$ref": "#/components/schemas/error_response" + + run_test! + end + end + + delete "Delete customer" do + tags "Customers" + parameter name: :id, in: :path, type: :string + produces "application/json" + + response "200", "Customer deleted" do + param(:id) { customer1.id } + schema "$ref": "#/components/schemas/resources/customer" + + run_test! + end + end + end + + path "/api/v1/enterprises/{enterprise_id}/customers" do + get "List customers of an enterprise" do + tags "Customers", "Enterprises" + parameter name: :enterprise_id, in: :path, type: :string, required: true + produces "application/json" + + response "200", "Customers list" do + param(:enterprise_id) { enterprise1.id } + schema "$ref": "#/components/schemas/resources/customers_collection" + + run_test! + end + end + end +end diff --git a/spec/support/api_helper.rb b/spec/support/api_helper.rb index 9ac5c7a1cb..37d1d6a4d1 100644 --- a/spec/support/api_helper.rb +++ b/spec/support/api_helper.rb @@ -14,6 +14,14 @@ module OpenFoodNetwork end end + def json_response_ids + json_response[:data]&.map{ |item| item["id"] } + end + + def json_error_detail + json_response[:errors][0][:detail] + end + def assert_unauthorized! expect(json_response).to eq("error" => "You are not authorized to perform that action.") expect(response.status).to eq 401 diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index d47d6a49ae..46e7a7760d 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.configure do |config| + config.include Devise::Test::IntegrationHelpers, type: :request + config.include OpenFoodNetwork::ApiHelper, type: :request + # Specify a root folder where Swagger JSON files are generated # NOTE: If you're using the rswag-api to serve API descriptions, you'll need # to ensure that it's configured to serve Swagger from the same folder @@ -21,6 +24,35 @@ RSpec.configure do |config| title: 'API V1', version: 'v1' }, + components: { + schemas: { + error_response: ErrorsSchema.schema, + resources: { + customer: CustomerSchema.schema(require_all: true), + customers_collection: CustomerSchema.collection(require_all: true) + } + }, + securitySchemas: { + api_key_header: { + type: :apiKey, + name: 'X-Api-Token', + in: :header, + description: "Authenticates via API key passed in specified header" + }, + api_key_param: { + type: :apiKey, + name: 'token', + in: :query, + description: "Authenticates via API key passed in specified query param" + }, + session: { + type: :http, + name: '_ofn_session', + in: :cookie, + description: "Authenticates using the current user's session if logged in" + }, + } + }, paths: {}, servers: [ { url: "/" } @@ -41,3 +73,10 @@ RSpec.configure do |config| # Defaults to json. Accepts ':json' and ':yaml'. config.swagger_format = :yaml end + +module RswagExtension + def param(args, &block) + public_send(:let, args) { instance_eval(&block) } + end +end +Rswag::Specs::ExampleGroupHelpers.prepend RswagExtension diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index d387a8fd33..9b2642959d 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -3,6 +3,395 @@ openapi: 3.0.1 info: title: API V1 version: v1 -paths: {} +components: + schemas: + error_response: + type: object + properties: + errors: + type: array + items: + type: object + properties: + title: + type: string + detail: + type: string + source: + type: object + required: + - detail + required: + - errors + resources: + customer: + type: object + properties: + data: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + example: customer + attributes: + type: object + properties: + id: + type: integer + example: 1 + enterprise_id: + type: integer + example: 2 + first_name: + type: string + nullable: true + example: Alice + last_name: + type: string + nullable: true + example: Springs + code: + type: string + nullable: true + example: BUYER1 + email: + type: string + example: alice@example.com + required: + - id + - enterprise_id + - first_name + - last_name + - code + - email + relationships: + type: object + properties: + enterprise: + type: object + properties: + data: + type: object + properties: + id: + type: string + type: + type: string + example: enterprise + links: + type: object + properties: + related: + type: string + meta: + type: object + links: + type: object + required: + - data + customers_collection: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + example: '1' + type: + type: string + example: customer + attributes: + type: object + properties: + id: + type: integer + example: 1 + enterprise_id: + type: integer + example: 2 + first_name: + type: string + nullable: true + example: Alice + last_name: + type: string + nullable: true + example: Springs + code: + type: string + nullable: true + example: BUYER1 + email: + type: string + example: alice@example.com + required: + - id + - enterprise_id + - first_name + - last_name + - code + - email + relationships: + type: object + properties: + enterprise: + type: object + properties: + data: + type: object + properties: + id: + type: string + type: + type: string + example: enterprise + links: + type: object + properties: + related: + type: string + meta: + type: object + properties: + pagination: + type: object + properties: + results: + type: integer + example: 250 + pages: + type: integer + example: 5 + page: + type: integer + example: 2 + per_page: + type: integer + example: 50 + required: + - pagination + links: + type: object + properties: + self: + type: string + first: + type: string + prev: + type: string + nullable: true + next: + type: string + nullable: true + last: + type: string + required: + - data + - meta + - links + securitySchemas: + api_key_header: + type: apiKey + name: X-Api-Token + in: header + description: Authenticates via API key passed in specified header + api_key_param: + type: apiKey + name: token + in: query + description: Authenticates via API key passed in specified query param + session: + type: http + name: _ofn_session + in: cookie + description: Authenticates using the current user's session if logged in +paths: + "/api/v1/customers": + get: + summary: List customers + tags: + - Customers + parameters: + - name: enterprise_id + in: query + schema: + type: string + responses: + '200': + description: Customers list + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customers_collection" + post: + summary: Create customer + tags: + - Customers + parameters: [] + responses: + '201': + description: Customer created + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customer" + '422': + description: Unprocessable entity + content: + application/json: + schema: + "$ref": "#/components/schemas/error_response" + requestBody: + content: + application/json: + schema: + type: object + properties: + enterprise_id: + type: integer + example: 2 + first_name: + type: string + nullable: true + example: Alice + last_name: + type: string + nullable: true + example: Springs + code: + type: string + nullable: true + example: BUYER1 + email: + type: string + example: alice@example.com + required: + - enterprise_id + - email + "/api/v1/customers/{id}": + get: + summary: Show customer + tags: + - Customers + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Customer + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customer" + '404': + description: Not found + content: + application/json: + schema: + "$ref": "#/components/schemas/error_response" + '401': + description: Unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/error_response" + put: + summary: Update customer + tags: + - Customers + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Customer updated + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customer" + '422': + description: Unprocessable entity + content: + application/json: + schema: + "$ref": "#/components/schemas/error_response" + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: integer + example: 1 + enterprise_id: + type: integer + example: 2 + first_name: + type: string + nullable: true + example: Alice + last_name: + type: string + nullable: true + example: Springs + code: + type: string + nullable: true + example: BUYER1 + email: + type: string + example: alice@example.com + required: + - enterprise_id + - email + delete: + summary: Delete customer + tags: + - Customers + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Customer deleted + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customer" + "/api/v1/enterprises/{enterprise_id}/customers": + get: + summary: List customers of an enterprise + tags: + - Customers + - Enterprises + parameters: + - name: enterprise_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Customers list + content: + application/json: + schema: + "$ref": "#/components/schemas/resources/customers_collection" servers: - url: "/"