diff --git a/.rubocop.yml b/.rubocop.yml index 07842b4..b1a53da 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,8 @@ inherit_from: .rubocop_todo.yml +inherit_gem: + rswag-specs: .rubocop_rspec_alias_config.yml + require: - rubocop-rspec - rubocop-rails @@ -19,3 +22,10 @@ Style/Documentation: Style/StringLiterals: Enabled: true EnforcedStyle: "double_quotes" + +# API Docs + +RSpec/NestedGroups: + Enabled: true + Exclude: + - 'spec/**/*_doc_spec.rb' diff --git a/Gemfile b/Gemfile index 950068c..e5ac2c1 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,8 @@ gem "active_storage_validations" # Validate ActiveStorage attachments gem "good_job" # Postgres-backed job queue gem "mime-types" gem "pdf-reader" +gem "rswag-api" # Serves the generated Swagger documentation +gem "rswag-ui" # Provides the Swagger UI interface gem "sib-api-v3-sdk", require: false # Brevo (ex Sendinblue) API group :development, :test do @@ -68,6 +70,7 @@ group :development, :test do gem "factory_bot_rails" gem "pry-rails" gem "rspec-rails" + gem "rswag-specs" # Allows API documentation via specs end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index c3a8032..9d224f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -220,6 +220,9 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.8.2) + json-schema (5.1.1) + addressable (~> 2.8) + bigdecimal (~> 3.1) language_server-protocol (3.17.0.3) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -358,6 +361,17 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.2) + rswag-api (2.16.0) + activesupport (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rswag-specs (2.16.0) + activesupport (>= 5.2, < 8.1) + json-schema (>= 2.2, < 6.0) + railties (>= 5.2, < 8.1) + rspec-core (>= 2.14) + rswag-ui (2.16.0) + actionpack (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) rubocop (1.68.0) json (~> 2.3) language_server-protocol (>= 3.17.0) @@ -478,6 +492,9 @@ DEPENDENCIES rails rspec rspec-rails + rswag-api + rswag-specs + rswag-ui rubocop rubocop-capybara rubocop-factory_bot diff --git a/README.md b/README.md index 71d7b93..1b45298 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ composants DSFR](https://github.com/betagouv/dsfr-view-components) * Sentry pour monitorer et être alerté en cas d'erreur ; * Matomo pour mesurer et comprendre l'usage via des analytics ; * RSpec comme framework de tests ; +* Rswag comme outil de documentation au format Swagger/ OpenAPI de l'API à travers des tests ; * Cucumber et Capybara pour les tests BDD ; * Rubocop (RSpec et Rails) pour le linting ; * Docker pour avoir un environnement de développement. @@ -35,27 +36,8 @@ Les fichiers devis sont traités par le `QuoteChecksController` qui les envoient ## API -via header `Accept` à la valeur `application/json` pour forcer le retour au format JSON et non classique HTML - -- GET `/profils` pour lister les profils disponibles -- POST `/[:profil]/devis/verifier` avec paramètre `quote_file` contenant le fichier -le type de retour avec erreurs retournées est: -``` -{ - "valid": false, - "errors": [ - "file_reading_error", - "devis_manquant", - "pro_raison_sociale_manquant", - "pro_forme_juridique_manquant", - "tva_manquant", - "capital_manquant", - "siret_manquant", - "client_prenom_manquant", - "client_nom_manquant" - ] -} -``` +- au format REST JSON +- voir fichier de documentation de l'API au format OpenAPI Swagger et interface bac à sable interractif sur `/api-docs` ## Démarrage diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 6c374e2..adb4e26 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -5,7 +5,7 @@ module V1 # Controller for Profiles API class ProfilesController < BaseController def index - render json: QuoteCheck::PROFILES + render json: { data: QuoteCheck::PROFILES } end end end diff --git a/app/controllers/api/v1/quote_checks_controller.rb b/app/controllers/api/v1/quote_checks_controller.rb index 44e5245..f9ad834 100644 --- a/app/controllers/api/v1/quote_checks_controller.rb +++ b/app/controllers/api/v1/quote_checks_controller.rb @@ -7,6 +7,7 @@ class QuoteChecksController < BaseController before_action :quote_check, only: %i[show] def show + # Force to use async way by using show to get other fields render json: quote_check_json end @@ -23,11 +24,13 @@ def create upload_file.tempfile, upload_file.original_filename, quote_check_params[:profile] ) @quote_check = quote_check_service.quote_check - @quote_check = quote_check_service.check # Might be time consuming, TODO: move to background job is needed + + # @quote_check = quote_check_service.check # Might be time consuming, TODO: move to background job is needed + QuoteCheckCheckJob.perform_later(@quote_check.id) QuoteCheckMailer.created(@quote_check).deliver_later - render json: quote_check_json(@quote_check) + render json: quote_check_json(@quote_check), status: :created end # rubocop:enable Metrics/MethodLength @@ -38,20 +41,28 @@ def quote_check end def quote_check_params - params.require(:quote_check).permit(:file, :profile) + params.permit(:file, :profile) end + # rubocop:disable Metrics/MethodLength def quote_check_json(quote_check_provided = nil) object = quote_check_provided || quote_check - object.attributes.merge({ - status: object.status, - valid: object.quote_valid?, - errors: object.validation_errors, - error_messages: object.validation_errors&.index_with do |error_key| - I18n.t("quote_validator.errors.#{error_key}") - end - }) + json_hash = object.attributes.merge({ # Warning: attributes has stringifed keys, so use it too + "status" => object.status, + "valid" => object.quote_valid?, + "errors" => object.validation_errors, + "error_messages" => object.validation_errors&.index_with do |error_key| + I18n.t("quote_validator.errors.#{error_key}") + end + }) + return json_hash if Rails.env.development? + + json_hash.slice( + "id", "status", "profile", + "valid", "errors", "error_messages" + ) end + # rubocop:enable Metrics/MethodLength end end end diff --git a/app/jobs/quote_check_check_job.rb b/app/jobs/quote_check_check_job.rb index 6dbbdb2..8686de0 100644 --- a/app/jobs/quote_check_check_job.rb +++ b/app/jobs/quote_check_check_job.rb @@ -6,6 +6,6 @@ class QuoteCheckCheckJob < ApplicationJob def perform(quote_check_id) quote_check = QuoteCheck.find(quote_check_id) - QuoteCheckCheckService.new(quote_check).check + QuoteCheckService.new(quote_check).check end end diff --git a/app/models/quote_check.rb b/app/models/quote_check.rb index 4bd1938..e07e5e6 100644 --- a/app/models/quote_check.rb +++ b/app/models/quote_check.rb @@ -4,6 +4,8 @@ class QuoteCheck < ApplicationRecord belongs_to :file, class_name: "QuoteFile" + STATUSES = %w[pending valid invalid].freeze + PROFILES = %w[artisan particulier mandataire conseiller].freeze validates :profile, presence: true, inclusion: { in: PROFILES } diff --git a/app/services/quote_check_service.rb b/app/services/quote_check_service.rb index b98e78d..388ae75 100644 --- a/app/services/quote_check_service.rb +++ b/app/services/quote_check_service.rb @@ -4,7 +4,7 @@ class QuoteCheckService attr_reader :quote_check - def initialize(tempfile_or_quote_check, filename, profile) + def initialize(tempfile_or_quote_check, filename = nil, profile = nil) @quote_check = if tempfile_or_quote_check.is_a?(QuoteCheck) tempfile_or_quote_check else diff --git a/config/application.rb b/config/application.rb index 723de61..5b618d5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -52,5 +52,9 @@ class Application < Rails::Application config.action_mailer.default_url_options = { host: ENV.fetch("APPLICATION_HOST", nil) } config.application_name = "Mon Devis Sans Oublis" + + config.openapi_file = lambda { |version| + "#{config.application_name.parameterize}_api_#{version.downcase}_swagger.yaml" + } end end diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 0000000..3f23545 --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Rswag::Api.configure do |config| + # Specify a root folder where Swagger JSON files are located + # This is used by the Swagger middleware to serve requests for API descriptions + # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure + # that it's configured to generate files in the same folder + config.openapi_root = Rails.root.join("swagger").to_s + + # Inject a lambda function to alter the returned Swagger prior to serialization + # The function will have access to the rack env for the current request + # For example, you could leverage this to dynamically assign the "host" property + # + # config.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 0000000..7eb3cec --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Rswag::Ui.configure do |config| + # 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 openapi_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + config.swagger_endpoint "/api-docs/v1/#{Rails.application.config.openapi_file.call('v1')}", + "#{Rails.application.config.application_name} API V1 Documentation" + + # Add Basic Auth in case your API is private + # config.basic_auth_enabled = true + # config.basic_auth_credentials 'username', 'password' +end diff --git a/config/routes/api.rb b/config/routes/api.rb index 51c86a2..94fcdf1 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -7,4 +7,7 @@ resources :quote_checks, only: %i[create show] end end + + mount Rswag::Api::Engine => "/api-docs" + mount Rswag::Ui::Engine => "/api-docs" end diff --git a/spec/requests/api/v1/profiles_doc_spec.rb b/spec/requests/api/v1/profiles_doc_spec.rb new file mode 100644 index 0000000..caf2e7f --- /dev/null +++ b/spec/requests/api/v1/profiles_doc_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "swagger_helper" + +describe "Profiles API" do + path "/profiles" do + get "Récupérer les profils disponibles" do + tags "Profils" + produces "application/json" + + response "200", "liste des profiles" do + schema type: :object, + properties: { + data: { + type: "array", + data: { type: "#/components/schemas/profile" } + } + }, + required: ["data"] + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb index 9d5cec3..b7d9688 100644 --- a/spec/requests/api/v1/profiles_spec.rb +++ b/spec/requests/api/v1/profiles_spec.rb @@ -14,7 +14,7 @@ it "returns a complete response" do get api_v1_profiles_url - expect(json).to include(*QuoteCheck::PROFILES) + expect(json.fetch("data")).to include(*QuoteCheck::PROFILES) end end end diff --git a/spec/requests/api/v1/quote_checks_doc_spec.rb b/spec/requests/api/v1/quote_checks_doc_spec.rb new file mode 100644 index 0000000..8bbbafc --- /dev/null +++ b/spec/requests/api/v1/quote_checks_doc_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "swagger_helper" + +describe "Devis API" do + path "/quote_checks" do + # TODO: i18n? + post "Téléverser un devis" do + tags "Devis" + # TODO: security [ basic_auth: [] ] + consumes "multipart/form-data" + produces "application/json" + + parameter name: :file, in: :formData, schema: { + type: :string, + format: :binary + }, required: true + parameter name: :profile, in: :formData, schema: { + "$ref" => "#/components/schemas/profile" + }, required: true + + response "201", "Devis téléversé" do + schema "$ref" => "#/components/schemas/quote_check" + description "Au retour le devis a été téléversé avec succès. +Mais vérifiez selon le statut si le devis a été déjà analysé ou non. +Il peut contenir des erreurs dès le téléversement. +Si le statut est 'pending', cela signifie que l'analyse est encore en cours. +Et qu'il faut boucler sur l'appel /quote_check/:id pour récupérer le devis à jour.".gsub("\n", "
") + + let(:file) { fixture_file_upload("quote_files/Devis_test.pdf") } + let(:profile) { "artisan" } + + run_test! + end + + response "422", "invalid request" do + let(:file) { fixture_file_upload("quote_files/Devis_test.pdf") } + let(:profile) { "blabla" } + + run_test! + end + end + end + + path "/quote_checks/{id}" do + get "Récupérer un Devis" do + tags "Devis" + consumes "application/json" + produces "application/json" + parameter name: :id, in: :path, type: :string, required: true + + response "200", "Devis trouvé" do + schema "$ref" => "#/components/schemas/quote_check" + + let(:id) { create(:quote_check).id } + + run_test! + end + + response "404", "Devis non trouvé" do + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/quote_checks_spec.rb b/spec/requests/api/v1/quote_checks_spec.rb index 307f03a..9c9c4da 100644 --- a/spec/requests/api/v1/quote_checks_spec.rb +++ b/spec/requests/api/v1/quote_checks_spec.rb @@ -4,6 +4,8 @@ require "rails_helper" RSpec.describe "/api/v1/quote_checks" do + let(:json) { response.parsed_body } + describe "POST /api/v1/quote_checks" do let(:file) { fixture_file_upload("quote_files/Devis_test.pdf") } let(:quote_check_params) do @@ -12,52 +14,57 @@ profile: "artisan" } end - let(:json) { response.parsed_body } it "returns a successful response" do - post api_v1_quote_checks_url, params: { quote_check: quote_check_params } + post api_v1_quote_checks_url, params: quote_check_params expect(response).to be_successful end - # rubocop:disable RSpec/MultipleExpectations - it "returns a complete response" do - post api_v1_quote_checks_url, params: { quote_check: quote_check_params } - - expect(json.fetch("status")).to eq("invalid") - - expect(json.fetch("validation_errors")).to include("devis_manquant") - expect(json.fetch("validation_errors")).not_to include("siret_manquant") + it "returns a created response" do + post api_v1_quote_checks_url, params: quote_check_params + expect(response).to have_http_status(:created) + end - expect(json.dig("read_attributes", "pro", "siret")).to eq("12345678900000") + it "returns a pending treatment response" do + post api_v1_quote_checks_url, params: quote_check_params + expect(json.fetch("status")).to eq("pending") end - # rubocop:enable RSpec/MultipleExpectations it "creates a QuoteCheck" do expect do - post api_v1_quote_checks_url, params: { quote_check: quote_check_params } + post api_v1_quote_checks_url, params: quote_check_params end.to change(QuoteCheck, :count).by(1) end - - context "with invalid file type" do - let(:file) { fixture_file_upload("quote_files/Devis_test.png") } - - # rubocop:disable RSpec/MultipleExpectations - it "returns a direct error response" do - post api_v1_quote_checks_url, params: { quote_check: quote_check_params } - expect(json.fetch("status")).to eq("invalid") - expect(json.fetch("validation_errors")).to include("unsupported_file_format") - end - # rubocop:enable RSpec/MultipleExpectations - end end describe "GET /api/v1/quote_checks/:id" do let(:quote_file) { create(:quote_file) } let(:quote_check) { create(:quote_check, file: quote_file) } + before do + QuoteCheckCheckJob.new.perform(quote_check.id) + end + + # rubocop:disable RSpec/MultipleExpectations it "renders a successful response" do get api_v1_quote_check_url(quote_check), as: :json expect(response).to be_successful + expect(json.fetch("status")).to eq("invalid") + end + # rubocop:enable RSpec/MultipleExpectations + + context "with invalid file type" do + let(:file) { Rails.root.join("spec/fixtures/files/quote_files/Devis_test.png").open } + let(:quote_file) { create(:quote_file, file: file) } + + # rubocop:disable RSpec/MultipleExpectations + it "returns a direct error response" do + get api_v1_quote_check_url(quote_check), as: :json + expect(response).to be_successful + expect(json.fetch("status")).to eq("invalid") + expect(json.fetch("errors")).to include("file_reading_error") + end + # rubocop:enable RSpec/MultipleExpectations end end end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 0000000..9f6d8a8 --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Via Rswag gems +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + # NOTE: If you're using the rswag-api to serve API descriptions, you'll need + # to ensure that it's configured to serve Swagger from the same folder + config.openapi_root = Rails.root.join("swagger").to_s # TODO: doc + + # Define one or more Swagger documents and provide global metadata for each one + # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will + # be generated at the provided relative path under openapi_root + # By default, the operations defined in spec files are added to the first + # document below. You can override this behavior by adding a openapi_spec tag to the + # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' + config.openapi_specs = { + "v1/#{Rails.application.config.openapi_file.call('v1')}" => { + openapi: "3.0.1", + info: { + title: "#{Rails.application.config.application_name} API V1", + version: "v1" + }, + paths: {}, + components: { + # TODO + # securitySchemes: { + # basic_auth: { + # type: :http, + # scheme: :basic + # }, + # api_key: { + # type: :apiKey, + # name: "api_key", + # in: :query + # } + # }, + schemas: { + profile: { + type: :string, + enum: QuoteCheck::PROFILES + }, + quote_check_status: { + type: :string, + enum: QuoteCheck::STATUSES, + description: { + "pending" => "analyse en cours", + "valid" => "valide", + "invalid" => "invalide" + }.slice(*QuoteCheck::STATUSES).map { |status, description| "#{status}: #{description}" }.join(" | ") + }, + quote_check_error: { + type: :string, + # enum: QuoteCheck::ERRORS, # TODO + description: "code d'erreur" + }, + quote_check: { + type: "object", + properties: { + id: { + type: :string, + description: "UUID unique" + }, + status: { type: :string }, + profile: { "$ref" => "#/components/schemas/profile" }, + valid: { type: :boolean, nullable: true }, + errors: { + type: :array, + items: { "$ref" => "#/components/schemas/quote_check_error" }, + description: "liste des erreurs dans ordre à afficher", + nullable: true + }, + error_messages: { + type: :object, + additionalProperties: { + type: :string, + description: "code d'erreur => message" + }, + nullable: true + } + }, + required: %w[id status profile] + } + } + }, + servers: [ # Swagger reccomends to have path version listed inside server URLs + { + url: "https://api.staging.mon-devis-sans-oublis.beta.gouv.fr/api/v1", + description: "Staging server" + }, + { + url: "https://api.mon-devis-sans-oublis.beta.gouv.fr/api/v1", + description: "Production server" + }, + { + url: "http://localhost:3000/api/v1", + description: "Development server" + }, + if ENV.key?("APPLICATION_HOST") # current host + { + url: "http#{Rails.env.development? ? '' : 's'}://#{ENV.fetch('APPLICATION_HOST')}", + variables: { + defaultHost: { + default: ENV.fetch("APPLICATION_HOST", "localhost:3000") + } + } + } + end, + { # example host + url: "http#{Rails.env.development? ? '' : 's'}://{defaultHost}", + variables: { + defaultHost: { + default: ENV.fetch("APPLICATION_HOST", "localhost:3000") + } + } + } + ].compact.uniq { |server| server[:url] } + } + } + + # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. + # The openapi_specs configuration option has the filename including format in + # the key, this may want to be changed to avoid putting yaml in json files. + # Defaults to json. Accepts ':json' and ':yaml'. + config.openapi_format = :yaml + # TODO: config.swagger_format = :json + + # TODO: config.openapi_strict_schema_validation = true +end diff --git a/swagger/v1/mon-devis-sans-oublis_api_v1_swagger.yaml b/swagger/v1/mon-devis-sans-oublis_api_v1_swagger.yaml new file mode 100644 index 0000000..19c94bb --- /dev/null +++ b/swagger/v1/mon-devis-sans-oublis_api_v1_swagger.yaml @@ -0,0 +1,131 @@ +--- +openapi: 3.0.1 +info: + title: Mon Devis Sans Oublis API V1 + version: v1 +paths: + "/profiles": + get: + summary: Récupérer les profils disponibles + tags: + - Profils + responses: + '200': + description: liste des profiles + content: + application/json: + schema: + type: object + properties: + data: + type: array + data: + type: "#/components/schemas/profile" + required: + - data + "/quote_checks": + post: + summary: Téléverser un devis + tags: + - Devis + parameters: [] + description: Au retour le devis a été téléversé avec succès.
Mais vérifiez + selon le statut si le devis a été déjà analysé ou non.
Il peut contenir + des erreurs dès le téléversement.
Si le statut est 'pending', cela signifie + que l'analyse est encore en cours.
Et qu'il faut boucler sur l'appel /quote_check/:id + pour récupérer le devis à jour. + responses: + '201': + description: Devis téléversé + content: + application/json: + schema: + "$ref": "#/components/schemas/quote_check" + '422': + description: invalid request + requestBody: + content: + multipart/form-data: + schema: + type: string + format: binary + required: true + "/quote_checks/{id}": + get: + summary: Récupérer un Devis + tags: + - Devis + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Devis trouvé + content: + application/json: + schema: + "$ref": "#/components/schemas/quote_check" + '404': + description: Devis non trouvé +components: + schemas: + profile: + type: string + enum: + - artisan + - particulier + - mandataire + - conseiller + quote_check_status: + type: string + enum: + - pending + - valid + - invalid + description: 'pending: analyse en cours | valid: valide | invalid: invalide' + quote_check_error: + type: string + description: code d'erreur + quote_check: + type: object + properties: + id: + type: string + description: UUID unique + status: + type: string + profile: + "$ref": "#/components/schemas/profile" + valid: + type: boolean + nullable: true + errors: + type: array + items: + "$ref": "#/components/schemas/quote_check_error" + description: liste des erreurs dans ordre à afficher + nullable: true + error_messages: + type: object + additionalProperties: + type: string + description: code d'erreur => message + nullable: true + required: + - id + - status + - profile +servers: +- url: https://api.staging.mon-devis-sans-oublis.beta.gouv.fr/api/v1 + description: Staging server +- url: https://api.mon-devis-sans-oublis.beta.gouv.fr/api/v1 + description: Production server +- url: http://localhost:3000/api/v1 + description: Development server +- url: https://{defaultHost} + variables: + defaultHost: + default: localhost:3000