Last active
July 28, 2023 17:24
-
-
Save ekampp/cd1ca9e19bd65686f0ea1ba2c9bbd804 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
require "hash" | |
def pretty_name(string) | |
return "election" if string.to_s == "genesis_item" | |
string.to_s.gsub("_configuration_item", "") | |
end | |
class Openapi # rubocop:disable Metrics/ClassLength | |
SUMMARY_MAP = { | |
"index" => "List", | |
"create" => "Append", | |
}.freeze | |
HEADERS = { | |
"Polling-URL": { | |
schema: { | |
type: "string", | |
description: <<~STR.squish, | |
Contains the [polling location](#section/board/polling) to retrieve reciprocal | |
items appended to the board. | |
STR | |
example: "https://myvoice.technology/items/abcdef123", | |
}, | |
}, | |
"Content-Type": { | |
schema: { | |
type: "string", | |
example: "application/json", | |
}, | |
}, | |
HTTP_ACCEPT: { | |
schema: { | |
type: "string", | |
example: "application/json", | |
}, | |
}, | |
"Authorization-Address": { | |
schema: { | |
type: "string", | |
example: "abcdef123", | |
description: "Authorization Item address", | |
}, | |
}, | |
"Total-Count": { | |
schema: { | |
type: "integer", | |
example: "67", | |
description: "Number of items total in the collection", | |
}, | |
}, | |
HTTP_PUBLIC_KEY: { | |
schema: { | |
type: "string", | |
example: "abcdef123", | |
description: "Hex representation of a pre-shared public key", | |
}, | |
}, | |
Location: { | |
schema: { | |
type: "string", | |
example: "https://google.com", | |
}, | |
}, | |
"API-Version": { | |
schema: { | |
type: "string", | |
example: "1.2.3", | |
description: "Semantic version of the API to use if multiple are available.", | |
}, | |
}, | |
"Content-Disposition": { | |
schema: { | |
type: "string", | |
}, | |
}, | |
ETag: { | |
schema: { | |
type: "string", | |
example: "w/33a64df551425fcc55e4d42a148795d9f25f89d4", | |
}, | |
}, | |
"If-None-Match": { | |
schema: { | |
type: "string", | |
}, | |
}, | |
}.freeze | |
# rubocop:disable Metrics/AbcSize | |
# rubocop:disable Metrics/MethodLength | |
def self.additional_properties(key, object) | |
{}.tap do |h| | |
file = Rails.public_path.join("docs/#{key}/properties.json") | |
if file.exist? | |
additional_object = JSON.parse(file.read).deep_symbolize_keys | |
additional_object[:properties].each do |k, v| | |
next unless object.fetch(:properties, {}).fetch(k, nil) | |
h.deep_merge! properties: { k => v } | |
end | |
end | |
file = Rails.public_path.join("docs/item/properties.json") | |
if file.exist? | |
additional_object = JSON.parse(file.read).deep_symbolize_keys | |
additional_object[:properties].each do |k, v| | |
next unless object.fetch(:properties, {}).fetch(k, nil) | |
h.deep_merge! properties: { k => v } | |
end | |
end | |
end | |
end | |
# rubocop:enable Metrics/AbcSize | |
# rubocop:enable Metrics/MethodLength | |
class SchemaGenerator | |
attr_reader :schema | |
def initialize(json) | |
@json = json.deep_stringify_keys | |
@schema = {} | |
return if json.blank? | |
build_schema | |
end | |
private | |
attr_reader :json | |
def build_schema | |
case json | |
when Hash then hash_schema | |
when Array then array_schema | |
else raise ArgumentError, "unable to build schema from #{json.class}" | |
end | |
end | |
def array_schema | |
schema[:type] = "array" | |
schema[:items] = json.collect { |item| self.class.new(item).schema } | |
end | |
def hash_schema | |
schema[:type] = "object" | |
schema[:properties] = {} | |
walk_hash_schema | |
end | |
def walk_hash_schema | |
json.each do |k, v| | |
schema[:properties][k] = {} | |
case v | |
when String then partial_string_schema(k, v) | |
when Integer then partial_integer_schema(k, v) | |
when Hash then partial_object_schema(k, v) | |
when Array then partial_array_schema(k, v) | |
when TrueClass, FalseClass then partial_boolean_schema(k, v) | |
end | |
end | |
end | |
def partial_string_schema(key, value) | |
schema[:properties][key][:type] = "string" | |
schema[:properties][key][:example] = value | |
end | |
def partial_boolean_schema(key, value) | |
schema[:properties][key][:type] = "boolean" | |
schema[:properties][key][:example] = value | |
end | |
def partial_integer_schema(key, value) | |
schema[:properties][key][:type] = "integer" | |
schema[:properties][key][:example] = value | |
end | |
def partial_object_schema(key, value) | |
schema[:properties][key][:type] = "object" | |
schema[:properties][key][:example] = value | |
end | |
def partial_array_schema(key, value) | |
schema[:properties][key][:type] = "array" | |
schema[:properties][key][:example] = value | |
end | |
end | |
class Example # rubocop:disable Metrics/ClassLength | |
def initialize(context:, example:, options:) | |
@example = example | |
@options = options | |
@request = context.request | |
@response = context.response | |
end | |
def summary | |
str = options.fetch(:summary, action).to_s | |
(Openapi::SUMMARY_MAP[str].presence || str).humanize.presence | |
end | |
def description | |
file = Rails.public_path.join("docs/#{pretty_name(resource_type || 'item')}/#{action}.md") | |
return file.read.chomp if file.exist? | |
options.fetch(:description, example.full_description).presence | |
end | |
def method | |
request.method.to_s.downcase | |
end | |
def status_code | |
response.status | |
end | |
def response_content_type | |
response.headers["Content-Type"].split(";").first | |
end | |
def response_description | |
options.fetch(:response_description, example.description).presence | |
end | |
def tags | |
options.fetch(:tags, []).map(&:to_s) | |
end | |
def schema | |
{}.tap do |path_object| | |
generate_method_metadata path_object | |
generate_method_request_body path_object if method != "get" | |
generate_method_request_headers path_object if method != "get" | |
generate_method_request_example path_object if method != "get" | |
generate_method_params path_object | |
generate_method_responses path_object | |
generate_method_security path_object | |
end | |
end | |
def path | |
if options[:route] | |
options[:route] | |
elsif options[:resolve_route] | |
request.path | |
else | |
route.path.spec.to_s.delete_suffix("(.:format)").to_sym | |
end | |
end | |
private | |
def generate_method_request_headers(path_object) # rubocop:disable Metrics/AbcSize | |
return if request.headers.blank? | |
headers = HEADERS.collect do |k, v| | |
name = k.to_s | |
name = name.gsub("HTTP_", "").split("_").map(&:humanize).join("-") if name.include? "HTTP_" | |
value = request.headers[k] | |
next if value.blank? | |
{ | |
in: "header", | |
name: name.to_s, | |
**v.merge(schema: { example: value }), | |
} | |
end | |
path_object.bury :parameters, path_object.fetch(:parameters, []).concat(headers.compact) | |
end | |
def generate_method_security(path_object) | |
return if options[:roles].blank? | |
path_object.bury :security, [options[:roles]] | |
end | |
def action | |
request.parameters.fetch :action, "" | |
end | |
# rubocop:disable Metrics/MethodLength | |
# rubocop:disable Metrics/AbcSize | |
def generate_method_responses(path_object) | |
if response.body.present? | |
object = json_to_schema(response.body).deep_symbolize_keys | |
object.merge! Openapi.additional_properties resource_type, object | |
path_object.bury \ | |
:responses, | |
status_code, | |
:content, | |
response_content_type, | |
:schema, | |
object | |
end | |
if response.headers.present? | |
HEADERS.each do |header, details| | |
next unless response.headers.key? header.to_s | |
path_object.bury \ | |
:responses, | |
status_code, | |
:headers, | |
header, | |
details | |
end | |
end | |
path_object.bury \ | |
:responses, | |
status_code, | |
:description, | |
response_description | |
if response.body.present? # rubocop:disable Style/GuardClause | |
path_object.bury \ | |
:responses, | |
status_code, | |
:content, | |
response_content_type, | |
:examples, | |
:default, | |
{ | |
summary: "Default", | |
value: JSON.parse(response.body), | |
} | |
end | |
end | |
# rubocop:enable Metrics/MethodLength | |
# rubocop:enable Metrics/AbcSize | |
def generate_method_metadata(path_object) | |
path_object.bury :tags, tags | |
path_object.bury :summary, summary if summary | |
path_object.bury :description, description | |
end | |
def json_to_schema(json) | |
SchemaGenerator.new(JSON.parse(json)).schema | |
end | |
def generate_method_request_body(path_object) | |
path_object.bury \ | |
:requestBody, | |
:content, | |
response_content_type, | |
:schema, | |
"$ref", | |
"#/components/schemas/#{pretty_name(resource_type)}" | |
end | |
def generate_method_request_example(path_object) | |
path_object.bury \ | |
:requestBody, | |
:content, | |
response_content_type, | |
:examples, | |
:default, | |
{ | |
summary: "Default", | |
value: JSON.parse(request.raw_post), | |
} | |
end | |
def generate_method_params(path_object) | |
prms = query_parameters.collect do |k, v| | |
{ | |
in: "query", | |
name: k.to_s, | |
content: v, | |
}.merge(options.fetch(:parameters, {}).fetch(k.to_sym)) | |
end | |
path_object.bury :parameters, path_object.fetch(:parameters, []).concat(prms) | |
end | |
def query_parameters | |
options.fetch(:parameters, {}).keys.map(&:to_s) | |
end | |
def resource_type # rubocop:disable Metrics/MethodLength | |
return options[:resource_type] if options[:resource_type].present? | |
JSON | |
.parse(response.body) | |
.fetch("data", {}) | |
.fetch("type", nil) | |
.presence | |
&.classify | |
&.demodulize | |
&.underscore | |
rescue StandardError | |
nil | |
end | |
attr_reader :context, :example, :options, :request, :response | |
def route(app: Rails.application, fix_path: true, req: request) # rubocop:disable Metrics/AbcSize | |
# Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41 | |
if fix_path && !req.script_name.empty? | |
req = req.dup | |
req.path_info = File.join(req.script_name, req.path_info) | |
end | |
app.routes.router.recognize(req) do |route| | |
route = find_rails_route(request: req, app: route.app.app, fix_path: false) unless route.path.anchored | |
return route | |
end | |
raise "No route matched for #{req.request_method} #{req.path_info}" | |
end | |
end | |
class << self # rubocop:disable Metrics/ClassLength | |
attr_reader :examples, :component_schemas, :tags | |
def prepare | |
@examples = [] | |
@tags = [] | |
@component_schemas = load_component_schemas.flatten.compact | |
end | |
def write | |
Rails.public_path.join("schema.yml").write(schema.deep_stringify_keys.deep_sort.to_yaml) | |
end | |
def record(context:, example:, options:) | |
options = {} unless options.is_a? Hash | |
examples << Example.new(context:, example:, options:) | |
end | |
private | |
def schema | |
{}.tap do |schema| | |
build_schema_base_info schema | |
build_component_schemas schema | |
build_security_schemas schema | |
build_schema_tags schema | |
build_schema_examples schema | |
build_servers schema | |
end | |
end | |
def build_servers(schema) | |
schema.bury :servers, [ | |
{ | |
url: Rails.configuration.uri.to_s, | |
}, | |
] | |
end | |
def build_security_schemas(schema) # rubocop:disable Metrics/MethodLength | |
schema.bury :security, [ | |
{ | |
public: ["no security"], | |
}, | |
] | |
schema.bury \ | |
:components, | |
:securitySchemes, | |
:public, | |
{ | |
type: "http", | |
scheme: "no security", | |
} | |
schema.bury \ | |
:components, | |
:securitySchemes, | |
:authorizationItem, | |
{ | |
type: "http", | |
scheme: "signed request", | |
} | |
schema.bury \ | |
:components, | |
:securitySchemes, | |
:presharedPublicKey, | |
{ | |
type: "http", | |
scheme: "signed request", | |
} | |
schema.bury \ | |
:components, | |
:securitySchemes, | |
:configuredService, | |
{ | |
type: "http", | |
scheme: "signed request", | |
} | |
end | |
def build_schema_base_info(schema) | |
schema.bury :openapi, "3.0.3" | |
schema.bury :info, :title, Rails.configuration.title | |
schema.bury :info, :version, Rails.configuration.version | |
schema.bury :info, :description, Rails.public_path.join("docs/description.md").read.chomp | |
end | |
def build_schema_tags(schema) | |
tags = component_schemas.collect do |cs| | |
file = Rails.public_path.join("docs/#{cs.keys.first}/description.md") | |
{ | |
name: cs.keys.first.classify, | |
}.tap do |h| | |
h[:description] = file.read.chomp if file.exist? | |
end | |
end | |
schema.bury :tags, tags | |
end | |
def build_component_schemas(schema) | |
component_schemas.each do |component_schema| | |
key = component_schema.keys.first | |
object = component_schema[key] | |
object.deep_merge! Openapi.additional_properties(key, object) | |
schema.bury :components, :schemas, key, object | |
end | |
end | |
def build_schema_examples(schema) | |
examples.each do |example| | |
schema.bury :paths, example.path, example.method, example.schema | |
end | |
end | |
def load_component_schemas # rubocop:disable Metrics/MethodLength | |
array = Dir[Rails.root.join("app/dtos/**/*.rb")].collect do |f| | |
file_name = File.basename(f.to_s, ".rb") | |
resource_name = file_name.gsub("_dto", "") | |
next if %w[application].include? resource_name | |
schema = file_name.classify.constantize.schema&.json_schema | |
schema.delete :$schema | |
{ pretty_name(resource_name) => schema } | |
end | |
array.push( | |
{ | |
"item" => { | |
type: "object", | |
}, | |
"election" => { | |
type: "object", | |
}, | |
} | |
) | |
end | |
end | |
end | |
RSpec.configure do |config| | |
config.before :suite do | |
Openapi.prepare | |
end | |
config.after :each, type: :request do |example| | |
options = example.metadata[:openapi] | |
Openapi.record(context: self, example:, options:) if options.present? | |
end | |
config.after :suite do | |
Openapi.write | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment