From a9ed0b588e0e22426b194cf69b1876a7e2c2cf62 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Fri, 30 Jun 2023 18:23:52 -0700 Subject: [PATCH] WIP: Use specs to write an openapi3 spec --- Gemfile | 4 +- Gemfile.lock | 8 +- app/controllers/api/base_controller.rb | 36 + app/controllers/api/v1/api.yaml | 686 ++++++++++++++++++++ app/controllers/api/v1/schema_controller.rb | 9 + config/initializers/rswag_ui.rb | 17 + config/routes.rb | 6 +- 7 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 app/controllers/api/v1/api.yaml create mode 100644 app/controllers/api/v1/schema_controller.rb create mode 100644 config/initializers/rswag_ui.rb diff --git a/Gemfile b/Gemfile index 6928572547c..49d2bf09bdb 100644 --- a/Gemfile +++ b/Gemfile @@ -10,7 +10,7 @@ gem "clearance", "~> 2.6" gem "dalli", "~> 3.2" gem "ddtrace", "~> 1.10", require: "ddtrace/auto_instrument" gem "dogstatsd-ruby", "~> 5.5" -gem "google-protobuf", "~> 3.22" +gem "google-protobuf", "~> 3.23" gem "faraday", "~> 1.10" gem "good_job", "~> 3.14" gem "gravtastic", "~> 3.2" @@ -73,6 +73,7 @@ group :development, :test do gem "toxiproxy", "~> 2.0" gem "factory_bot_rails", "~> 6.2" gem "dotenv-rails", "~> 2.8" + gem "openapi_parser", "~> 1.0" gem "brakeman", "~> 5.4", require: false gem "rubocop", "~> 1.48", require: false @@ -87,6 +88,7 @@ group :development do gem "listen", "~> 3.8" gem "letter_opener", "~> 1.8" gem "letter_opener_web", "~> 2.0" + gem "rswag-ui", "~> 2.9" end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index d1b0c020af9..3a149832bd4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -360,6 +360,7 @@ GEM omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) omniauth (~> 2.0) + openapi_parser (1.0.0) opensearch-api (1.0.0) multi_json opensearch-dsl (0.2.1) @@ -466,6 +467,9 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) + rswag-ui (2.9.0) + actionpack (>= 3.1, < 7.1) + railties (>= 3.1, < 7.1) rubocop (1.48.0) json (~> 2.3) parallel (~> 1.10) @@ -632,7 +636,7 @@ DEPENDENCIES faraday (~> 1.10) faraday_middleware-aws-sigv4 (~> 0.6) good_job (~> 3.14) - google-protobuf (~> 3.22) + google-protobuf (~> 3.23) gravtastic (~> 3.2) groupdate (~> 6.2) high_voltage (~> 3.1) @@ -653,6 +657,7 @@ DEPENDENCIES omniauth (~> 2.1) omniauth-github (~> 2.0) omniauth-rails_csrf_protection (~> 1.0) + openapi_parser (~> 1.0) opensearch-dsl (~> 0.2.0) opensearch-ruby (~> 1.0) pg (~> 1.4) @@ -672,6 +677,7 @@ DEPENDENCIES roadie-rails (~> 3.0) rotp (~> 6.2) rqrcode (~> 2.1) + rswag-ui (~> 2.9) rubocop (~> 1.48) rubocop-capybara (~> 2.17) rubocop-minitest (~> 0.29) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index db60595fd7f..0eb48bdd944 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,6 +1,8 @@ class Api::BaseController < ApplicationController skip_before_action :verify_authenticity_token + around_action :rswag + private def name_params @@ -117,4 +119,38 @@ def render_api_key_forbidden def render_soft_deleted_api_key render plain: "An invalid API key cannot be used. Please delete it and create a new one.", status: :forbidden end + + def rswag + f = Rails.root.join("app", "controllers", "api", "v1", "api.yaml") + schema = OpenAPIParser.load(f, { strict_reference_validation: true, strict_response_validation: true }) + path = request.path + path = path.delete_suffix(".#{request.format.to_sym}") + path = path.sub("/api/v1", "") + + ro = schema.request_operation(request.method.downcase.to_sym, path) + raise "No operation found for #{request.method} #{path}" unless ro + + ro.validate_request_body(request.content_mime_type.to_s, request.request_parameters) + ro.validate_path_params + + yield.tap do + ro.validate_response_body( + OpenAPIParser::RequestOperation::ValidatableResponseBody.new(response.status, deserialized_response_body, response.headers) + ) + end + end + + def deserialized_response_body + case Mime::Type.lookup response.content_type.sub(/^([^,;]*).*/, '\1').strip.downcase + when Mime::Type.lookup_by_extension(:json) + JSON.parse(response.body) + when Mime::Type.lookup_by_extension(:yaml) + YAML.safe_load(response.body) + when Mime::Type.lookup("text/plain") + response.body + else + raise "Unknown content type #{response.content_type} (#{response.content_type.sub(/^([^,;]*).*/, '\1')}) in response body: " + + response.body + end + end end diff --git a/app/controllers/api/v1/api.yaml b/app/controllers/api/v1/api.yaml new file mode 100644 index 00000000000..5749a816387 --- /dev/null +++ b/app/controllers/api/v1/api.yaml @@ -0,0 +1,686 @@ +openapi: 3.0.0 +x-stoplight: + id: ke9efxx0sv0in +info: + title: RubyGems.org API + version: 1.0.0 + contact: + email: support@rubygems.org +servers: + - url: "https://rubygems.org/api/v1" + description: Production + - url: "https://staging.rubygems.org/api/v1" + description: Staging +tags: + - name: dependencies + - name: gems + - name: web_hooks +paths: + /gems: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Rubygem" + text/yaml: + schema: + type: array + items: + $ref: "#/components/schemas/Rubygem" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + tags: + - gems + post: + operationId: pushGem + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + description: The .gem file to push + responses: + "200": + description: Created + content: + text/plain: + schema: + type: string + example: "Successfully registered gem: rubygem1 (1.1.0.pre.2)" + 4XX: + $ref: "#/components/responses/Error" + "/gems/{name}": + parameters: + - name: name + in: path + required: true + schema: + $ref: "#/components/schemas/GemName" + get: + responses: + "200": + description: OK + content: + text/yaml: + schema: + $ref: "#/components/schemas/Rubygem" + application/json: + schema: + $ref: "#/components/schemas/Rubygem" + example: + name: rubygem1 + downloads: 0 + version: 1.1.0.pre.2 + version_created_at: "2023-06-24T01:22:24.352Z" + version_downloads: 0 + platform: ruby + authors: null + info: This rubygem does not have a description or summary. + licenses: null + metadata: {} + yanked: false + sha: null + project_uri: "http://localhost/gems/rubygem1" + gem_uri: "http://localhost/gems/rubygem1-1.1.0.pre.2.gem" + homepage_uri: null + wiki_uri: null + documentation_uri: "https://www.rubydoc.info/gems/rubygem1/1.1.0.pre.2" + mailing_list_uri: null + source_code_uri: null + bug_tracker_uri: null + changelog_uri: null + funding_uri: null + dependencies: + development: [] + runtime: + - name: rubygem0 + requirements: ~> 1.0.0 + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/Error" + tags: + - gems + options: + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + "/gems/{name}/reverse_dependencies": + get: + parameters: + - name: name + in: path + required: true + schema: + $ref: "#/components/schemas/GemName" + responses: + "200": + description: OK + content: + text/yaml: + schema: + $ref: "#/components/schemas/ReverseDependencies" + application/json: + schema: + $ref: "#/components/schemas/ReverseDependencies" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/Error" + tags: + - gems + - dependencies + /web_hooks: + post: + operationId: createWebHook + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + description: The webhook to create + responses: + "201": + description: Created + content: + text/plain: + schema: + type: string + 4XX: + $ref: "#/components/responses/Error" + tags: + - web_hooks + get: + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + all gems: + type: array + items: + $ref: "#/components/schemas/WebHook" + additionalProperties: + type: array + items: + $ref: "#/components/schemas/WebHook" + text/yaml: + schema: + type: object + properties: + all gems: + type: array + items: + $ref: "#/components/schemas/WebHook" + additionalProperties: + type: array + items: + $ref: "#/components/schemas/WebHook" + 4XX: + $ref: "#/components/responses/Error" + tags: + - web_hooks + delete: + operationId: deleteWebHook + requestBody: + content: + application/json: + schema: + type: object + description: The webhook to delete + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + 4XX: + $ref: "#/components/responses/Error" + tags: + - web_hooks + /web_hooks/fire: + post: + operationId: fireWebHook + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + required: + - url + properties: + gem_name: + $ref: "#/components/schemas/GemName" + nullable: true + url: + type: string + format: uri + text/yaml: + schema: + type: object + additionalProperties: false + properties: + gem_name: + oneOf: + - $ref: "#/components/schemas/GemName" + - type: string + enum: + - "*" + nullable: true + url: + type: string + format: uri + "*/*": + schema: + type: object + additionalProperties: false + properties: + gem_name: + oneOf: + - $ref: "#/components/schemas/GemName" + - type: string + enum: + - "*" + nullable: true + url: + type: string + format: uri + description: The webhook to fire + responses: + "200": + description: Fired + content: + text/plain: + schema: + type: string + 4XX: + $ref: "#/components/responses/Error" + tags: + - web_hooks + /web_hooks/remove: + delete: + responses: + "200": + description: Deleted + content: + text/plain: + schema: + type: string + 4XX: + $ref: "#/components/responses/Error" + tags: + - web_hooks + "/versions/{gem_name}": + parameters: + - schema: + $ref: "#/components/schemas/GemName" + name: gem_name + in: path + required: true + get: + tags: + - versions + responses: + "200": + description: OK + content: + text/yaml: + schema: + type: array + items: + $ref: "#/components/schemas/Version" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Version" + "4XX": + $ref: "#/components/responses/Error" + "304": + $ref: "#/components/responses/NotModified" + operationId: getGemVersions + /versions/{gem_name}/latest: + parameters: + - schema: + $ref: "#/components/schemas/GemName" + name: gem_name + in: path + required: true + get: + tags: + - versions + responses: + "200": + description: OK + content: + text/yaml: + schema: + type: object + properties: + version: + oneOf: + - $ref: "#/components/schemas/VersionString" + - type: string + enum: + - "unknown" + additionalProperties: false + application/json: + schema: + type: object + properties: + version: + oneOf: + - type: string + enum: + - "unknown" + - $ref: "#/components/schemas/VersionString" + additionalProperties: false + text/javascript: + schema: + type: string + "4XX": + $ref: "#/components/responses/Error" + "304": + $ref: "#/components/responses/NotModified" + operationId: getLatestGemVersion + /versions/{gem_name}/reverse_dependencies: + parameters: + - schema: + $ref: "#/components/schemas/GemName" + name: gem_name + in: path + required: true + get: + tags: + - versions + responses: + "200": + description: OK + content: + text/yaml: + schema: + type: array + items: + type: string + application/json: + schema: + type: array + items: + type: string + "4XX": + $ref: "#/components/responses/Error" + "304": + $ref: "#/components/responses/NotModified" + operationId: getReverseDependencies + /api_key: + get: + tags: + - api_key + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKey" + application/x-yaml: + schema: + $ref: "#/components/schemas/ApiKey" + text/plain: + schema: + type: string + "4XX": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + operationId: getApiKey + post: + tags: + - api_key + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + "4XX": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + operationId: signIn + put: + tags: + - api_key + responses: + "200": + description: OK + content: + text/plain: + schema: + type: string + "4XX": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + operationId: updateApiKey +components: + schemas: + GemName: + type: string + pattern: '\A[A-Za-z0-9\.\-_]+\z' + VersionString: + type: string + pattern: '\A([0-9]+(?>\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?)?\z' + x-stoplight: + id: 1037iwcuunjvw + Rubygem: + type: object + x-stoplight: + id: kfk9i6uptmzzr + additionalProperties: false + properties: + name: + $ref: "#/components/schemas/GemName" + downloads: + type: integer + version: + $ref: "#/components/schemas/VersionString" + version_created_at: + type: string + format: date-time + version_downloads: + type: integer + platform: + type: string + authors: + type: string + nullable: true + info: + type: string + licenses: + type: string + nullable: true + metadata: + type: object + nullable: true + yanked: + type: boolean + sha: + type: string + nullable: true + project_uri: + type: string + format: uri + gem_uri: + type: string + format: uri + homepage_uri: + type: string + format: uri + nullable: true + wiki_uri: + type: string + format: uri + nullable: true + documentation_uri: + type: string + format: uri + nullable: true + mailing_list_uri: + type: string + format: uri + nullable: true + source_code_uri: + type: string + format: uri + nullable: true + bug_tracker_uri: + type: string + format: uri + nullable: true + changelog_uri: + type: string + format: uri + nullable: true + funding_uri: + type: string + format: uri + nullable: true + dependencies: + type: object + additionalProperties: false + properties: + development: + type: array + items: + $ref: "#/components/schemas/Dependency" + runtime: + type: array + items: + $ref: "#/components/schemas/Dependency" + ReverseDependencies: + type: array + items: + type: string + x-stoplight: + id: drr3o1k6z2j54 + Dependency: + type: object + additionalProperties: false + properties: + name: + $ref: "#/components/schemas/GemName" + requirements: + type: string + nullable: true + WebHook: + type: object + additionalProperties: false + required: + - url + - failure_count + properties: + url: + type: string + format: uri + failure_count: + type: integer + Version: + type: object + additionalProperties: false + properties: + authors: + type: string + nullable: true + built_at: + type: string + format: date-time + created_at: + type: string + format: date-time + description: + type: string + nullable: true + downloads_count: + type: integer + metadata: + type: object + nullable: true + additionalProperties: + type: string + maxLength: 1024 + number: + $ref: "#/components/schemas/VersionString" + summary: + type: string + nullable: true + platform: + type: string + nullable: true + rubygems_version: + type: string + nullable: true + ruby_version: + type: string + nullable: true + prerelease: + type: boolean + licenses: + type: string + nullable: true + requirements: + type: string + # additionalProperties: false + # properties: + # development: + # type: array + # items: + # $ref: "#/components/schemas/Dependency" + # runtime: + # type: array + # items: + # $ref: "#/components/schemas/Dependency" + sha: + type: string + nullable: true + ApiKey: + type: object + additionalProperties: false + required: + - rubygems_api_key + - status + properties: + rubygems_api_key: + type: string + status: + type: string + responses: + Forbidden: + description: Forbidden + content: + text/plain: + schema: + type: string + example: Forbidden + Unauthorized: + description: Forbidden + content: + text/plain: + schema: + type: string + example: "Access Denied. Please sign up for an account at https://rubygems.org" + application/json: + schema: + type: string + "": + schema: + type: string + NotModified: + description: Not Modified + content: + "": + schema: + type: string + example: Not Modified + Error: + description: Error + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + status: + type: integer + example: 404 + error: + type: string + example: Not Found + text/plain: + schema: + type: string + example: This gem could not be found + securitySchemes: + API Key: + name: Authorization + type: apiKey + in: header + description: "" diff --git a/app/controllers/api/v1/schema_controller.rb b/app/controllers/api/v1/schema_controller.rb new file mode 100644 index 00000000000..a73d02cb886 --- /dev/null +++ b/app/controllers/api/v1/schema_controller.rb @@ -0,0 +1,9 @@ +class Api::V1::SchemaController < Api::BaseController + def show + schema = YAML.load Rails.root.join("app", "controllers", "api", "v1", "api.yaml").read + respond_to do |format| + format.json { render json: schema } + format.yaml { render yaml: schema } + end + end +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 00000000000..057c17a6693 --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,17 @@ +if Rails.env.development? + Rswag::Ui.configure do |c| + # List the Swagger endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + # NOTE: If you're using rspec-api to expose Swagger files + # (under swagger_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + c.swagger_endpoint '/api/v1/schema.yaml', 'API V1 Docs' + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' + end +end diff --git a/config/routes.rb b/config/routes.rb index 2be100f3548..d95b0eafe1e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -284,5 +284,9 @@ ################################################################################ # Development routes - mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + if Rails.env.development? + mount LetterOpenerWeb::Engine, at: "/letter_opener" + get '/api/v1/schema' => 'api/v1/schema#show' + mount Rswag::Ui::Engine => '/api-docs' + end end