diff --git a/app/assets/stylesheets/procedure_champs_editor.scss b/app/assets/stylesheets/procedure_champs_editor.scss index ab4db456083..08efe7c5f8e 100644 --- a/app/assets/stylesheets/procedure_champs_editor.scss +++ b/app/assets/stylesheets/procedure_champs_editor.scss @@ -73,7 +73,8 @@ } .cell { - label { + label, + .fake-label { margin-bottom: 8px; text-transform: uppercase; font-size: 12px; diff --git a/app/components/referentiels/mapping_form_component.rb b/app/components/referentiels/mapping_form_component.rb new file mode 100644 index 00000000000..89169b79116 --- /dev/null +++ b/app/components/referentiels/mapping_form_component.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class Referentiels::MappingFormComponent < ApplicationComponent + TYPES = { + String => "Chaine de caractère", + Float => "Nombre à virgule", + Integer => "Nombre Entier", + TrueClass => "Booléen", + FalseClass => "Booléen" + } + + PREFIX = "type_de_champ[referentiel_mapping][]" + + attr_reader :procedure, :type_de_champ, :referentiel + + def initialize(procedure:, type_de_champ:, referentiel:) + @procedure = procedure + @type_de_champ = type_de_champ + @referentiel = referentiel + end + + def last_request_keys + hash_to_jsonpath(referentiel.last_response_body) + end + + def error_title + "¡Ay, caramba! 💣💥" + end + + def back_url + edit_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel.id) + end + + def cast_tag(jsonpath, value) + attribute = "type" + current_value = lookup_existing_value(jsonpath, attribute) || value_to_type(value) + + select_tag "#{PREFIX}[#{attribute}]", options_for_select(TYPES.values.uniq, current_value), class: "fr-select" + end + + def prefill_tag(jsonpath) + attribute = "prefill" + current_value = lookup_existing_value(jsonpath, attribute) || false + tag.div(class: "fr-checkbox-group") do + safe_join([ + check_box_tag("#{PREFIX}[#{attribute}]", "1", current_value, class: "fr-checkbox", id: jsonpath.parameterize, data: { "action": "change->referentiel-mapping#onCheckboxChange" }), + tag.label(for: jsonpath.parameterize, class: "fr-label") { " ".html_safe } + ]) + end + end + + def libelle_tag(jsonpath) + attribute = "libelle" + current_value = lookup_existing_value(jsonpath, attribute) || jsonpath + options = { class: 'fr-input', data: { "referentiel-mapping-target": "input" } } + options[:disabled] = :disabled if lookup_existing_value(jsonpath, "prefill") == "1" + + text_field_tag "#{PREFIX}[#{attribute}]", current_value, options + end + + private + + def lookup_existing_value(jsonpath, attribute) + type_de_champ.referentiel_mapping + &.find { _1["jsonpath"] == jsonpath } + &.fetch(attribute) { nil } + end + + def value_to_type(value) + TYPES.fetch(value.class) { TYPES[String] } + end + + def hash_to_jsonpath(hash, parent_path = '$') + hash.each_with_object({}) do |(key, value), result| + current_path = "#{parent_path}.#{key}" + + if value.is_a?(Hash) + result.merge!(hash_to_jsonpath(value, current_path)) + elsif value.is_a?(Array) && value[0].is_a?(Hash) + + result.merge!(hash_to_jsonpath(value[0], "#{current_path}[0]")) + else + result[current_path] = value + end + end + end +end diff --git a/app/components/referentiels/mapping_form_component/mapping_form_component.html.haml b/app/components/referentiels/mapping_form_component/mapping_form_component.html.haml new file mode 100644 index 00000000000..589b0690231 --- /dev/null +++ b/app/components/referentiels/mapping_form_component/mapping_form_component.html.haml @@ -0,0 +1,64 @@ +- if !@referentiel.ready? + %h4 Impossible de contacter le référentiel + + = render Dsfr::AlertComponent.new(title: error_title, state: :error, extra_class_names: 'fr-mb-3w') do |c| + - c.with_body do + %p L'API n'a pas retournée une réponse valide, veuillez vérifier les paramètres du referentiel : + %pre Endpoint: #{@referentiel.url} + %pre Body: #{JSON.pretty_generate(@referentiel.last_response_body)} + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li= link_to "Annuler", champs_admin_procedure_path(@procedure), class: 'fr-btn fr-btn--secondary' + %li= link_to "Étape précédente", back_url, class: 'fr-btn' + %li + %button.fr-btn{ disabled: true } Étape suivante + +- else + %section.fr-accordion.fr-mb-3w + %h3.fr-accordion__title + %button.fr-accordion__btn{ "aria-controls" => "last_response", "aria-expanded" => "false" } Afficher la réponse récupérée à partir de la requête configurée + + .fr-collapse#last_response + %pre= @referentiel.url + %pre= JSON.pretty_generate(@referentiel.last_response_body) + + %section + = form_with(model: @type_de_champ, url: update_mapping_type_de_champ_admin_procedure_referentiel_path(@procedure, @type_de_champ.stable_id, @referentiel)) do |f| + .fr-table + .fr-table__wrapper + .fr-table__container + .fr-table__content + %table + %caption Complétez le tableau de mapping ci-dessous en fonction des données que vous souhaitez exploiter + %thead + %tr + %th{ scope: "col" } Propriété + %th.fr-cell--fixed{ scope: "col" } Exemple de donnée + %th{ scope: "col" } Type de donnée + %th{ scope: "col" } + Utiliser la donnée + %br + pour préremplir + %br + un champ du + %br + formulaire + %th{ scope: "col" } + Libellé de la donnée récupérée + %br + (pour afficher à l'usager et/ou l'instructeur) + %tbody + - last_request_keys.each do |jsonpath,example_value| + %tr{ data: { controller: "referentiel-mapping" } } + %td + = jsonpath + = hidden_field_tag "type_de_champ[referentiel_mapping][][jsonpath]", jsonpath + %td.fr-cell--multiline= example_value + %td= cast_tag(jsonpath, example_value) + %td.text-center= prefill_tag(jsonpath) + %td= libelle_tag(jsonpath) + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li= link_to "Annuler", champs_admin_procedure_path(@procedure), class: 'fr-btn fr-btn--secondary' + %li= link_to "Étape précédente", back_url, class: 'fr-btn fr-btn--secondary' + %li= f.submit "Étape suivante", class: "fr-btn" diff --git a/app/components/referentiels/new_form_component.rb b/app/components/referentiels/new_form_component.rb new file mode 100644 index 00000000000..8631e8780e2 --- /dev/null +++ b/app/components/referentiels/new_form_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Referentiels::NewFormComponent < ApplicationComponent + attr_reader :referentiel, :type_de_champ, :procedure + def initialize(referentiel:, type_de_champ:, procedure:) + @referentiel = referentiel + @type_de_champ = type_de_champ + @procedure = procedure + end + + def id + :new_referentiel + end + + def back_url + champs_admin_procedure_path(@procedure) + end + + def form_url + if @referentiel.persisted? + admin_procedure_referentiel_path(@procedure, @type_de_champ.stable_id, @referentiel) + else + admin_procedure_referentiels_path(@procedure, @type_de_champ.stable_id) + end + end + + def form_options + { + method: @referentiel.persisted? ? :patch : :post, + data: { turbo: 'true' }, + html: { novalidate: 'novalidate', id: } + } + end + + def submit_options + if referentiel.type.nil? + { class: 'fr-btn', disabled: true } + else + { class: 'fr-btn' } + end + end +end diff --git a/app/components/referentiels/new_form_component/new_form_component.html.haml b/app/components/referentiels/new_form_component/new_form_component.html.haml new file mode 100644 index 00000000000..6c67d59d455 --- /dev/null +++ b/app/components/referentiels/new_form_component/new_form_component.html.haml @@ -0,0 +1,33 @@ += form_with model: @referentiel, scope: :referentiel, url: form_url, **form_options do |form| + = form.hidden_field :referentiel_id, value: params[:referentiel_id] + %div + = render Dsfr::RadioButtonListComponent.new(form:, target: :type, + buttons: [ { label: 'À partir d’une URL', value: 'Referentiels::APIReferentiel', hint: 'Connectez un champ à une API', data: {controller: 'autosubmit'} } , + { label: 'À partir d’un fichier CSV', value: 'Referentiels::CsvReferentiel', hint: 'Connectez un champ à un CSV', disabled: true }]) do || + Comment intéroger votre référentiel ? + + - if @referentiel.type == 'Referentiels::APIReferentiel' + = render Dsfr::InputComponent.new(form:, attribute: :url) + - elsif @referentiel.type == 'Referentiels::CsvReferentiel' + .fr-input-group + = form.label :piece_justificative_template, class: 'fr-label', for: dom_id(@type_de_champ, :piece_justificative_template) do + Fichier CSV + %span.fr-text-hint Utilisez le modèle du fichier CSV fourni ci-dessous pour construire votre référentiel (le nombre de colonne n’est pas limité). + = render Attachment::EditComponent.new(attached_file: @type_de_champ.piece_justificative_template, view_as: :link) + + %hr.fr-hr.fr-my-5w + + %div + = render Dsfr::RadioButtonListComponent.new(form:, target: :mode, + buttons: [ { label: 'Correspondance exacte', value: 'exact_match', hint: 'Vérification de l’existence de la donnée saisie dans la BDD du référentiel (exemple : plaque d’immatriculation, SIREN...)' } , + { label: 'Autosuggestion au fur et à mesure de la saisie de l’usager', value: 'autocomplete', hint: 'Affichage de données issues de la BDD du référentiel correspondant en partie ou en totalité à la donnée saisie par l’usager (exemple : BDD de médicaments, modèles de véhicules...)', disabled: true }]) do + Mode de remplissage du champ par l’usager + + = render Dsfr::InputComponent.new(form:, attribute: :hint) + = render Dsfr::InputComponent.new(form:, attribute: :test_data) + + %hr.fr-hr.fr-my-5w + + %ul.fr-btns-group.fr-btns-group--inline-sm + %li= link_to "Annuler", back_url, class: 'fr-btn fr-btn--secondary' + %li= form.submit 'Étape suivante', submit_options diff --git a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml index 7e01524d335..b5204b20717 100644 --- a/app/components/types_de_champ_editor/champ_component/champ_component.html.haml +++ b/app/components/types_de_champ_editor/champ_component/champ_component.html.haml @@ -126,6 +126,9 @@ Spécifier un nombre maximal conseillé de caractères : = form.select :character_limit, options_for_character_limit, {}, { id: dom_id(type_de_champ, :character_limit), class: 'fr-select' } + - if type_de_champ.referentiel? + = render(TypesDeChampEditor::InfoReferentielComponent.new(procedure:, type_de_champ:)) + - if type_de_champ.repetition? .flex.justify-start.section.fr-ml-1w .editor-block.flex-grow.cell diff --git a/app/components/types_de_champ_editor/info_referentiel_component.rb b/app/components/types_de_champ_editor/info_referentiel_component.rb new file mode 100644 index 00000000000..53e98f8f86a --- /dev/null +++ b/app/components/types_de_champ_editor/info_referentiel_component.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class TypesDeChampEditor::InfoReferentielComponent < ApplicationComponent + attr_reader :procedure, :type_de_champ + delegate :referentiel, to: :type_de_champ + delegate :ready?, to: :referentiel, allow_nil: true + + def initialize(procedure:, type_de_champ:) + @procedure = procedure + @type_de_champ = type_de_champ + end + + def edit_referentiel_on_draft_or_clone_url + if should_create_new_referentiel? + dup_existing_referentiel_options = referentiel ? { referentiel_id: referentiel.id } : {} + new_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, dup_existing_referentiel_options) + else + edit_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, type_de_champ.referentiel) + end + end + + private + + def should_create_new_referentiel? + return true if referentiel.nil? + return true if procedure.publiee? + + already_in_use_in_another_procedure? + end + + def already_in_use_in_another_procedure? + Procedure.joins(revisions: :types_de_champ) + .where.not(published_at: nil) + .exists?(types_de_champ: { referentiel_id: referentiel.id }) + end +end diff --git a/app/components/types_de_champ_editor/info_referentiel_component/info_referentiel_component.html.haml b/app/components/types_de_champ_editor/info_referentiel_component/info_referentiel_component.html.haml new file mode 100644 index 00000000000..c92ad9bf673 --- /dev/null +++ b/app/components/types_de_champ_editor/info_referentiel_component/info_referentiel_component.html.haml @@ -0,0 +1,24 @@ +.fr-px-2w + + %hr.fr-hr + + .flex.fr-mb-2w.cell + %p.fake-label.fr-text--bold.flex-grow.fr-mb-0 Configuration du champ référentiel + - if !ready? + %p.fr-badge.fr-badge--sm.fr-badge--error À configurer + - else + %p.fr-badge.fr-badge--sm.fr-badge--success Configuré + + .fr-notice.fr-notice--info.fr-mb-2w + .fr-container + .fr-notice__body + %p.fr-notice__title Ce champ sera présenté à l’usager sous forme d’un champ de type « Texte court ». + %p.notice__desc.fr-mt-2w Lors de la configuration, vous pourrez choisir de : + %ul.fr-mt-0 + %li proposer des autosuggestions au fur et à mesure de la saisie de l’usager (issue de la BDD du référentiel) + %li ou uniquement vérifier la correspondance exacte de saisie dans la BDD du référentiel + + %p.flex.column.align-end + = link_to "Configurer le champ", edit_referentiel_on_draft_or_clone_url, class: 'fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-equalizer-fill' + + %hr.fr-hr diff --git a/app/controllers/administrateurs/referentiels_controller.rb b/app/controllers/administrateurs/referentiels_controller.rb new file mode 100644 index 00000000000..d551ca9f17e --- /dev/null +++ b/app/controllers/administrateurs/referentiels_controller.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Administrateurs + class ReferentielsController < AdministrateurController + before_action :retrieve_procedure + before_action :retrieve_type_de_champ + before_action :retrieve_referentiel, except: [:new, :create] + layout 'empty_layout' + + def new + @referentiel = @type_de_champ.build_referentiel(build_or_clone_by_id_params) + end + + def edit + end + + def create + referentiel = @type_de_champ.build_referentiel(referentiel_params) + + if referentiel.configured? && referentiel.update(referentiel_params) + redirect_to mapping_type_de_champ_admin_procedure_referentiel_path(@procedure, @type_de_champ.stable_id, referentiel) + else + component = Referentiels::NewFormComponent.new(referentiel:, type_de_champ: @type_de_champ, procedure: @procedure) + render turbo_stream: turbo_stream.replace(component.id, component) + end + end + + def update + if @referentiel.update(referentiel_params) + redirect_to mapping_type_de_champ_admin_procedure_referentiel_path(@procedure, @type_de_champ.stable_id, @referentiel) + else + render :edit + end + end + + def mapping_type_de_champ + @service = ReferentielService.new(referentiel: @referentiel) + @service.test + end + + def update_mapping_type_de_champ + flash = if @type_de_champ.update(type_de_champ_mapping_params) + { notice: "La configuration du mapping a bien été enregistrée" } + else + { alert: "Une erreur est survenue" } + end + redirect_to mapping_type_de_champ_admin_procedure_referentiel_path(@procedure, @type_de_champ.stable_id, @referentiel), flash: + end + + private + + def type_de_champ_mapping_params + params.require(:type_de_champ) + .permit(referentiel_mapping: [:jsonpath, :type, :prefill, :libelle]) + end + + def referentiel_params + params.require(:referentiel) + .permit(:type, :mode, :url, :hint, :test_data) + rescue ActionController::ParameterMissing + {} + end + + def retrieve_type_de_champ + @type_de_champ = @procedure.draft_revision.find_and_ensure_exclusive_use(params[:stable_id]) + end + + def retrieve_referentiel + @referentiel = Referentiel.find(params[:id]) + end + + def build_or_clone_by_id_params + if params[:referentiel_id] + Referentiel.find(params[:referentiel_id]).attributes.slice(*%w[url test_data hint mode type]) + else + referentiel_params + end + end + end +end diff --git a/app/javascript/controllers/referentiel_mapping_controller.ts b/app/javascript/controllers/referentiel_mapping_controller.ts new file mode 100644 index 00000000000..a91ca40db7f --- /dev/null +++ b/app/javascript/controllers/referentiel_mapping_controller.ts @@ -0,0 +1,16 @@ +import { ApplicationController } from './application_controller'; + +export class ReferentielMappingController extends ApplicationController { + static targets = ['input']; + + declare readonly checkboxTarget: HTMLInputElement; + declare readonly inputTarget: HTMLInputElement; + + connect() {} + + onCheckboxChange(event: Event) { + const checkbox = event.currentTarget as HTMLInputElement; + + this.inputTarget.disabled = checkbox.checked; + } +} diff --git a/app/javascript/entrypoints/main.css b/app/javascript/entrypoints/main.css index aa49669eb40..ab0b156f576 100644 --- a/app/javascript/entrypoints/main.css +++ b/app/javascript/entrypoints/main.css @@ -36,6 +36,7 @@ @import '@gouvfr/dsfr/dist/component/translate/translate.css'; @import '@gouvfr/dsfr/dist/component/pagination/pagination.css'; @import '@gouvfr/dsfr/dist/component/skiplink/skiplink.css'; +@import '@gouvfr/dsfr/dist/component/stepper/stepper.css'; @import '@gouvfr/dsfr/dist/component/password/password.css'; @import '@gouvfr/dsfr/dist/component/accordion/accordion.css'; @import '@gouvfr/dsfr/dist/component/tab/tab.css'; diff --git a/app/models/referentiels/api_referentiel.rb b/app/models/referentiels/api_referentiel.rb index 12688b55da3..74be0be978b 100644 --- a/app/models/referentiels/api_referentiel.rb +++ b/app/models/referentiels/api_referentiel.rb @@ -5,6 +5,18 @@ class Referentiels::APIReferentiel < Referentiel before_save :name_as_url + def last_response_body + (last_response || {}).fetch("body") { {} } + end + + def last_response_status + (last_response || {}).fetch("status") { 500 } + end + + def ready? + configured? && last_response_status == 200 + end + def configured? case type when "Referentiels::APIReferentiel" diff --git a/app/models/type_de_champ.rb b/app/models/type_de_champ.rb index 3e3c91cbaff..43bffec864e 100644 --- a/app/models/type_de_champ.rb +++ b/app/models/type_de_champ.rb @@ -139,7 +139,8 @@ class TypeDeChamp < ApplicationRecord :expression_reguliere_error_message, :collapsible_explanation_enabled, :collapsible_explanation_text, - :header_section_level + :header_section_level, + :referentiel_mapping has_many :revision_types_de_champ, -> { revision_ordered }, class_name: 'ProcedureRevisionTypeDeChamp', dependent: :destroy, inverse_of: :type_de_champ diff --git a/app/services/referentiel_service.rb b/app/services/referentiel_service.rb new file mode 100644 index 00000000000..d0fbddfe85a --- /dev/null +++ b/app/services/referentiel_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ReferentielService + include Dry::Monads[:result] + + attr_reader :referentiel, :service + + def initialize(referentiel:) + @referentiel = referentiel + end + + def test + case referentiel + when Referentiels::APIReferentiel + result = API::Client.new.call(url: referentiel.url.gsub('{id}', referentiel.test_data)) + + case result + in Success(data) + referentiel.update(last_response: { status: 200, body: data.body }) + true + in Failure(data) + referentiel.update(last_response: { status: data.code, body: data.try(:body) }) + false + end + else + fail "not yet implemented: #{referentiel.type}" + end + end +end diff --git a/app/views/administrateurs/referentiels/edit.html.haml b/app/views/administrateurs/referentiels/edit.html.haml new file mode 100644 index 00000000000..1a802d34f2e --- /dev/null +++ b/app/views/administrateurs/referentiels/edit.html.haml @@ -0,0 +1,16 @@ +.fr-container.fr-mt-6w.fr-mb-15w + = link_to " Champs du formulaire", champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm' + %h3.fr-my-3w + Configuration du champ « #{@type_de_champ.libelle} » + + .fr-stepper + %h2.fr-stepper__title + Requête + %span.fr-stepper__state Étape 1 sur 3 + + .fr-stepper__steps{ data: { "fr-current-step" => "1", "fr-steps" => "3" } } + %p.fr-stepper__details + %span.fr-text--bold Étape suivante : + Réponse et mapping + + = render Referentiels::NewFormComponent.new(referentiel: @referentiel, procedure: @procedure, type_de_champ: @type_de_champ) diff --git a/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml b/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml new file mode 100644 index 00000000000..428448fac1d --- /dev/null +++ b/app/views/administrateurs/referentiels/mapping_type_de_champ.html.haml @@ -0,0 +1,17 @@ +.fr-container.fr-mt-6w.fr-mb-15w + = link_to " Champs du formulaire", champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm' + %h3.fr-my-3w + Configuration du champ « #{@type_de_champ.libelle} » + + .fr-stepper + %h2.fr-stepper__title + Réponse et mapping + %span.fr-stepper__state Étape 2 sur 3 + + .fr-stepper__steps{ data: { "fr-current-step" => "2", "fr-steps" => "3" } } + %p.fr-stepper__details + %span.fr-text--bold Étape suivante : + Pré remplissage des champs et/ou affichage des données récupérées + + + = render Referentiels::MappingFormComponent.new(procedure: @procedure, type_de_champ: @type_de_champ, referentiel: @referentiel) diff --git a/app/views/administrateurs/referentiels/new.html.haml b/app/views/administrateurs/referentiels/new.html.haml new file mode 100644 index 00000000000..1a802d34f2e --- /dev/null +++ b/app/views/administrateurs/referentiels/new.html.haml @@ -0,0 +1,16 @@ +.fr-container.fr-mt-6w.fr-mb-15w + = link_to " Champs du formulaire", champs_admin_procedure_path(@procedure), class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm' + %h3.fr-my-3w + Configuration du champ « #{@type_de_champ.libelle} » + + .fr-stepper + %h2.fr-stepper__title + Requête + %span.fr-stepper__state Étape 1 sur 3 + + .fr-stepper__steps{ data: { "fr-current-step" => "1", "fr-steps" => "3" } } + %p.fr-stepper__details + %span.fr-text--bold Étape suivante : + Réponse et mapping + + = render Referentiels::NewFormComponent.new(referentiel: @referentiel, procedure: @procedure, type_de_champ: @type_de_champ) diff --git a/app/views/layouts/empty_layout.html.haml b/app/views/layouts/empty_layout.html.haml index fee313006ca..b417e7bca9e 100644 --- a/app/views/layouts/empty_layout.html.haml +++ b/app/views/layouts/empty_layout.html.haml @@ -28,4 +28,5 @@ %body{ class: browser.platform.ios? ? 'ios' : nil, data: { controller: 'turbo' } } .page-wrapper %main + = render partial: "layouts/flash_messages" = content_for?(:content) ? yield(:content) : yield diff --git a/config/routes.rb b/config/routes.rb index 1ba0e460c4e..49c31ef6f1f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -743,6 +743,13 @@ post :reset end + resources :referentiels, only: [:new, :create, :edit, :update], path: ':stable_id' do + member do + get :mapping_type_de_champ + patch :update_mapping_type_de_champ + end + end + resource :dossier_submitted_message, only: [:edit, :update, :create] # ADDED TO ACCESS IT FROM THE IFRAME get 'attestation_template/preview' => 'attestation_templates#preview' diff --git a/db/migrate/20241226092358_add_last_response_to_referentiels.rb b/db/migrate/20241226092358_add_last_response_to_referentiels.rb new file mode 100644 index 00000000000..366bef14008 --- /dev/null +++ b/db/migrate/20241226092358_add_last_response_to_referentiels.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastResponseToReferentiels < ActiveRecord::Migration[7.0] + def change + add_column :referentiels, :last_response, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 18eeec21b90..b0f292c6ee9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1089,6 +1089,7 @@ t.datetime "created_at", null: false t.string "headers", default: [], array: true t.string "hint" + t.jsonb "last_response" t.string "mode" t.string "name", null: false t.string "test_data" diff --git a/spec/components/referentiels/mapping_form_component_spec.rb b/spec/components/referentiels/mapping_form_component_spec.rb new file mode 100644 index 00000000000..46de2e68f14 --- /dev/null +++ b/spec/components/referentiels/mapping_form_component_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +RSpec.describe Referentiels::MappingFormComponent, type: :component do + let(:component) { described_class.new(referentiel:, type_de_champ:, procedure:) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :referentiel }] } + let(:type_de_champ) { procedure.draft_revision.types_de_champ_public.first } + let(:referentiel) { create(:api_referentiel, :configured) } + + describe 'render' do + delegate :url_helpers, to: :routes + delegate :routes, to: :application + delegate :application, to: Rails + + before do + Flipper.enable_actor(:referentiel_type_de_champ, procedure) + render_inline(component) + end + + context 'when referentiel is not ready' do + it 'render error' do + expect(page).to have_text(component.error_title) + expect(page).to have_selector("button.fr-btn[disabled]") + end + end + + context 'when referentiel is properly configured' do + let(:referentiel) { create(:api_referentiel, :with_last_response, :configured) } + + it 'table' do + # thead + expect(page).to have_selector("th", text: "Propriété") + expect(page).to have_selector("th", text: "Exemple de donnée") + expect(page).to have_selector("th", text: "Type de donnée") + expect(page).to have_selector("th", text: "Utiliser la donnée\n\npour préremplir\n\nun champ du\n\nformulaire") + expect(page).to have_selector("th", text: "Libellé de la donnée récupérée\n\n(pour afficher à l'usager et/ou l'instructeur)") + + # tbody + jsonpaths = page.all("tr td:nth-child(1)").map(&:text).map(&:strip) + ["$.point.type", "$.point.coordinates", "$.shape.type"].each do |sample| + expect(jsonpaths).to include(sample) + end + values = page.all("tr td:nth-child(2)").map(&:text).map(&:strip) + ["Point", "[-0.570505392116188, 44.841034137099996]", "MultiPolygon"].each do |sample| + expect(values).to include(sample) + end + + # navigation + expect(page).to have_selector("form[action=\"#{url_helpers.update_mapping_type_de_champ_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel)}\"]") + expect(page).to have_selector('input[type=submit][value="Étape suivante"]') + expect(page).to have_link("Étape précédente", href: url_helpers.edit_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel.id)) + expect(page).to have_selector("input[type=submit]") + end + end + end + + describe "value_to_type" do + def convert_json_value_to_human(value:) = component.send(:value_to_type, JSON.parse({ value: }.to_json)["value"]) + + it "json value to human" do + expect(convert_json_value_to_human(value: 1)).to eq("Nombre Entier") + expect(convert_json_value_to_human(value: 1.1)).to eq("Nombre à virgule") + expect(convert_json_value_to_human(value: true)).to eq("Booléen") + expect(convert_json_value_to_human(value: false)).to eq("Booléen") + expect(convert_json_value_to_human(value: "hello")).to eq("Chaine de caractère") + expect(convert_json_value_to_human(value: [1, 2])).to eq("Chaine de caractère") + end + end + + describe 'hash_to_jsonpath' do + def hash_to_jsonpath(hash) = component.send(:hash_to_jsonpath, hash) + + it 'jsonpathify simple hash' do + expect(hash_to_jsonpath({ "key" => "value" })).to eq({ + "$.key" => "value" + }) + end + it 'jsonpathify nested hash' do + expect(hash_to_jsonpath({ "key" => { "nested" => "value" } })).to eq({ + "$.key.nested" => "value" + }) + end + it 'jsonpathify nested hash' do + expect(hash_to_jsonpath({ data: [{ "key" => "value" }] })).to eq({ + "$.data[0].key" => "value" + }) + end + it 'jsonpathify real response' do + rnb_json = JSON.parse(File.read('spec/fixtures/files/api_referentiel_rnb.json')) + expect(hash_to_jsonpath(rnb_json).keys).to match_array([ + "$.point.type", + "$.point.coordinates", + "$.shape.type", + "$.shape.coordinates", + "$.rnb_id", + "$.status", + "$.ext_ids[0].id", + "$.ext_ids[0].source", + "$.ext_ids[0].created_at", + "$.ext_ids[0].source_version", + "$.addresses[0].id", + "$.addresses[0].source", + "$.addresses[0].street", + "$.addresses[0].city_name", + "$.addresses[0].street_rep", + "$.addresses[0].city_zipcode", + "$.addresses[0].street_number", + "$.addresses[0].city_insee_code", + "$.is_active" + ]) + end + end +end diff --git a/spec/components/referentiels/new_form_component_spec.rb b/spec/components/referentiels/new_form_component_spec.rb new file mode 100644 index 00000000000..1fd45b93710 --- /dev/null +++ b/spec/components/referentiels/new_form_component_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe Referentiels::NewFormComponent, type: :component do + describe 'render' do + delegate :url_helpers, to: :routes + delegate :routes, to: :application + delegate :application, to: Rails + + let(:component) { described_class.new(referentiel:, type_de_champ:, procedure:) } + let(:procedure) { create(:procedure, types_de_champ_public:) } + let(:types_de_champ_public) { [{ type: :referentiel }] } + let(:type_de_champ) { procedure.draft_revision.types_de_champ_public.first } + + before do + Flipper.enable_actor(:referentiel_type_de_champ, procedure) + render_inline(component) + end + + context 'when referentiel was not persisted' do + let(:referentiel) { type_de_champ.build_referentiel() } + + it 'render back button as destroy' do + expect(page).to have_link("Annuler", href: url_helpers.champs_admin_procedure_path(procedure)) + end + + context 'when mode was not selected' do + it 'forward referentiel_id if present in params' do + inputs = { + type: 2, + mode: 2, + referentiel_id: 1, + test_data: 1, + hint: 1, + url: 0 + } + expect(page).to have_css('form[method=post]') + expect(page).to have_css("form[action=\"#{url_helpers.admin_procedure_referentiels_path(procedure, type_de_champ.stable_id)}\"]") + expect(page).not_to have_selector('input[type="file"]') + expect(page).not_to have_selector('input[name="referentiel_url"]') + inputs.each do |input_name, count| + expect(page).to have_selector("input[name=\"referentiel[#{input_name}]\"]", count:) + end + expect(page).to have_selector('input[type=submit][disabled]', count: 1) + end + end + + context 'with api was selected' do + let(:referentiel) { type_de_champ.build_referentiel(type: "Referentiels::APIReferentiel") } + it 'renders url' do + expect(page).to have_selector('input[name="referentiel[url]"]') + expect(page).to have_selector('input[type=submit][disabled]', count: 0) + end + end + + context 'with csv was selected' do + let(:referentiel) { type_de_champ.build_referentiel(type: "Referentiels::CsvReferentiel") } + it 'renders url' do + expect(page).to have_selector('input[type="file"]') + expect(page).to have_selector('input[type=submit][disabled]', count: 0) + end + end + end + + context 'when referentiel was persisted' do + let(:referentiel) { create(:api_referentiel, types_de_champ: [type_de_champ], url: "https://rnb.api") } + it 'render form to update' do + expect(page).to have_css('form[method=post]') + expect(page).to have_css('input[name=_method][value=patch]') + expect(page).to have_css("form[action=\"#{url_helpers.admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel)}\"]") + end + end + end +end diff --git a/spec/components/types_de_champ_editor/info_referentiel_component_spec.rb b/spec/components/types_de_champ_editor/info_referentiel_component_spec.rb new file mode 100644 index 00000000000..b8d799e48d3 --- /dev/null +++ b/spec/components/types_de_champ_editor/info_referentiel_component_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +describe TypesDeChampEditor::InfoReferentielComponent, type: :component do + describe 'render' do + let(:component) { described_class.new(procedure:, type_de_champ:) } + let(:types_de_champ_public) { [{ type: :referentiel }] } + let(:type_de_champ) { procedure.draft_revision.types_de_champ_public.first } + + before do + referentiel + type_de_champ + Flipper.enable_actor(:referentiel_type_de_champ, procedure) + render_inline(component) + end + + context "draft_procedure" do + let(:procedure) { create(:procedure, types_de_champ_public:) } + context 'having referentiel' do + let(:referentiel) { create(:api_referentiel, types_de_champ: [type_de_champ], url: "https://rnb.api") } + + it "allows to edit referentiel" do + expect(page).to have_link("Configurer le champ", href: Rails.application.routes.url_helpers.edit_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel.id)) + end + end + context 'not having referentiel' do + let(:referentiel) { nil } + + it "new referentiel" do + expect(page).to have_link("Configurer le champ", href: Rails.application.routes.url_helpers.new_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id)) + end + end + end + + context "published_procedure" do + let(:procedure) { create(:procedure, :published, types_de_champ_public:) } + + context "having referentiel" do + let(:referentiel) { create(:api_referentiel, types_de_champ: [type_de_champ], url: "https://rnb.api") } + + it "does not allow to edit existing referentiel" do + expect(page).to have_link("Configurer le champ", href: Rails.application.routes.url_helpers.new_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id, referentiel_id: referentiel.id)) + end + end + + context 'not having referentiel' do + let(:referentiel) { nil } + + it "new referentiel" do + expect(page).to have_link("Configurer le champ", href: Rails.application.routes.url_helpers.new_admin_procedure_referentiel_path(procedure, type_de_champ.stable_id)) + end + end + end + end +end diff --git a/spec/controllers/administrateurs/referentiels_controller_spec.rb b/spec/controllers/administrateurs/referentiels_controller_spec.rb new file mode 100644 index 00000000000..262ee5f9fd8 --- /dev/null +++ b/spec/controllers/administrateurs/referentiels_controller_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +describe Administrateurs::ReferentielsController, type: :controller do + before { sign_in(procedure.administrateurs.first.user) } + let(:stable_id) { 123 } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :referentiel, stable_id: }]) } + + describe '#new' do + it 'renders successifully' do + get :new, params: { procedure_id: procedure.id, stable_id: } + expect(response).to have_http_status(:success) + end + + context 'given a referentiel_id' do + let(:original_data) do + { + url: 'https://rnb.api', + test_data: 'test', + hint: 'howtofillme', + mode: 'exact_match' + } + end + let(:referentiel) { create(:api_referentiel, **original_data) } + + it 'clone existing one' do + get :new, params: { procedure_id: procedure.id, referentiel_id: referentiel.id, stable_id: } + expect(assigns(:referentiel).attributes.with_indifferent_access.slice(*original_data.keys)) + .to eq(original_data.with_indifferent_access) + end + end + end + + describe '#create' do + subject { post :create, params: { procedure_id: procedure.id, stable_id:, referentiel: referentiel_params }, format: :turbo_stream } + + context 'partial update' do + let(:referentiel_params) { { type: 'Referentiels::APIReferentiel' } } + it 're-render form' do + expect { subject }.not_to change { Referentiel.count } + expect(response).to have_http_status(:success) + end + end + + context 'full update' do + let(:referentiel_params) do + { + type: 'Referentiels::APIReferentiel', + mode: 'exact_match', + url: 'https://rnb-api.beta.gouv.fr/api/alpha/buildings/{id}/', + hint: 'Identifiant unique du bâtiment dans le RNB, composé de 12 chiffre et lettre', + test_data: 'PG46YY6YWCX8' + } + end + + it 'creates referentiel and redirects to mapping' do + expect { subject }.to change { Referentiel.count }.by(1) + + referentiel = Referentiel.first + + expect(response).to redirect_to(mapping_type_de_champ_admin_procedure_referentiel_path(procedure, stable_id, referentiel)) + + expect(referentiel.types_de_champ).to include(TypeDeChamp.find_by(stable_id:)) + expect(referentiel.type).to eq(referentiel_params[:type]) + expect(referentiel.mode).to eq(referentiel_params[:mode]) + expect(referentiel.url).to eq(referentiel_params[:url]) + expect(referentiel.hint).to eq(referentiel_params[:hint]) + expect(referentiel.test_data).to eq(referentiel_params[:test_data]) + end + end + end + + describe "#edit" do + let(:type_de_champ) { procedure.draft_revision.types_de_champ.first } + let(:referentiel) { create(:api_referentiel, :configured, types_de_champ: [type_de_champ]) } + + it 'works' do + get :edit, params: { procedure_id: procedure.id, stable_id:, id: referentiel.id } + expect(response).to have_http_status(:success) + end + end + + describe "#update" do + let(:type_de_champ) { procedure.draft_revision.types_de_champ.first } + let(:referentiel) { create(:api_referentiel, :configured, types_de_champ: [type_de_champ]) } + let(:referentiel_params) do + { + mode: 'autocomplete', + url: 'https://ban.fr/{search}/', + hint: 'Rechercher par adresse', + test_data: '18 rue du solférino, paris' + } + end + it 'works' do + patch :update, params: { procedure_id: procedure.id, stable_id:, id: referentiel.id, referentiel: referentiel_params } + expect(response).to have_http_status(:found) + referentiel.reload + + expect(referentiel.mode).to eq(referentiel_params[:mode]) + expect(referentiel.url).to eq(referentiel_params[:url]) + expect(referentiel.hint).to eq(referentiel_params[:hint]) + expect(referentiel.test_data).to eq(referentiel_params[:test_data]) + end + end + + describe '#mapping_type_de_champ' do + let(:type_de_champ) { procedure.draft_revision.types_de_champ.first } + let(:referentiel) { create(:api_referentiel, :configured, types_de_champ: [type_de_champ]) } + + before do + allow_any_instance_of(API::Client) + .to receive(:call).with(anything).and_return(stub_response) + end + + context 'test APIReferentiel return valid response' do + include Dry::Monads[:result] + OK = Data.define(:body, :response) + + let(:body) { {} } + let(:http_response) { {} } + let(:stub_response) { Success(OK[body, http_response]) } + + it 'renders' do + expect { get :mapping_type_de_champ, params: { procedure_id: procedure.id, stable_id:, id: referentiel.id } } + .to change { referentiel.reload.last_response }.from(nil).to({ "body" => {}, "status" => 200 }) + expect(response).to have_http_status(200) + end + end + end + + describe '#update_mapping_type_de_champ' do + let(:type_de_champ) { procedure.draft_revision.types_de_champ.first } + let(:referentiel) { create(:api_referentiel, :configured, types_de_champ: [type_de_champ]) } + let(:referentiel_mapping) do + [ + { + jsonpath: "jsonpath", + type: "type", + prefill: "prefill", + libelle: "libelle" + } + ] + end + it 'update type de champ referentiel_mapping' do + expect do + patch :update_mapping_type_de_champ, params: { + procedure_id: procedure.id, + stable_id: stable_id, + id: referentiel.id, + type_de_champ: { referentiel_mapping: } + } + end.to change { type_de_champ.reload.referentiel_mapping }.from(nil).to(referentiel_mapping) + end + end +end diff --git a/spec/factories/referentiel.rb b/spec/factories/referentiel.rb index 77fa3406c8a..328a54fadd4 100644 --- a/spec/factories/referentiel.rb +++ b/spec/factories/referentiel.rb @@ -6,4 +6,19 @@ factory :api_referentiel, class: 'Referentiels::APIReferentiel' do end + + trait :with_last_response do + last_response do + { + status: 200, + body: JSON.parse(File.read("spec/fixtures/files/api_referentiel_rnb.json")) + } + end + end + + trait :configured do + url { 'https://rnb-api.beta.gouv.fr/api/alpha/buildings/{id}/' } + mode { 'exact_match' } + test_data { 'PG46YY6YWCX8' } + end end diff --git a/spec/fixtures/cassettes/referentiel/ko.yml b/spec/fixtures/cassettes/referentiel/ko.yml new file mode 100644 index 00000000000..7c9fcb8dbf5 --- /dev/null +++ b/spec/fixtures/cassettes/referentiel/ko.yml @@ -0,0 +1,55 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.fr/kthxbye/ + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 404 + message: '{}' + headers: + X-Powered-By: + - PHP/8.0.30 + X-Dns-Prefetch-Control: + - 'on' + Expires: + - Wed, 11 Jan 1984 05:00:00 GMT + Cache-Control: + - no-cache, must-revalidate, max-age=0 + Content-Type: + - text/html; charset=UTF-8 + X-Redirect-By: + - WordPress + Location: + - https://www.api.fr/kthxbye/ + X-Litespeed-Cache: + - miss + Content-Length: + - '0' + Date: + - Mon, 13 Jan 2025 16:03:58 GMT + Server: + - LiteSpeed + Platform: + - hostinger + Panel: + - hpanel + Content-Security-Policy: + - upgrade-insecure-requests + Alt-Svc: + - h3=":443"; ma=2592000, h3-29=":443"; ma=2592000, h3-Q050=":443"; ma=2592000, + h3-Q046=":443"; ma=2592000, h3-Q043=":443"; ma=2592000, quic=":443"; ma=2592000; + v="43,46" + body: + encoding: ASCII-8BIT + string: '' + recorded_at: Mon, 13 Jan 2025 16:03:58 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/cassettes/referentiel/test.yml b/spec/fixtures/cassettes/referentiel/test.yml new file mode 100644 index 00000000000..07a3ee713f8 --- /dev/null +++ b/spec/fixtures/cassettes/referentiel/test.yml @@ -0,0 +1,55 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.fr/kthxbye/ + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - demarches-simplifiees.fr + Expect: + - '' + response: + status: + code: 200 + message: '' + headers: + X-Powered-By: + - PHP/8.0.30 + X-Dns-Prefetch-Control: + - 'on' + Expires: + - Wed, 11 Jan 1984 05:00:00 GMT + Cache-Control: + - no-cache, must-revalidate, max-age=0 + Content-Type: + - text/html; charset=UTF-8 + X-Redirect-By: + - WordPress + Location: + - https://www.api.fr/kthxbye/ + X-Litespeed-Cache: + - hit + Content-Length: + - '2' + Date: + - Mon, 23 Dec 2024 16:00:00 GMT + Server: + - LiteSpeed + Platform: + - hostinger + Panel: + - hpanel + Content-Security-Policy: + - upgrade-insecure-requests + Alt-Svc: + - h3=":443"; ma=2592000, h3-29=":443"; ma=2592000, h3-Q050=":443"; ma=2592000, + h3-Q046=":443"; ma=2592000, h3-Q043=":443"; ma=2592000, quic=":443"; ma=2592000; + v="43,46" + body: + encoding: ASCII-8BIT + string: '{}' + recorded_at: Mon, 23 Dec 2024 16:00:00 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/files/api_referentiel_rnb.json b/spec/fixtures/files/api_referentiel_rnb.json new file mode 100644 index 00000000000..181031e208f --- /dev/null +++ b/spec/fixtures/files/api_referentiel_rnb.json @@ -0,0 +1 @@ +{"point":{"type":"Point","coordinates":[-0.570505392116188,44.841034137099996]},"shape":{"type":"MultiPolygon","coordinates":[[[[-0.570280674866822,44.84077010891215],[-0.570266534554258,44.84078678664414],[-0.570216757410612,44.84084066163719],[-0.57021549325346,44.84084070233097],[-0.570165716013095,44.8408945773021],[-0.57038352854418,44.84097318468645],[-0.570493893555193,44.8412346000065],[-0.570498893016816,44.8412335378075],[-0.570500385989195,44.8412370947547],[-0.570646462855594,44.841203551952326],[-0.570645027078788,44.841200894418094],[-0.570648705165646,44.84119897350007],[-0.570648533553692,44.84119627526716],[-0.570656061338606,44.84119513166363],[-0.570628163727203,44.84109508951354],[-0.570555522911404,44.840928893872714],[-0.570442458008472,44.84086403858294],[-0.570414463423371,44.84090189114245],[-0.570361202950893,44.84088107439267],[-0.570387990603872,44.840844161953605],[-0.570319217125699,44.84081843706597],[-0.570320366885589,44.84081659754895],[-0.570321573843764,44.840815657442946],[-0.57032272360352,44.8408138179259],[-0.570323930561594,44.84081287781987],[-0.570323816164808,44.84081107899784],[-0.570325023122807,44.84081013889179],[-0.570324965924399,44.84080923948077],[-0.570326172882338,44.840808299374714],[-0.57032605848549,44.840806500552674],[-0.570326001287068,44.84080560114165],[-0.570327208244913,44.84080466103559],[-0.570327093848039,44.84080286221355],[-0.570327036649606,44.84080196280253],[-0.570326979451173,44.840801063391524],[-0.570325600898172,44.84079930526454],[-0.570325543699765,44.840798405853505],[-0.570324222345258,44.84079754713753],[-0.570324165146875,44.8407966477265],[-0.570322786594071,44.84079488959948],[-0.570321465239689,44.84079403088345],[-0.570320143885347,44.84079317216742],[-0.570318822531043,44.84079231345137],[-0.570280674866822,44.84077010891215]]]]},"rnb_id":"PG46YY6YWCX8","status":"constructed","ext_ids":[{"id":"bdnb-bc-M72P-QAA4-C5NT","source":"bdnb","created_at":"2023-12-09T20:03:28.553322+00:00","source_version":"2023_01"},{"id":"BATIMENT0000000248395542","source":"bdtopo","created_at":"2023-12-21T14:58:52.127485+00:00","source_version":"bdtopo_2023_09"}],"addresses":[{"id":"33063_1155_00002","source":"bdnb","street":"place de la bourse","city_name":"Bordeaux","street_rep":"","city_zipcode":"33000","street_number":"2","city_insee_code":"33063"},{"id":"33063_1155_00008","source":"bdnb","street":"place de la bourse","city_name":"Bordeaux","street_rep":"","city_zipcode":"33000","street_number":"8","city_insee_code":"33063"}],"is_active":true} \ No newline at end of file diff --git a/spec/services/referentiel_service_spec.rb b/spec/services/referentiel_service_spec.rb new file mode 100644 index 00000000000..27f5ed74811 --- /dev/null +++ b/spec/services/referentiel_service_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe ReferentielService, type: :service do + describe '.test' do + let(:api_referentiel) { build(:api_referentiel, url:, test_data:) } + let(:url) { "https://api.fr/{id}/" } + let(:test_data) { "kthxbye" } + subject { described_class.new(referentiel: api_referentiel).test } + + context 'when referentiel works', vcr: 'referentiel/test' do + it { is_expected.to eq(true) } + it 'update referentiel.last_response and body' do + expect { subject }.to change { api_referentiel.last_response }.from(nil).to({ "status" => 200, "body" => {} }) + end + end + + context "when referentiel does not works", vcr: 'referentiel/ko' do + it "update referentiel.last_response with status and body" do + expect { subject }.to change { api_referentiel.last_response }.from(nil).to({ "body" => nil, "status" => 404 }) + end + end + end +end