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 @@ +
<%= server.url %>
+<% end %> +<%= paths %> +<%= operation.summary %>
+<% end %> +<% if operation.description %> +<%= render_markdown(operation.description) %>
+<% end %> +<% if operation.parameters.any? %> +Parameter | In | Type | Required | Description |
---|---|---|---|---|
<%= parameter.name %> | +<%= parameter.in %> | +<%= parameter.schema.type %> | +<%= parameter.required? %> | +<%= render_markdown(parameter.description) %> | +
Status | Description | Schema |
---|---|---|
<%= 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 %> + | +
Name | Type | Required | Description |
---|---|---|---|
<%= property[0] %> | +<%= property[1].type %> | +<%= property[1].required.present? %> | +<%= render_markdown(property[1].description) %> | +
#{text.strip}
\n") + end + def header(text, level) anchor = UniqueIdentifierGenerator.instance.create(text, level) %(