diff --git a/app/controllers/api/v2/graphql_controller.rb b/app/controllers/api/v2/graphql_controller.rb index 7d1c2092e..770e37341 100644 --- a/app/controllers/api/v2/graphql_controller.rb +++ b/app/controllers/api/v2/graphql_controller.rb @@ -2,9 +2,7 @@ class API::V2::GraphqlController < API::V2::BaseController include GraphqlOperationLogConcern def execute - variables = ensure_hash(params[:variables]) - - result = API::V2::Schema.execute(params[:query], + result = API::V2::Schema.execute(query, variables: variables, context: context, operation_name: params[:operationName]) @@ -26,7 +24,7 @@ class API::V2::GraphqlController < API::V2::BaseController super payload.merge!({ - graphql_operation: operation_log(params[:query], params[:operationName], to_unsafe_hash(params[:variables])) + graphql_operation: operation_log(query(fallback: ''), params[:operationName], to_unsafe_hash(params[:variables])) }) end @@ -41,6 +39,18 @@ class API::V2::GraphqlController < API::V2::BaseController }, status: 400 end + def query(fallback: nil) + if params[:queryId].present? + API::V2::StoredQuery.get(params[:queryId], fallback: fallback) + else + params[:query] + end + end + + def variables + ensure_hash(params[:variables]) + end + # Handle form data, JSON body, or a blank value def ensure_hash(ambiguous_param) case ambiguous_param @@ -99,11 +109,7 @@ class API::V2::GraphqlController < API::V2::BaseController def handle_error_in_production(exception) id = SecureRandom.uuid - Sentry.capture_exception(exception, extra: { - exception_id: id, - query: params[:query], - variables: params[:variables].to_json - }) + Sentry.capture_exception(exception, extra: { exception_id: id }) render json: { errors: [ diff --git a/app/graphql/api/v2/stored_query.rb b/app/graphql/api/v2/stored_query.rb new file mode 100644 index 000000000..d9ae6c79b --- /dev/null +++ b/app/graphql/api/v2/stored_query.rb @@ -0,0 +1,693 @@ +class API::V2::StoredQuery + def self.get(query_id, fallback: nil) + case query_id + when 'ds-query-v2' + QUERY_V2 + when 'ds-mutation-v2' + MUTATION_V2 + else + if fallback.nil? + raise GraphQL::ExecutionError.new("No query with id \"#{query_id}\"") + else + fallback + end + end + end + + QUERY_V2 = <<-'GRAPHQL' + query getDemarche( + $demarcheNumber: Int! + $state: DossierState + $order: Order + $first: Int + $after: String + $archived: Boolean + $revision: ID + $createdSince: ISO8601DateTime + $updatedSince: ISO8601DateTime + $deletedOrder: Order + $deletedFirst: Int + $deletedAfter: String + $deletedSince: ISO8601DateTime + $includeGroupeInstructeurs: Boolean = false + $includeDossiers: Boolean = false + $includeDeletedDossiers: Boolean = false + $includeRevision: Boolean = false + $includeService: Boolean = false + $includeChamps: Boolean = true + $includeAnotations: Boolean = true + $includeTraitements: Boolean = true + $includeInstructeurs: Boolean = true + $includeAvis: Boolean = false + $includeMessages: Boolean = false + $includeGeometry: Boolean = false + ) { + demarche(number: $demarcheNumber) { + id + number + title + state + declarative + dateCreation + dateFermeture + publishedRevision @include(if: $includeRevision) { + ...RevisionFragment + } + groupeInstructeurs @include(if: $includeGroupeInstructeurs) { + ...GroupeInstructeurFragment + } + service @include(if: $includeService) { + ...ServiceFragment + } + dossiers( + state: $state + order: $order + first: $first + after: $after + archived: $archived + createdSince: $createdSince + updatedSince: $updatedSince + revision: $revision + ) @include(if: $includeDossiers) { + pageInfo { + ...PageInfoFragment + } + nodes { + ...DossierFragment + } + } + deletedDossiers( + order: $deletedOrder + first: $deletedFirst + after: $deletedAfter + deletedSince: $deletedSince + ) @include(if: $includeDeletedDossiers) { + pageInfo { + ...PageInfoFragment + } + nodes { + ...DeletedDossierFragment + } + } + } + } + + query getGroupeInstructeur( + $groupeInstructeurNumber: Int! + $state: DossierState + $order: Order + $first: Int + $after: String + $createdSince: ISO8601DateTime + $updatedSince: ISO8601DateTime + $includeDossiers: Boolean = false + $includeChamps: Boolean = true + $includeAnotations: Boolean = true + $includeTraitements: Boolean = true + $includeInstructeurs: Boolean = true + $includeAvis: Boolean = false + $includeMessages: Boolean = false + $includeGeometry: Boolean = false + ) { + groupeInstructeur(number: $groupeInstructeurNumber) { + id + number + label + instructeurs @include(if: $includeInstructeurs) { + id + email + } + dossiers( + state: $state + order: $order + first: $first + after: $after + createdSince: $createdSince + updatedSince: $updatedSince + ) @include(if: $includeDossiers) { + pageInfo { + ...PageInfoFragment + } + nodes { + ...DossierFragment + } + } + } + } + + query getDossier( + $dossierNumber: Int! + $includeRevision: Boolean = false + $includeService: Boolean = false + $includeChamps: Boolean = true + $includeAnotations: Boolean = true + $includeTraitements: Boolean = true + $includeInstructeurs: Boolean = true + $includeAvis: Boolean = false + $includeMessages: Boolean = false + $includeGeometry: Boolean = false + ) { + dossier(number: $dossierNumber) { + ...DossierFragment + demarche { + ...DemarcheDescriptorFragment + } + } + } + + fragment ServiceFragment on Service { + nom + siret + organisme + typeOrganisme + } + + fragment GroupeInstructeurFragment on GroupeInstructeur { + id + number + label + instructeurs @include(if: $includeInstructeurs) { + id + email + } + } + + fragment DossierFragment on Dossier { + id + number + archived + state + dateDerniereModification + dateDepot + datePassageEnConstruction + datePassageEnInstruction + dateTraitement + dateExpiration + dateSuppressionParUsager + motivation + motivationAttachment { + ...FileFragment + } + attestation { + ...FileFragment + } + pdf { + url + } + usager { + email + } + groupeInstructeur { + ...GroupeInstructeurFragment + } + demandeur { + ... on PersonnePhysique { + civilite + nom + prenom + dateDeNaissance + } + ...PersonneMoraleFragment + } + demarche { + revision { + id + } + } + instructeurs @include(if: $includeInstructeurs) { + id + email + } + traitements @include(if: $includeTraitements) { + state + emailAgentTraitant + dateTraitement + motivation + } + champs @include(if: $includeChamps) { + ...ChampFragment + ...RootChampFragment + } + annotations @include(if: $includeAnotations) { + ...ChampFragment + ...RootChampFragment + } + avis @include(if: $includeAvis) { + ...AvisFragment + } + messages @include(if: $includeMessages) { + ...MessageFragment + } + } + + fragment DemarcheDescriptorFragment on DemarcheDescriptor { + id + number + title + description + state + declarative + dateCreation + datePublication + dateDerniereModification + dateDepublication + dateFermeture + service @include(if: $includeService) { + ...ServiceFragment + } + revision @include(if: $includeRevision) { + ...RevisionFragment + } + } + + fragment DeletedDossierFragment on DeletedDossier { + id + number + dateSupression + state + reason + } + + fragment RevisionFragment on Revision { + id + datePublication + champDescriptors { + ...ChampDescriptorFragment + champDescriptors { + ...ChampDescriptorFragment + } + } + annotationDescriptors { + ...ChampDescriptorFragment + champDescriptors { + ...ChampDescriptorFragment + } + } + } + + fragment ChampDescriptorFragment on ChampDescriptor { + id + type + label + description + required + options + } + + fragment AvisFragment on Avis { + id + question + reponse + dateQuestion + dateReponse + claimant { + email + } + expert { + email + } + attachment { + ...FileFragment + } + } + + fragment MessageFragment on Message { + id + email + body + createdAt + attachment { + ...FileFragment + } + } + + fragment GeoAreaFragment on GeoArea { + id + source + description + geometry @include(if: $includeGeometry) { + type + coordinates + } + ... on ParcelleCadastrale { + commune + numero + section + prefixe + surface + } + } + + fragment RootChampFragment on Champ { + ... on RepetitionChamp { + rows { + champs { + ...ChampFragment + } + } + } + ... on CarteChamp { + geoAreas { + ...GeoAreaFragment + } + } + ... on DossierLinkChamp { + dossier { + id + number + state + } + } + } + + fragment ChampFragment on Champ { + id + __typename + label + stringValue + ... on DateChamp { + date + } + ... on DatetimeChamp { + datetime + } + ... on CheckboxChamp { + checked: value + } + ... on DecimalNumberChamp { + decimalNumber: value + } + ... on IntegerNumberChamp { + integerNumber: value + } + ... on CiviliteChamp { + civilite: value + } + ... on LinkedDropDownListChamp { + primaryValue + secondaryValue + } + ... on MultipleDropDownListChamp { + values + } + ... on PieceJustificativeChamp { + file { + ...FileFragment + } + } + ... on AddressChamp { + address { + ...AddressFragment + } + } + ... on CommuneChamp { + commune { + name + code + } + departement { + name + code + } + } + ... on SiretChamp { + etablissement { + ...PersonneMoraleFragment + } + } + } + + fragment PersonneMoraleFragment on PersonneMorale { + siret + siegeSocial + naf + libelleNaf + address { + ...AddressFragment + } + entreprise { + siren + capitalSocial + numeroTvaIntracommunautaire + formeJuridique + formeJuridiqueCode + nomCommercial + raisonSociale + siretSiegeSocial + codeEffectifEntreprise + dateCreation + nom + prenom + attestationFiscaleAttachment { + ...FileFragment + } + attestationSocialeAttachment { + ...FileFragment + } + } + association { + rna + titre + objet + dateCreation + dateDeclaration + datePublication + } + } + + fragment FileFragment on File { + filename + contentType + checksum + byteSize: byteSizeBigInt + url + } + + fragment AddressFragment on Address { + label + type + streetAddress + streetNumber + streetName + postalCode + cityName + cityCode + departmentName + departmentCode + regionName + regionCode + } + + fragment PageInfoFragment on PageInfo { + hasPreviousPage + hasNextPage + endCursor + } + GRAPHQL + + MUTATION_V2 = <<-'GRAPHQL' + mutation dossierArchiver($input: DossierArchiverInput!) { + dossierArchiver(input: $input) { + dossier { + id + archived + } + errors { + message + } + } + } + + mutation dossierPasserEnInstruction($input: DossierPasserEnInstructionInput!) { + dossierPasserEnInstruction(input: $input) { + dossier { + id + state + } + errors { + message + } + } + } + + mutation dossierRepasserEnConstruction( + $input: DossierRepasserEnConstructionInput! + ) { + dossierRepasserEnConstruction(input: $input) { + dossier { + id + state + } + errors { + message + } + } + } + + mutation dossierAccepter($input: DossierAccepterInput!) { + dossierAccepter(input: $input) { + dossier { + id + state + attestation { + url + } + } + errors { + message + } + } + } + + mutation dossierRefuser($input: DossierRefuserInput!) { + dossierRefuser(input: $input) { + dossier { + id + state + } + errors { + message + } + } + } + + mutation dossierClasserSansSuite($input: DossierClasserSansSuiteInput!) { + dossierClasserSansSuite(input: $input) { + dossier { + id + state + } + errors { + message + } + } + } + + mutation dossierRepasserEnInstruction( + $input: DossierRepasserEnInstructionInput! + ) { + dossierRepasserEnInstruction(input: $input) { + dossier { + id + state + } + errors { + message + } + } + } + + mutation dossierEnvoyerMessage($input: DossierEnvoyerMessageInput!) { + dossierEnvoyerMessage(input: $input) { + message { + id + createdAt + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationText( + $input: DossierModifierAnnotationTextInput! + ) { + dossierModifierAnnotationText(input: $input) { + annotation { + id + value: stringValue + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationCheckbox( + $input: DossierModifierAnnotationCheckboxInput! + ) { + dossierModifierAnnotationCheckbox(input: $input) { + annotation { + id + ... on CheckboxChamp { + value + } + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationDate( + $input: DossierModifierAnnotationDateInput! + ) { + dossierModifierAnnotationDate(input: $input) { + annotation { + id + ... on DateChamp { + value: date + } + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationDateTime( + $input: DossierModifierAnnotationDatetimeInput! + ) { + dossierModifierAnnotationDatetime(input: $input) { + annotation { + id + ... on DatetimeChamp { + value: datetime + } + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationIntegerNumber( + $input: DossierModifierAnnotationIntegerNumberInput! + ) { + dossierModifierAnnotationIntegerNumber(input: $input) { + annotation { + id + ... on IntegerNumberChamp { + value + } + } + errors { + message + } + } + } + + mutation dossierModifierAnnotationAjouterLigne( + $input: DossierModifierAnnotationAjouterLigneInput! + ) { + dossierModifierAnnotationAjouterLigne(input: $input) { + annotation { + id + } + errors { + message + } + } + } + + mutation createDirectUpload($input: CreateDirectUploadInput!) { + createDirectUpload(input: $input) { + directUpload { + signedBlobId + headers + url + } + } + } + GRAPHQL +end diff --git a/spec/controllers/api/v2/graphql_controller_spec.rb b/spec/controllers/api/v2/graphql_controller_spec.rb index 7d51c5d27..7ecc06aba 100644 --- a/spec/controllers/api/v2/graphql_controller_spec.rb +++ b/spec/controllers/api/v2/graphql_controller_spec.rb @@ -95,11 +95,13 @@ describe API::V2::GraphqlController do }" end let(:variables) { {} } + let(:operation_name) { nil } + let(:query_id) { nil } let(:body) { JSON.parse(subject.body, symbolize_names: true) } let(:gql_data) { body[:data] } let(:gql_errors) { body[:errors] } - subject { post :execute, params: { query: query, variables: variables } } + subject { post :execute, params: { query: query, variables: variables, operationName: operation_name, queryId: query_id }.compact, as: :json } context "when authenticated with legacy token" do let(:authorization_header) { ActionController::HttpAuthentication::Token.encode_credentials(legacy_token) } @@ -822,6 +824,164 @@ describe API::V2::GraphqlController do end end + describe 'stored queries' do + let(:procedure) { create(:procedure, :published, :for_individual, administrateurs: [admin]) } + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) } + let(:query_id) { 'ds-query-v2' } + + context 'not found operation id' do + let(:query_id) { 'ds-query-v0' } + + it { + expect(gql_errors.first[:message]).to eq('No query with id "ds-query-v0"') + } + end + + context 'not found operation name' do + let(:operation_name) { 'getStuff' } + + it { + expect(gql_errors.first[:message]).to eq('No operation named "getStuff"') + } + end + + context 'getDossier' do + let(:variables) { { dossierNumber: dossier.id } } + let(:operation_name) { 'getDossier' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossier][:id]).to eq(dossier.to_typed_id) + } + end + + context 'getDemarche' do + let(:variables) { { demarcheNumber: procedure.id } } + let(:operation_name) { 'getDemarche' } + + before { dossier } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:demarche][:id]).to eq(procedure.to_typed_id) + expect(gql_data[:demarche][:dossiers]).to be_nil + } + + context 'include Dossiers' do + let(:variables) { { demarcheNumber: procedure.id, includeDossiers: true } } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:demarche][:id]).to eq(procedure.to_typed_id) + expect(gql_data[:demarche][:dossiers][:nodes].size).to eq(1) + } + end + end + + context 'mutation' do + let(:query_id) { 'ds-mutation-v2' } + + context 'not found operation name' do + let(:operation_name) { 'dossierStuff' } + + it { + expect(gql_errors.first[:message]).to eq('No operation named "dossierStuff"') + } + end + + context 'dossierArchiver' do + let(:dossier) { create(:dossier, :refuse, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierArchiver' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierArchiver][:errors]).to be_nil + expect(gql_data[:dossierArchiver][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierArchiver][:dossier][:archived]).to be_truthy + } + end + + context 'dossierPasserEnInstruction' do + let(:dossier) { create(:dossier, :en_construction, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierPasserEnInstruction' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierPasserEnInstruction][:errors]).to be_nil + expect(gql_data[:dossierPasserEnInstruction][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierPasserEnInstruction][:dossier][:state]).to eq('en_instruction') + } + end + + context 'dossierRepasserEnConstruction' do + let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierRepasserEnConstruction' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierRepasserEnConstruction][:errors]).to be_nil + expect(gql_data[:dossierRepasserEnConstruction][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierRepasserEnConstruction][:dossier][:state]).to eq('en_construction') + } + end + + context 'dossierRepasserEnInstruction' do + let(:dossier) { create(:dossier, :refuse, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierRepasserEnInstruction' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierRepasserEnInstruction][:errors]).to be_nil + expect(gql_data[:dossierRepasserEnInstruction][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierRepasserEnInstruction][:dossier][:state]).to eq('en_instruction') + } + end + + context 'dossierAccepter' do + let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id } } } + let(:operation_name) { 'dossierAccepter' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierAccepter][:errors]).to be_nil + expect(gql_data[:dossierAccepter][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierAccepter][:dossier][:state]).to eq('accepte') + } + end + + context 'dossierRefuser' do + let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id, motivation: 'yolo' } } } + let(:operation_name) { 'dossierRefuser' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierRefuser][:errors]).to be_nil + expect(gql_data[:dossierRefuser][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierRefuser][:dossier][:state]).to eq('refuse') + } + end + + context 'dossierClasserSansSuite' do + let(:dossier) { create(:dossier, :en_instruction, :with_individual, procedure: procedure) } + let(:variables) { { input: { dossierId: dossier.to_typed_id, instructeurId: instructeur.to_typed_id, motivation: 'yolo' } } } + let(:operation_name) { 'dossierClasserSansSuite' } + + it { + expect(gql_errors).to be_nil + expect(gql_data[:dossierClasserSansSuite][:errors]).to be_nil + expect(gql_data[:dossierClasserSansSuite][:dossier][:id]).to eq(dossier.to_typed_id) + expect(gql_data[:dossierClasserSansSuite][:dossier][:state]).to eq('sans_suite') + } + end + end + end + describe "mutations" do describe 'dossierEnvoyerMessage' do let(:query) do