Skip to content

Instantly share code, notes, and snippets.

@ekampp
Last active July 28, 2023 17:24
Show Gist options
  • Save ekampp/cd1ca9e19bd65686f0ea1ba2c9bbd804 to your computer and use it in GitHub Desktop.
Save ekampp/cd1ca9e19bd65686f0ea1ba2c9bbd804 to your computer and use it in GitHub Desktop.
# 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