# 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 `. 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