diff --git a/docs/configuration.md b/docs/configuration.md index 9c549313..55ba1f56 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,3 +148,11 @@ default: `true` ```yaml show_govuk_logo: true ``` + +## `api_path` + +Define a path to an Open API V3 spec file. This can be a relative file path or a URI to a raw file. + +```yaml +api_path: ./source/pets.yml +``` diff --git a/example/config/tech-docs.yml b/example/config/tech-docs.yml index f74edf82..bc391520 100644 --- a/example/config/tech-docs.yml +++ b/example/config/tech-docs.yml @@ -41,3 +41,5 @@ github_repo: alphagov/example-repo redirects: /something/old.html: /index.html + +api_path: source/pets.yml diff --git a/example/source/api-path.html.md b/example/source/api-path.html.md new file mode 100644 index 00000000..b6bb2d54 --- /dev/null +++ b/example/source/api-path.html.md @@ -0,0 +1,7 @@ +--- +title: API /Pets +--- + +# API /Pets + +api> /pets diff --git a/example/source/api-reference.html.md b/example/source/api-reference.html.md new file mode 100644 index 00000000..e1c678e5 --- /dev/null +++ b/example/source/api-reference.html.md @@ -0,0 +1,5 @@ +--- +title: Example API Petstore +--- + +api> diff --git a/example/source/pets.yml b/example/source/pets.yml new file mode 100644 index 00000000..f8f5f0ab --- /dev/null +++ b/example/source/pets.yml @@ -0,0 +1,106 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Swagger Petstore + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/govuk_tech_docs.gemspec b/govuk_tech_docs.gemspec index 618297c5..7d4c5ba9 100644 --- a/govuk_tech_docs.gemspec +++ b/govuk_tech_docs.gemspec @@ -32,6 +32,8 @@ Gem::Specification.new do |spec| spec.add_dependency "middleman-search" spec.add_dependency "nokogiri" spec.add_dependency "redcarpet", "~> 3.3.2" + spec.add_dependency "openapi3_parser" + spec.add_dependency "pry" spec.add_development_dependency "bundler", "~> 1.15" diff --git a/lib/govuk_tech_docs.rb b/lib/govuk_tech_docs.rb index c8527cb8..05bae140 100644 --- a/lib/govuk_tech_docs.rb +++ b/lib/govuk_tech_docs.rb @@ -20,6 +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' module GovukTechDocs # Configure the tech docs template @@ -37,7 +38,9 @@ def self.configure(context, options = {}) context.set :markdown_engine, :redcarpet context.set :markdown, renderer: TechDocsHTMLRenderer.new( - with_toc_data: true + with_toc_data: true, + api: true, + context: context ), fenced_code_blocks: true, tables: true, @@ -56,6 +59,8 @@ def self.configure(context, options = {}) context.config[:tech_docs] = YAML.load_file('config/tech-docs.yml').with_indifferent_access context.activate :unique_identifier + context.activate :api_reference + context.helpers do include GovukTechDocs::TableOfContents::Helpers include GovukTechDocs::ContributionBanner diff --git a/lib/govuk_tech_docs/api_reference/api_reference.rb b/lib/govuk_tech_docs/api_reference/api_reference.rb new file mode 100644 index 00000000..6199f089 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/api_reference.rb @@ -0,0 +1,173 @@ +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] + + # 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 + + # 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/templates/api_reference_full.html.erb b/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb new file mode 100644 index 00000000..b56aeb59 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/api_reference_full.html.erb @@ -0,0 +1,9 @@ +

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

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

Base URL

+

<%= server.url %>

+<% end %> +<%= paths %> +

Schemas

+<%= schemas %> diff --git a/lib/govuk_tech_docs/api_reference/templates/path.html.erb b/lib/govuk_tech_docs/api_reference/templates/path.html.erb new file mode 100644 index 00000000..58cc9f84 --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/path.html.erb @@ -0,0 +1,58 @@ +
+<% operations = get_operations(path) %> +<% operations.compact.each do |key,operation| %> +<% if text %> +<% id = key + text; %> +

