mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-29 18:28:28 -04:00
250 lines
9.9 KiB
Ruby
250 lines
9.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# The OpenAPI description of /api/v1, built from the Graphiti resources so that it cannot drift from the API.
|
|
# Served to Swagger UI at /api-docs/v1/swagger.json.
|
|
class ApiDocs
|
|
SCALARS = {
|
|
'string' => { type: 'string' },
|
|
'integer' => { type: 'integer' },
|
|
'integer_id' => { type: 'integer' },
|
|
'float' => { type: 'number', format: 'float' },
|
|
'big_decimal' => { type: 'string', format: 'decimal' },
|
|
'boolean' => { type: 'boolean' },
|
|
'date' => { type: 'string', format: 'date' },
|
|
'datetime' => { type: 'string', format: 'date-time' },
|
|
'hash' => { type: 'object' }
|
|
}.freeze
|
|
|
|
ERRORS = {
|
|
'400' => 'The request could not be understood, for example an unknown filter, sort or include.',
|
|
'401' => 'A write was attempted without a valid API token.',
|
|
'403' => 'The token holder may not do this to this record.',
|
|
'404' => 'No such record.',
|
|
'422' => 'The record is not valid. The errors say which attributes are at fault.'
|
|
}.freeze
|
|
|
|
def self.openapi
|
|
new(Graphiti::Schema.generate).openapi
|
|
end
|
|
|
|
def initialize(schema)
|
|
@schema = schema
|
|
@resources = schema[:resources].index_by { |resource| resource[:name] }
|
|
end
|
|
|
|
def openapi
|
|
{
|
|
openapi: '3.0.3',
|
|
info: { title: 'Growstuff API', version: 'v1', description: description },
|
|
servers: [{ url: Rails.configuration.x.api_base_url }],
|
|
paths: paths,
|
|
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } }, schemas: schemas }
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def description
|
|
<<~TEXT
|
|
A JSON:API (https://jsonapi.org) interface to Growstuff. Reading is open to everyone; creating, changing and
|
|
deleting need an API token from your account settings, sent as `Authorization: Bearer <token>`.
|
|
Attribute and filter names are dashed (`planted-at`, `filter[owner-id]`).
|
|
TEXT
|
|
end
|
|
|
|
def paths
|
|
@schema[:endpoints].each_with_object({}) do |(path, endpoint), paths|
|
|
actions = endpoint[:actions].transform_values { |action| @resources[action[:resource]] }
|
|
collection = actions.slice(:index, :create, :search).map { |name, resource| [verb(name), operation(name, resource)] }
|
|
member = actions.slice(:show, :update, :destroy).map { |name, resource| [verb(name), operation(name, resource)] }
|
|
paths[path.to_s] = collection.to_h if collection.any?
|
|
paths["#{path}/{id}"] = member.to_h.merge(parameters: [id_parameter]) if member.any?
|
|
end
|
|
end
|
|
|
|
def verb(action)
|
|
{ index: :get, search: :get, show: :get, create: :post, update: :patch, destroy: :delete }.fetch(action)
|
|
end
|
|
|
|
def operation(action, resource)
|
|
type = resource[:type]
|
|
operation = { tags: [type], summary: summary(action, type), responses: responses(action, resource) }
|
|
operation[:parameters] = parameters(action, resource)
|
|
operation[:security] = [{ bearerAuth: [] }] if %i(create update destroy).include?(action)
|
|
operation[:requestBody] = request_body(action, resource) if %i(create update).include?(action)
|
|
operation.compact
|
|
end
|
|
|
|
def summary(action, type)
|
|
{ index: "List #{type}", search: "Search #{type} by name", show: "Get one of #{type}",
|
|
create: "Create one of #{type}", update: "Change one of #{type}", destroy: "Delete one of #{type}" }.fetch(action)
|
|
end
|
|
|
|
def parameters(action, resource)
|
|
case action
|
|
when :index then filter_parameters(resource) + [sort_parameter(resource), include_parameter(resource)] + page_parameters(resource)
|
|
when :search then [term_parameter] + page_parameters(resource)
|
|
when :show then [include_parameter(resource)]
|
|
end
|
|
end
|
|
|
|
def id_parameter
|
|
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }
|
|
end
|
|
|
|
def term_parameter
|
|
{ name: 'term', in: 'query', required: true, schema: { type: 'string' }, description: 'Words from the crop\'s name' }
|
|
end
|
|
|
|
def filter_parameters(resource)
|
|
resource[:filters].map do |name, filter|
|
|
extra = filter[:operators] - ['eq']
|
|
description = +'Several values can be given, separated by commas.'
|
|
if extra.many?
|
|
description << " Also `filter[#{dashed(name)}][#{extra.first}]` and #{extra.drop(1).map do |op|
|
|
"`#{op}`"
|
|
end.to_sentence}."
|
|
end
|
|
# Filters that take a list are strings; those that take one value (booleans) are that value.
|
|
schema = filter[:single] ? scalar(filter[:type]) : { type: 'string' }
|
|
{ name: "filter[#{dashed(name)}]", in: 'query', required: false, schema: schema, description: description }
|
|
end
|
|
end
|
|
|
|
def sort_parameter(resource)
|
|
names = resource[:sorts].keys.map { |name| dashed(name) }
|
|
{ name: 'sort', in: 'query', required: false, schema: { type: 'string' },
|
|
description: "Comma separated, with `-` in front to reverse. One of: #{names.map { |n| "`#{n}`" }.to_sentence}." }
|
|
end
|
|
|
|
def include_parameter(resource)
|
|
names = resource[:relationships].keys
|
|
{ name: 'include', in: 'query', required: false, schema: { type: 'string' },
|
|
description: "Related records to send as well, comma separated. One of: #{names.map { |n| "`#{n}`" }.to_sentence}." }
|
|
end
|
|
|
|
def page_parameters(resource)
|
|
max = ApplicationResource.max_page_size
|
|
[
|
|
{ name: 'page[number]', in: 'query', required: false, schema: { type: 'integer', minimum: 1 }, description: 'Which page, from 1.' },
|
|
{ name: 'page[size]', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: max },
|
|
description: "How many to a page, up to #{max}. Defaults to #{resource[:default_page_size]}. " \
|
|
'`page[limit]` and `page[offset]` also work.' }
|
|
]
|
|
end
|
|
|
|
def responses(action, resource)
|
|
type = resource[:type]
|
|
ok = case action
|
|
when :destroy then { '204' => { description: 'Deleted' } }
|
|
when :create then { '201' => document("A new #{type.singularize}", type) }
|
|
when :index, :search then { '200' => document("A page of #{type}", type, many: true) }
|
|
else { '200' => document(type.singularize.capitalize, type) }
|
|
end
|
|
errors = ERRORS.slice(*error_codes(action)).transform_values { |text| { description: text } }
|
|
ok.merge(errors)
|
|
end
|
|
|
|
def error_codes(action)
|
|
case action
|
|
when :index, :search then %w(400)
|
|
when :show then %w(400 404)
|
|
when :create then %w(400 401 403 422)
|
|
else %w(401 403 404 422)
|
|
end
|
|
end
|
|
|
|
def document(description, type, many: false)
|
|
resource = { '$ref' => "#/components/schemas/#{schema_name(type)}" }
|
|
data = many ? { type: 'array', items: resource } : resource
|
|
{ description: description,
|
|
content: { 'application/vnd.api+json' => { schema: { type: 'object', properties: {
|
|
data: data, included: { type: 'array', items: { type: 'object' } },
|
|
links: { type: 'object', description: 'first, prev, next and last pages' }
|
|
} } } } }
|
|
end
|
|
|
|
# Changing a record says which one, as well as what it is.
|
|
def request_body(action, resource)
|
|
properties = { type: { type: 'string', enum: [resource[:type]] }, attributes: writable_attributes(resource),
|
|
relationships: writable_relationships(resource) }
|
|
properties[:id] = { type: 'string' } if action == :update
|
|
required = action == :update ? %w(type id) : %w(type)
|
|
{ required: true, content: { 'application/vnd.api+json' => { schema: { type: 'object', properties: {
|
|
data: { type: 'object', required: required, properties: properties.compact }
|
|
} } } } }
|
|
end
|
|
|
|
def schemas
|
|
@resources.values.to_h do |resource|
|
|
[schema_name(resource[:type]), {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' }, type: { type: 'string', enum: [resource[:type]] },
|
|
attributes: readable_attributes(resource), relationships: relationships(resource)
|
|
}
|
|
}]
|
|
end
|
|
end
|
|
|
|
def schema_name(type)
|
|
type.singularize.camelize
|
|
end
|
|
|
|
def readable_attributes(resource)
|
|
attributes_schema(resource, nullable: true) { |attribute| attribute[:readable] }
|
|
end
|
|
|
|
def writable_attributes(resource)
|
|
attributes_schema(resource) { |attribute| attribute[:readable] && attribute[:writable] }
|
|
end
|
|
|
|
# The `id` is the resource's own, not one of its attributes. Anything can be empty in a response, but not in a request.
|
|
def attributes_schema(resource, nullable: false)
|
|
chosen = resource[:attributes].select { |name, attribute| name != :id && yield(attribute) }
|
|
{ type: 'object', properties: chosen.to_h { |name, attribute| [dashed(name), attribute_schema(attribute, nullable: nullable)] } }
|
|
end
|
|
|
|
# A belongs_to is written through its foreign key, which the API shows only as a relationship.
|
|
def writable_relationships(resource)
|
|
writable = resource[:relationships].select do |name, relationship|
|
|
relationship[:type] == 'belongs_to' && resource.dig(:attributes, :"#{name}_id", :writable)
|
|
end
|
|
return if writable.empty?
|
|
|
|
{ type: 'object', properties: writable.to_h { |name, relationship| [name.to_s, linkage(relationship)] } }
|
|
end
|
|
|
|
def relationships(resource)
|
|
{ type: 'object', description: 'Each has a `links.related` URL to follow; `data` is sent for the ones asked for with `include`.',
|
|
properties: resource[:relationships].keys.to_h { |name| [name.to_s, { type: 'object' }] } }
|
|
end
|
|
|
|
def linkage(relationship)
|
|
target = @resources[relationship[:resource]][:type]
|
|
{ type: 'object', properties: { data: { type: 'object', properties: {
|
|
type: { type: 'string', enum: [target] }, id: { type: 'string' }
|
|
} } } }
|
|
end
|
|
|
|
def attribute_schema(attribute, nullable:)
|
|
scalar(attribute[:type]).merge(description: attribute[:description], nullable: (true if nullable)).compact
|
|
end
|
|
|
|
def scalar(type)
|
|
SCALARS.fetch(type.to_s) do
|
|
if type.to_s.start_with?('array_of')
|
|
{ type: 'array',
|
|
items: SCALARS.fetch(type.to_s.delete_prefix('array_of_').singularize,
|
|
{ type: 'string' }) }
|
|
else
|
|
{ type: 'string' }
|
|
end
|
|
end
|
|
end
|
|
|
|
def dashed(name)
|
|
name.to_s.dasherize
|
|
end
|
|
end
|