diff --git a/.rubocop.yml b/.rubocop.yml index ff792b14..6f862958 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,6 @@ Naming/HeredocDelimiterNaming: Lint/NestedMethodDefinition: Enabled: false + +Performance/HashEachMethods: + Enabled: false diff --git a/lib/assets/stylesheets/modules/_technical-documentation.scss b/lib/assets/stylesheets/modules/_technical-documentation.scss index 205e4b02..cdb0c570 100644 --- a/lib/assets/stylesheets/modules/_technical-documentation.scss +++ b/lib/assets/stylesheets/modules/_technical-documentation.scss @@ -3,11 +3,11 @@ @mixin heading-offset($tabletTopMargin) { // Scale margins with font size on mobile (16/19ths) $mobileTopMargin: ceil($tabletTopMargin * (16 / 19)); - + // Offset headings down on mobile so that linking to anchors they appear after // the sticky 'table of contents' element $stickyTocOffset: 20px + $gutter-half + 10px + 1px; - + // Pad the heading so that when linking to an anchor there is at most a // $gutter-half (mobile) or $gutter (tablet and above) sized gap between the // top of the viewport and the heading. @@ -25,9 +25,9 @@ display: block; margin: 0 $gutter-half 10px; max-width: 40em; - + line-height: 1.4; - + color: $text-colour; @include media(tablet) { @@ -45,14 +45,14 @@ @include bold-48; @include heading-offset($gutter * 2); border-top: 5px solid $text-colour; - + &:first-of-type { @include heading-offset($gutter); border-top: none; } } - h2 { + h2 { @include bold-36; @include heading-offset($gutter * 1.5); } @@ -125,7 +125,7 @@ ol + p, ul + p, .table-container + p { margin-top: ceil($gutter * (16 / 19)); - + @include media(tablet) { margin-top: $gutter; } @@ -144,6 +144,11 @@ overflow: auto; position: relative; border: 1px solid $code-02; + // Restrict the width of pre tags, as they have a tendency grow larger than + // the viewport when placed within table cells. + // @todo: Use table-layout: fixed, and remove the max-width definition from + // .technical-documentation so tables can fill the viewport. + max-width: 40em; } pre code { @@ -156,11 +161,11 @@ background: $code-01; padding: 3px 5px; border-radius: 1px; - + font-family: monaco, Consolas, "Lucida Console", monospace; font-size: 15px; color: $code-0E; - + @include media(tablet) { font-size: 16px; } @@ -191,11 +196,11 @@ display: block; max-width: 100%; overflow-x: auto; - + margin-top: $gutter-half; } - table { + table { width: 100%; border-collapse: collapse; diff --git a/lib/govuk_tech_docs.rb b/lib/govuk_tech_docs.rb index 79474236..41dfc6de 100644 --- a/lib/govuk_tech_docs.rb +++ b/lib/govuk_tech_docs.rb @@ -20,7 +20,7 @@ require 'govuk_tech_docs/tech_docs_html_renderer' require 'govuk_tech_docs/unique_identifier_extension' require 'govuk_tech_docs/unique_identifier_generator' -require 'govuk_tech_docs/api_reference/api_reference' +require 'govuk_tech_docs/api_reference/api_reference_extension' module GovukTechDocs # Configure the tech docs template diff --git a/lib/govuk_tech_docs/api_reference/api_reference.rb b/lib/govuk_tech_docs/api_reference/api_reference.rb deleted file mode 100644 index 410c2fd4..00000000 --- a/lib/govuk_tech_docs/api_reference/api_reference.rb +++ /dev/null @@ -1,173 +0,0 @@ -require 'erb' -require 'openapi3_parser' -require 'uri' -require 'pry' - -module GovukTechDocs - class ApiReference < Middleman::Extension - expose_to_application api: :api - - def initialize(app, options_hash = {}, &block) - super - - @app = app - @config = @app.config[:tech_docs] - @api_parser = false - - # If no api path then just return. - if @config['api_path'].to_s.empty? - return - end - - # Is the api_path a url or path? - if uri?(@config['api_path']) - @api_parser = true - @document = Openapi3Parser.load_url(@config['api_path']) - elsif File.exist?(@config['api_path']) - # Load api file and set existence flag. - @api_parser = true - @document = Openapi3Parser.load_file(@config['api_path']) - else - raise 'Unable to load api path from tech-docs.yml' - end - - # Load template files - @render_api_full = get_renderer('api_reference_full.html.erb') - @render_path = get_renderer('path.html.erb') - @render_schema = get_renderer('schema.html.erb') - end - - def uri?(string) - uri = URI.parse(string) - %w(http https).include?(uri.scheme) - rescue URI::BadURIError - false - rescue URI::InvalidURIError - false - end - - def api(text) - if @api_parser == true - - keywords = { - 'api>' => 'default', - 'api_schema>' => 'schema' - } - - regexp = keywords.map { |k, _| Regexp.escape(k) }.join('|') - - md = text.match(/^

(#{regexp})/) - if md - key = md.captures[0] - type = keywords[key] - - text.gsub!(/#{ Regexp.escape(key) }\s+?/, '') - - # Strip paragraph tags from text - text = text.gsub(/<\/?[^>]*>/, '') - text = text.strip - - if type == 'default' - api_path_render(text) - else - api_schema_render(text) - end - - else - return text - end - else - text - end - end - - def api_path_render(text) - if text == 'api>' - api_full - else - # Call api parser on text - path = @document.paths[text] - output = @render_path.result(binding) - output - end - end - - def api_schema_render(text) - schemas = '' - schemas_data = @document.components.schemas - schemas_data.each do |schema_data| - if schema_data[0] == text - title = schema_data[0] - schema = schema_data[1] - output = @render_schema.result(binding) - return output - end - end - end - - def api_full - info = api_info - server = api_server - - paths = '' - paths_data = @document.paths - paths_data.each do |path_data| - # For some reason paths.each returns an array of arrays [title, object] - # instead of an array of objects - text = path_data[0] - path = path_data[1] - paths += @render_path.result(binding) - end - schemas = '' - schemas_data = @document.components.schemas - schemas_data.each do |schema_data| - title = schema_data[0] - schema = schema_data[1] - schemas += @render_schema.result(binding) - end - @render_api_full.result(binding) - end - - def render_markdown(text) - if text - Tilt['markdown'].new(context: @app) { text }.render - end - end - - private - - def get_renderer(file) - template_path = File.join(File.dirname(__FILE__), 'templates/' + file) - template = File.open(template_path, 'r').read - ERB.new(template) - end - - def get_operations(path) - operations = {} - operations['get'] = path.get if defined? path.get - operations['put'] = path.put if defined? path.put - operations['post'] = path.post if defined? path.post - operations['delete'] = path.delete if defined? path.delete - operations['patch'] = path.patch if defined? path.patch - operations - end - - def api_info - @document.info - end - - def api_server - @document.servers[0] - end - - def get_schema_name(text) - unless text.is_a?(String) - return nil - end - # Schema dictates that it's always components['schemas'] - text.gsub(/#\/components\/schemas\//, '') - end - end -end - -::Middleman::Extensions.register(:api_reference, GovukTechDocs::ApiReference) diff --git a/lib/govuk_tech_docs/api_reference/api_reference_extension.rb b/lib/govuk_tech_docs/api_reference/api_reference_extension.rb new file mode 100644 index 00000000..8e457241 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/api_reference_extension.rb @@ -0,0 +1,100 @@ +require 'erb' +require 'openapi3_parser' +require 'uri' +require 'pry' +require 'govuk_tech_docs/api_reference/api_reference_renderer' + +module GovukTechDocs + module ApiReference + class Extension < Middleman::Extension + expose_to_application api: :api + + def initialize(app, options_hash = {}, &block) + super + + @app = app + @config = @app.config[:tech_docs] + + # If no api path then just return. + if @config['api_path'].to_s.empty? + raise 'No api path defined in tech-docs.yml' + end + + # Is the api_path a url or path? + if uri?(@config['api_path']) + @api_parser = true + @document = Openapi3Parser.load_url(@config['api_path']) + elsif File.exist?(@config['api_path']) + # Load api file and set existence flag. + @api_parser = true + @document = Openapi3Parser.load_file(@config['api_path']) + else + @api_parser = false + raise 'Unable to load api path from tech-docs.yml' + end + @render = Renderer.new(@app, @document) + end + + def uri?(string) + uri = URI.parse(string) + %w(http https).include?(uri.scheme) + rescue URI::BadURIError + false + rescue URI::InvalidURIError + false + end + + def api(text) + if @api_parser == true + + keywords = { + 'api>' => 'default', + 'api_schema>' => 'schema' + } + + regexp = keywords.map { |k, _| Regexp.escape(k) }.join('|') + + md = text.match(/^

(#{regexp})/) + if md + key = md.captures[0] + type = keywords[key] + + text.gsub!(/#{ Regexp.escape(key) }\s+?/, '') + + # Strip paragraph tags from text + text = text.gsub(/<\/?[^>]*>/, '') + text = text.strip + + if text == 'api>' + @render.api_full(api_info, api_server) + elsif type == 'default' + output = @render.path(text) + # Render any schemas referenced in the above path + output += @render.schemas_from_path(text) + output + else + @render.schema(text) + end + + else + return text + end + else + text + end + end + + private + + def api_info + @document.info + end + + def api_server + @document.servers[0] + end + end + end +end + +::Middleman::Extensions.register(:api_reference, GovukTechDocs::ApiReference::Extension) diff --git a/lib/govuk_tech_docs/api_reference/api_reference_renderer.rb b/lib/govuk_tech_docs/api_reference/api_reference_renderer.rb new file mode 100644 index 00000000..50dea183 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/api_reference_renderer.rb @@ -0,0 +1,279 @@ +require 'erb' +require 'json' + +module GovukTechDocs + module ApiReference + class Renderer + def initialize(app, document) + @app = app + @document = document + + # Load template files + @template_api_full = get_renderer('api_reference_full.html.erb') + @template_path = get_renderer('path.html.erb') + @template_schema = get_renderer('schema.html.erb') + @template_operation = get_renderer('operation.html.erb') + @template_parameters = get_renderer('parameters.html.erb') + @template_responses = get_renderer('responses.html.erb') + end + + def api_full(info, server) + paths = '' + paths_data = @document.paths + paths_data.each do |path_data| + # For some reason paths.each returns an array of arrays [title, object] + # instead of an array of objects + text = path_data[0] + paths += path(text) + end + schemas = '' + schemas_data = @document.components.schemas + schemas_data.each do |schema_data| + text = schema_data[0] + schemas += schema(text) + end + @template_api_full.result(binding) + end + + def path(text) + path = @document.paths[text] + id = text.parameterize + operations = operations(path, id) + @template_path.result(binding) + end + + def schema(text) + schemas = '' + schemas_data = @document.components.schemas + schemas_data.each do |schema_data| + all_of = schema_data[1]["allOf"] + properties = [] + if !all_of.blank? + all_of.each do |schema_nested| + schema_nested.properties.each do |property| + properties.push property + end + end + end + + schema_data[1].properties.each do |property| + properties.push property + end + + if schema_data[0] == text + title = schema_data[0] + schema = schema_data[1] + return @template_schema.result(binding) + end + end + end + + def schemas_from_path(text) + path = @document.paths[text] + operations = get_operations(path) + # Get all referenced schemas + schemas = [] + operations.compact.each_value do |operation| + responses = operation.responses + responses.each do |_rkey, response| + if response.content['application/json'] + schema = response.content['application/json'].schema + schema_name = get_schema_name(schema.node_context.source_location.to_s) + if !schema_name.nil? + schemas.push schema_name + end + schemas.concat(schemas_from_schema(schema)) + end + end + end + # Render all referenced schemas + output = '' + schemas.uniq.each do |schema_name| + output += schema(schema_name) + end + if !output.empty? + output.prepend('

Schemas

') + end + output + end + + def schemas_from_schema(schema) + schemas = [] + properties = [] + schema.properties.each do |property| + properties.push property[1] + end + if schema.type == 'array' + properties.push schema.items + end + all_of = schema["allOf"] + if !all_of.blank? + all_of.each do |schema_nested| + schema_nested.properties.each do |property| + properties.push property[1] + end + end + end + properties.each do |property| + # Must be a schema be referenced by another schema + # And not a property of a schema + if property.node_context.referenced_by.to_s.include?('#/components/schemas') && + !property.node_context.source_location.to_s.include?('/properties/') + schema_name = get_schema_name(property.node_context.source_location.to_s) + end + if !schema_name.nil? + schemas.push schema_name + end + # Check sub-properties for references + schemas.concat(schemas_from_schema(property)) + end + schemas + end + + def operations(path, path_id) + output = '' + operations = get_operations(path) + operations.compact.each do |key, operation| + id = "#{path_id}-#{key.parameterize}" + parameters = parameters(operation, id) + responses = responses(operation, id) + output += @template_operation.result(binding) + end + output + end + + def parameters(operation, operation_id) + parameters = operation.parameters + id = "#{operation_id}-parameters" + output = @template_parameters.result(binding) + output + end + + def responses(operation, operation_id) + responses = operation.responses + id = "#{operation_id}-responses" + output = @template_responses.result(binding) + output + end + + def markdown(text) + if text + Tilt['markdown'].new(context: @app) { text }.render + end + end + + def json_output(schema) + properties = schema_properties(schema) + JSON.pretty_generate(properties) + end + + def json_prettyprint(data) + JSON.pretty_generate(data) + end + + def schema_properties(schema_data) + properties = Hash.new + if defined? schema_data.properties + schema_data.properties.each do |key, property| + properties[key] = property + end + end + properties.merge! get_all_of_hash(schema_data) + properties_hash = Hash.new + properties.each do |pkey, property| + if property.type == 'object' + properties_hash[pkey] = Hash.new + items = property.items + if !items.blank? + properties_hash[pkey] = schema_properties(items) + end + if !property.properties.blank? + properties_hash[pkey] = schema_properties(property) + end + elsif property.type == 'array' + properties_hash[pkey] = Array.new + items = property.items + if !items.blank? + properties_hash[pkey].push schema_properties(items) + end + else + properties_hash[pkey] = !property.example.nil? ? property.example : property.type + end + end + + properties_hash + end + + private + + def get_all_of_array(schema) + properties = Array.new + if schema.is_a?(Array) + schema = schema[1] + end + if schema["allOf"] + all_of = schema["allOf"] + end + if !all_of.blank? + all_of.each do |schema_nested| + schema_nested.properties.each do |property| + if property.is_a?(Array) + property = property[1] + end + properties.push property + end + end + end + properties + end + + def get_all_of_hash(schema) + properties = Hash.new + if schema["allOf"] + all_of = schema["allOf"] + end + if !all_of.blank? + all_of.each do |schema_nested| + schema_nested.properties.each do |key, property| + properties[key] = property + end + end + end + properties + end + + def get_renderer(file) + template_path = File.join(File.dirname(__FILE__), 'templates/' + file) + template = File.open(template_path, 'r').read + ERB.new(template) + end + + def get_operations(path) + operations = {} + operations['get'] = path.get if defined? path.get + operations['put'] = path.put if defined? path.put + operations['post'] = path.post if defined? path.post + operations['delete'] = path.delete if defined? path.delete + operations['patch'] = path.patch if defined? path.patch + operations + end + + def get_schema_name(text) + unless text.is_a?(String) + return nil + end + # Schema dictates that it's always components['schemas'] + text.gsub(/#\/components\/schemas\//, '') + end + + def get_schema_link(schema) + schema_name = get_schema_name schema.node_context.source_location.to_s + if !schema_name.nil? + id = "schema-#{schema_name.parameterize}" + output = "#{schema_name}" + output + end + end + end + end +end diff --git a/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb b/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb index b56aeb59..292fda40 100644 --- a/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb +++ b/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb @@ -1,5 +1,5 @@

<%= info.title %> v<%= info.version %>

-<%= render_markdown(info.description) %> +<%= markdown(info.description) %> <% if server %>

Base URL

<%= server.url %>

diff --git a/lib/govuk_tech_docs/api_reference/templates/operation.html.erb b/lib/govuk_tech_docs/api_reference/templates/operation.html.erb new file mode 100644 index 00000000..3a8a447b --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/operation.html.erb @@ -0,0 +1,11 @@ +

<%= key %>

+<% if operation.summary %> +

<%= operation.summary %>

+<% end %> +<% if operation.description %> +

<%= markdown(operation.description) %>

+<% end %> + +<%= parameters %> + +<%= responses %> diff --git a/lib/govuk_tech_docs/api_reference/templates/parameters.html.erb b/lib/govuk_tech_docs/api_reference/templates/parameters.html.erb new file mode 100644 index 00000000..16372550 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/parameters.html.erb @@ -0,0 +1,28 @@ +<% if parameters.any? %> +

Parameters

+ + + + + +<% parameters.each do |parameter| %> + + + + + + + +<% end %> + +
ParameterInTypeRequiredDescription
<%= parameter.name %><%= parameter.in %><%= parameter.schema.type %><%= parameter.required? %><%= markdown(parameter.description) %> +<% if parameter.schema.enum %> +

Available items:

+
    +<% parameter.schema.enum.each do |item| %> +
  • <%= item %>
  • +<% end %> +
+<% end %> +
+<% end %> diff --git a/lib/govuk_tech_docs/api_reference/templates/path.html.erb b/lib/govuk_tech_docs/api_reference/templates/path.html.erb index 58cc9f84..72db5e1a 100644 --- a/lib/govuk_tech_docs/api_reference/templates/path.html.erb +++ b/lib/govuk_tech_docs/api_reference/templates/path.html.erb @@ -1,58 +1,4 @@ -
-<% operations = get_operations(path) %> -<% operations.compact.each do |key,operation| %> <% if text %> -<% id = key + text; %> -

<%= key.upcase %> <%= text %>

+

<%= text %>

<% end %> -<% if operation.summary %> -

<%= operation.summary %>

-<% end %> -<% if operation.description %> -

<%= render_markdown(operation.description) %>

-<% end %> -<% if operation.parameters.any? %> -

Parameters

- - - - - -<% operation.parameters.each do |parameter| %> - - - - - - - -<% end %> - -
ParameterInTypeRequiredDescription
<%= parameter.name %><%= parameter.in %><%= parameter.schema.type %><%= parameter.required? %><%= render_markdown(parameter.description) %>
-<% end %> -<% if operation.responses.any? %> -

Responses

- - - - - -<% operation.responses.each do |key,response| %> - - - - - -<% end %> - -
StatusDescriptionSchema
<%= key %><%= response.description %> -<% if response.content['application/json'] - schema_name = get_schema_name(response.content['application/json'].schema.node_context.source_location.to_s) - if !schema_name.nil? %> -<%= schema_name %> -<% end %> -<% end %> -
-<% end %> -<% end %> -
+<%= operations %> diff --git a/lib/govuk_tech_docs/api_reference/templates/responses.html.erb b/lib/govuk_tech_docs/api_reference/templates/responses.html.erb new file mode 100644 index 00000000..5cdce831 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/responses.html.erb @@ -0,0 +1,33 @@ +<% if responses.any? %> +

Responses

+ + + + + +<% responses.each do |key,response| %> + + + + + +<% end %> + +
StatusDescriptionSchema
<%= key %> +<%= markdown(response.description) %> +<% if response.content['application/json'] +if response.content['application/json']["example"] + request_body = json_prettyprint(response.content['application/json']["example"]) +else + request_body = json_output(response.content['application/json'].schema) +end +end %> +<% if !request_body.blank? %> +
<%= request_body %>
+<% end %> +
+<%= if response.content['application/json'] + get_schema_link(response.content['application/json'].schema) +end %> +
+<% end %> diff --git a/lib/govuk_tech_docs/api_reference/templates/schema.html.erb b/lib/govuk_tech_docs/api_reference/templates/schema.html.erb index 8258be0d..4e593228 100644 --- a/lib/govuk_tech_docs/api_reference/templates/schema.html.erb +++ b/lib/govuk_tech_docs/api_reference/templates/schema.html.erb @@ -1,17 +1,27 @@

<%= title %>

-<%= render_markdown(schema.description) %> -<% if schema.properties.any? %> +<%= markdown(schema.description) %> +<% if properties.any? %> - + -<% schema.properties.each do |property| %> +<% properties.each do |property| %> - + + <% end %> diff --git a/spec/features/integration_spec.rb b/spec/features/integration_spec.rb index 2ab6723f..2e02cee3 100644 --- a/spec/features/integration_spec.rb +++ b/spec/features/integration_spec.rb @@ -136,7 +136,9 @@ def then_there_is_correct_api_info_content def then_there_is_correct_api_path_content # Path title - expect(page).to have_css('h2#get-pets', text: 'GET /pets') + expect(page).to have_css('h2#pets', text: '/pets') + # Operation title + expect(page).to have_css('h3#pets-get', text: 'get') # Path parameters expect(page).to have_css('table', text: /\b(How many items to return at one time)\b/) # Link to schema
NameTypeRequiredDescription
NameTypeRequiredDescriptionSchema
<%= property[0] %> <%= property[1].type %> <%= property[1].required.present? %><%= render_markdown(property[1].description) %><%= markdown(property[1].description) %> + <%= + schema = property[1] + # If property is an array, check the items property for a reference. + if property[1].type == 'array' + schema = property[1]['items'] + end + # Only print a link if it's a referenced object. + get_schema_link(schema) if schema.node_context.referenced_by.to_s.include? '#/components/schemas' and !schema.node_context.source_location.to_s.include? '/properties/' %> +