<%= key.upcase %> <%= 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 %> +
diff --git a/lib/govuk_tech_docs/api_reference/templates/schema.html.erb b/lib/govuk_tech_docs/api_reference/templates/schema.html.erb new file mode 100644 index 00000000..8258be0d --- /dev/null +++ b/lib/govuk_tech_docs/api_reference/templates/schema.html.erb @@ -0,0 +1,19 @@ +

<%= title %>

+<%= render_markdown(schema.description) %> +<% if schema.properties.any? %> + + + + + +<% schema.properties.each do |property| %> + + + + + + +<% end %> + +
NameTypeRequiredDescription
<%= property[0] %><%= property[1].type %><%= property[1].required.present? %><%= render_markdown(property[1].description) %>
+<% end %> diff --git a/lib/govuk_tech_docs/tech_docs_html_renderer.rb b/lib/govuk_tech_docs/tech_docs_html_renderer.rb index 1a3a82d8..43931c06 100644 --- a/lib/govuk_tech_docs/tech_docs_html_renderer.rb +++ b/lib/govuk_tech_docs/tech_docs_html_renderer.rb @@ -4,6 +4,16 @@ module GovukTechDocs class TechDocsHTMLRenderer < Middleman::Renderers::MiddlemanRedcarpetHTML include Redcarpet::Render::SmartyPants + def initialize(options = {}) + @local_options = options.dup + @app = @local_options[:context].app + super + end + + def paragraph(text) + @app.api("

#{text.strip}

\n") + end + def header(text, level) anchor = UniqueIdentifierGenerator.instance.create(text, level) %(#{text}) diff --git a/lib/source/layouts/_header.erb b/lib/source/layouts/_header.erb index f20d8548..c7716db7 100644 --- a/lib/source/layouts/_header.erb +++ b/lib/source/layouts/_header.erb @@ -33,9 +33,7 @@ diff --git a/spec/features/integration_spec.rb b/spec/features/integration_spec.rb index 94eabd67..2ab6723f 100644 --- a/spec/features/integration_spec.rb +++ b/spec/features/integration_spec.rb @@ -30,6 +30,14 @@ when_i_view_the_search_index then_there_is_indexed_content + + when_i_view_an_api_reference_page + then_there_is_correct_api_info_content + then_there_is_correct_api_path_content + then_there_is_correct_api_schema_content + + when_i_view_an_single_path_api_reference_page + then_there_is_correct_api_path_content end def when_the_site_is_created @@ -63,7 +71,7 @@ def then_there_is_a_source_footer end def then_the_page_highlighted_in_the_navigation_is(link_label) - expect(page.find('li.active a').text).to eq(link_label) + page.find('#navigation li.active a', text: link_label) end def then_there_are_navigation_headings_from_other_pages @@ -105,7 +113,40 @@ def when_i_view_the_search_index visit '/search.json' end + def when_i_view_an_api_reference_page + visit '/api-reference.html' + end + def then_there_is_indexed_content expect(page).to have_content 'troubleshoot' end + + def when_i_view_an_single_path_api_reference_page + visit '/api-path.html' + end + + def then_there_is_correct_api_info_content + # Title + expect(page).to have_css('h1', text: 'Swagger Petstore v1.0.0') + # Description + expect(page).to have_css('p', text: 'A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification') + # Base URL + expect(page).to have_css('strong', text: 'http://petstore.swagger.io/v1') + end + + def then_there_is_correct_api_path_content + # Path title + expect(page).to have_css('h2#get-pets', text: 'GET /pets') + # Path parameters + expect(page).to have_css('table', text: /\b(How many items to return at one time)\b/) + # Link to schema + expect(page).to have_css('table a[href="#schema-error"]') + end + + def then_there_is_correct_api_schema_content + # Schema title + expect(page).to have_css('h3#schema-pet', text: 'Pet') + # Schema parameters + expect(page).to have_css('table', text: /\b(tag )\b/) + end end