Merge pull request #7984 from tchak/graphql-stored-operations

feat(graphql): implement stored queries
This commit is contained in:
Paul Chavard 2022-11-02 11:37:51 +01:00 committed by GitHub
commit 7e97199b7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 872 additions and 11 deletions

View file

@ -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: [

View file

@ -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

View file

@ -4,7 +4,9 @@ class ApplicationRecord < ActiveRecord::Base
def self.record_from_typed_id(id)
class_name, record_id = GraphQL::Schema::UniqueWithinType.decode(id)
if defined?(class_name)
if class_name == 'Dossier'
Dossier.visible_by_administration.find(record_id)
elsif defined?(class_name)
Object.const_get(class_name).find(record_id)
else
raise ActiveRecord::RecordNotFound, "Unexpected object: #{class_name}"

View file

@ -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