Merge pull request #8891 from mkllnk/api-customers

Create API v1 with customers endpoint
This commit is contained in:
Maikel
2022-03-22 19:37:23 +11:00
committed by GitHub
20 changed files with 1215 additions and 2 deletions

View File

@@ -27,11 +27,16 @@ Metrics/BlockLength:
"class_eval",
"collection",
"context",
"delete",
"describe",
"feature",
"get",
"it",
"member",
"namespace",
"path",
"post",
"put",
"resource",
"resources",
"scenario",

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: "/"