Merge pull request #4231 from betagouv/api-v2-graphql

Api v2 graphql
This commit is contained in:
Paul Chavard 2019-09-24 10:59:28 +02:00 committed by GitHub
commit e66e2df73a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1672 additions and 1 deletions

View file

@ -32,6 +32,10 @@ gem 'flipper-ui'
gem 'fog-openstack'
gem 'font-awesome-rails'
gem 'gon'
gem 'graphiql-rails'
gem 'graphql'
gem 'graphql-batch'
gem 'graphql-rails_logger'
gem 'groupdate'
gem 'haml-rails'
gem 'hashie'
@ -105,6 +109,7 @@ end
group :development, :test do
gem 'byebug' # Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'graphql-schema_comparator'
gem 'mina', git: 'https://github.com/mina-deploy/mina.git', require: false # Deploy
gem 'pry-byebug'
gem 'rspec-rails'

View file

@ -250,6 +250,22 @@ GEM
actionpack (>= 3.0)
multi_json
request_store (>= 1.0)
graphiql-rails (1.7.0)
railties
sprockets-rails
graphql (1.9.10)
graphql-batch (0.4.1)
graphql (>= 1.3, < 2)
promise.rb (~> 0.7.2)
graphql-rails_logger (1.2.0)
actionpack (~> 5.0)
activesupport (~> 5.0)
railties (~> 5.0)
rouge (~> 3.0)
graphql-schema_comparator (0.6.1)
bundler (>= 1.14)
graphql (~> 1.6)
thor (>= 0.19, < 2.0)
groupdate (4.1.1)
activesupport (>= 4.2)
guard (2.15.0)
@ -430,6 +446,7 @@ GEM
premailer-rails (1.10.2)
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
promise.rb (0.7.4)
pry (0.12.2)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
@ -517,6 +534,7 @@ GEM
activesupport (>= 3.0)
builder (>= 3.0)
rubyzip (>= 1.0)
rouge (3.9.0)
rspec (3.8.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
@ -737,6 +755,11 @@ DEPENDENCIES
fog-openstack
font-awesome-rails
gon
graphiql-rails
graphql
graphql-batch
graphql-rails_logger
graphql-schema_comparator
groupdate
guard
guard-livereload

View file

@ -0,0 +1,20 @@
class API::V2::BaseController < ApplicationController
protect_from_forgery with: :null_session
private
def context
{
administrateur_id: current_administrateur&.id,
token: authorization_bearer_token
}
end
def authorization_bearer_token
received_token = nil
authenticate_with_http_token do |token, _options|
received_token = token
end
received_token
end
end

View file

@ -0,0 +1,45 @@
class API::V2::GraphqlController < API::V2::BaseController
def execute
variables = ensure_hash(params[:variables])
result = Api::V2::Schema.execute(params[:query],
variables: variables,
context: context,
operation_name: params[:operationName])
render json: result
rescue => e
if Rails.env.development?
handle_error_in_development e
else
raise e
end
end
private
# Handle form data, JSON body, or a blank value
def ensure_hash(ambiguous_param)
case ambiguous_param
when String
if ambiguous_param.present?
ensure_hash(JSON.parse(ambiguous_param))
else
{}
end
when Hash, ActionController::Parameters
ambiguous_param
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
end
end
def handle_error_in_development(e)
logger.error e.message
logger.error e.backtrace.join("\n")
render json: { error: { message: e.message, backtrace: e.backtrace }, data: {} }, status: 500
end
end

View file

@ -0,0 +1,70 @@
class Api::V2::Schema < GraphQL::Schema
default_max_page_size 100
max_complexity 300
max_depth 15
query Types::QueryType
mutation Types::MutationType
def self.id_from_object(object, type_definition, ctx)
object.to_typed_id
end
def self.object_from_id(id, query_ctx)
ApplicationRecord.record_from_typed_id(id)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def self.resolve_type(type, obj, ctx)
case obj
when Procedure
Types::DemarcheType
when Dossier
Types::DossierType
when Commentaire
Types::MessageType
when Instructeur, User
Types::ProfileType
else
raise GraphQL::ExecutionError.new("Unexpected object: #{obj}")
end
end
orphan_types Types::Champs::CarteChampType,
Types::Champs::CheckboxChampType,
Types::Champs::DateChampType,
Types::Champs::DecimalNumberChampType,
Types::Champs::DossierLinkChampType,
Types::Champs::IntegerNumberChampType,
Types::Champs::LinkedDropDownListChampType,
Types::Champs::MultipleDropDownListChampType,
Types::Champs::PieceJustificativeChampType,
Types::Champs::RepetitionChampType,
Types::Champs::SiretChampType,
Types::Champs::TextChampType,
Types::GeoAreas::ParcelleCadastraleType,
Types::GeoAreas::QuartierPrioritaireType,
Types::GeoAreas::SelectionUtilisateurType
def self.unauthorized_object(error)
# Add a top-level error to the response instead of returning nil:
raise GraphQL::ExecutionError.new("An object of type #{error.type.graphql_name} was hidden due to permissions", extensions: { code: :unauthorized })
end
middleware(GraphQL::Schema::TimeoutMiddleware.new(max_seconds: 5) do |_, query|
Rails.logger.info("GraphQL Timeout: #{query.query_string}")
end)
if Rails.env.development?
query_analyzer(GraphQL::Analysis::QueryComplexity.new do |_, complexity|
Rails.logger.info("[GraphQL Query Complexity] #{complexity}")
end)
query_analyzer(GraphQL::Analysis::QueryDepth.new do |_, depth|
Rails.logger.info("[GraphQL Query Depth] #{depth}")
end)
end
use GraphQL::Batch
use GraphQL::Tracing::SkylightTracing
end

View file

@ -0,0 +1,36 @@
# references:
# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls
module Extensions
class Attachment < GraphQL::Schema::FieldExtension
attr_reader :attachment_assoc
def apply
# Here we try to define the attachment name:
# - it could be set explicitly via extension options
# - or we imply that is the same as the field name w/o "_url"
# suffix (e.g., "avatar_url" => "avatar")
attachment = options&.[](:attachment) || field.original_name.to_s.sub(/_url$/, "")
# that's the name of the Active Record association
@attachment_assoc = "#{attachment}_attachment"
end
# This method resolves (as it states) the field itself
# (it's the same as defining a method within a type)
def resolve(object:, **_rest)
Loaders::Association.for(
object.object.class,
attachment_assoc => :blob
).load(object.object)
end
# This method is called if the result of the `resolve`
# is a lazy value (e.g., a Promise like in our case)
def after_resolve(value:, **_rest)
return if value.nil?
Rails.application.routes.url_helpers.url_for(value)
end
end
end

View file

@ -0,0 +1,66 @@
# references:
# https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb
# https://gist.github.com/palkan/03eb5306a1a3e8addbe8df97a298a466
# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls
module Loaders
class Association < GraphQL::Batch::Loader
def self.validate(model, association_name)
new(model, association_name)
nil
end
def initialize(model, association_schema)
@model = model
@association_schema = association_schema
@association_name = extract_association_id(association_schema)
validate
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
# We want to load the associations on all records, even if they have the same id
def cache_key(record)
record.object_id
end
def perform(records)
preload_association(records.uniq)
records.each { |record| fulfill(record, read_association(record)) }
end
private
def validate
unless @model.reflect_on_association(@association_name)
raise ArgumentError, "No association #{@association_name} on #{@model}"
end
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new.preload(records, @association_schema)
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
def extract_association_id(id_or_hash)
return id_or_hash unless id_or_hash.is_a?(Hash)
if id_or_hash.keys.size != 1
raise ArgumentError, "You can only preload exactly one association! You passed: #{id_or_hash}"
end
id_or_hash.keys.first
end
end
end

View file

@ -0,0 +1,30 @@
# references:
# https://github.com/Shopify/graphql-batch/blob/master/examples/record_loader.rb
module Loaders
class Record < GraphQL::Batch::Loader
def initialize(model, column: model.primary_key, where: nil)
@model = model
@column = column.to_s
@column_type = model.type_for_attribute(@column)
@where = where
end
def load(key)
super(@column_type.cast(key))
end
def perform(keys)
query(keys).each { |record| fulfill(record.public_send(@column), record) }
keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
end
private
def query(keys)
scope = @model
scope = scope.where(@where) if @where
scope.where(@column => keys)
end
end
end

View file

@ -0,0 +1,4 @@
module Mutations
class BaseMutation < GraphQL::Schema::Mutation
end
end

585
app/graphql/schema.graphql Normal file
View file

@ -0,0 +1,585 @@
type Avis {
answer: String
attachmentUrl: URL
createdAt: ISO8601DateTime!
email: String!
id: ID!
question: String!
}
type CarteChamp implements Champ {
geoAreas: [GeoArea!]!
id: ID!
label: String!
stringValue: String
}
interface Champ {
id: ID!
label: String!
stringValue: String
}
type ChampDescriptor {
description: String
id: ID!
label: String!
required: Boolean!
type: TypeDeChamp!
}
type CheckboxChamp implements Champ {
id: ID!
label: String!
stringValue: String
value: Boolean!
}
"""
GeoJSON coordinates
"""
scalar Coordinates
type DateChamp implements Champ {
id: ID!
label: String!
stringValue: String
value: ISO8601DateTime
}
type DecimalNumberChamp implements Champ {
id: ID!
label: String!
stringValue: String
value: Float
}
"""
Une demarche
"""
type Demarche {
annotationDescriptors: [ChampDescriptor!]!
archivedAt: ISO8601DateTime
champDescriptors: [ChampDescriptor!]!
createdAt: ISO8601DateTime!
"""
Déscription de la démarche.
"""
description: String!
"""
Liste de tous les dossiers d'une démarche.
"""
dossiers(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Filtrer les dossiers par ID.
"""
ids: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Dossiers crées depuis la date.
"""
since: ISO8601DateTime
): DossierConnection!
id: ID!
instructeurs: [Profile!]!
"""
Le numero de la démarche.
"""
number: ID!
state: DemarcheState!
title: String!
updatedAt: ISO8601DateTime!
}
enum DemarcheState {
"""
Archivée
"""
archivee
"""
Brouillon
"""
brouillon
"""
Publiée
"""
publiee
}
"""
Un dossier
"""
type Dossier {
annotations: [Champ!]!
archived: Boolean!
avis: [Avis!]!
champs: [Champ!]!
"""
Date de dépôt.
"""
datePassageEnConstruction: ISO8601DateTime!
"""
Date de passage en instruction.
"""
datePassageEnInstruction: ISO8601DateTime
"""
Date de traitement.
"""
dateTraitement: ISO8601DateTime
id: ID!
instructeurs: [Profile!]!
messages: [Message!]!
motivation: String
motivationAttachmentUrl: URL
"""
Le numero du dossier.
"""
number: ID!
"""
L'état du dossier.
"""
state: DossierState!
"""
Date de dernière mise à jour.
"""
updatedAt: ISO8601DateTime!
usager: Profile!
}
"""
The connection type for Dossier.
"""
type DossierConnection {
"""
A list of edges.
"""
edges: [DossierEdge]
"""
A list of nodes.
"""
nodes: [Dossier]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type DossierEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Dossier
}
type DossierLinkChamp implements Champ {
dossier: Dossier
id: ID!
label: String!
stringValue: String
}
enum DossierState {
"""
Accepté
"""
accepte
"""
En construction
"""
en_construction
"""
En instruction
"""
en_instruction
"""
Refusé
"""
refuse
"""
Sans suite
"""
sans_suite
}
interface GeoArea {
geometry: GeoJSON!
id: ID!
source: GeoAreaSource!
}
enum GeoAreaSource {
"""
translation missing: fr.activerecord.attributes.geo_area.source.cadastre
"""
cadastre
"""
translation missing: fr.activerecord.attributes.geo_area.source.parcelle_agricole
"""
parcelle_agricole
"""
translation missing: fr.activerecord.attributes.geo_area.source.quartier_prioritaire
"""
quartier_prioritaire
"""
translation missing: fr.activerecord.attributes.geo_area.source.selection_utilisateur
"""
selection_utilisateur
}
type GeoJSON {
coordinates: Coordinates!
type: String!
}
"""
An ISO 8601-encoded datetime
"""
scalar ISO8601DateTime
type IntegerNumberChamp implements Champ {
id: ID!
label: String!
stringValue: String
value: Int
}
type LinkedDropDownListChamp implements Champ {
id: ID!
label: String!
primaryValue: String
secondaryValue: String
stringValue: String
}
type Message {
attachmentUrl: URL
body: String!
createdAt: ISO8601DateTime!
email: String!
id: ID!
}
type MultipleDropDownListChamp implements Champ {
id: ID!
label: String!
stringValue: String
values: [String!]!
}
type Mutation {
}
"""
Information about pagination in a connection.
"""
type PageInfo {
"""
When paginating forwards, the cursor to continue.
"""
endCursor: String
"""
When paginating forwards, are there more items?
"""
hasNextPage: Boolean!
"""
When paginating backwards, are there more items?
"""
hasPreviousPage: Boolean!
"""
When paginating backwards, the cursor to continue.
"""
startCursor: String
}
type ParcelleCadastrale implements GeoArea {
codeArr: String!
codeCom: String!
codeDep: String!
feuille: Int!
geometry: GeoJSON!
id: ID!
nomCom: String!
numero: String!
section: String!
source: GeoAreaSource!
surfaceIntersection: Float!
surfaceParcelle: Float!
}
type PersonneMorale {
adresse: String!
codeInseeLocalite: String!
codePostal: String!
complementAdresse: String!
libelleNaf: String!
localite: String!
naf: String!
nomVoie: String!
numeroVoie: String!
siegeSocial: String!
siret: String!
typeVoie: String!
}
type PieceJustificativeChamp implements Champ {
id: ID!
label: String!
stringValue: String
url: URL
}
type Profile {
email: String!
id: ID!
}
type QuartierPrioritaire implements GeoArea {
code: String!
commune: String!
geometry: GeoJSON!
id: ID!
nom: String!
source: GeoAreaSource!
}
type Query {
"""
Informations concernant une démarche.
"""
demarche(
"""
Numéro de la démarche.
"""
number: ID!
): Demarche!
"""
Informations sur un dossier d'une démarche.
"""
dossier(
"""
Numéro du dossier.
"""
number: ID!
): Dossier!
}
type RepetitionChamp implements Champ {
champs: [Champ!]!
id: ID!
label: String!
stringValue: String
}
type SelectionUtilisateur implements GeoArea {
geometry: GeoJSON!
id: ID!
source: GeoAreaSource!
}
type SiretChamp implements Champ {
etablissement: PersonneMorale
id: ID!
label: String!
stringValue: String
}
type TextChamp implements Champ {
id: ID!
label: String!
stringValue: String
value: String
}
enum TypeDeChamp {
"""
Adresse
"""
address
"""
Carte
"""
carte
"""
Case à cocher
"""
checkbox
"""
Civilité
"""
civilite
"""
Date
"""
date
"""
Date et Heure
"""
datetime
"""
Nombre décimal
"""
decimal_number
"""
Départements
"""
departements
"""
Lien vers un autre dossier
"""
dossier_link
"""
Menu déroulant
"""
drop_down_list
"""
Email
"""
email
"""
Engagement
"""
engagement
"""
Explication
"""
explication
"""
Titre de section
"""
header_section
"""
Nombre entier
"""
integer_number
"""
Deux menus déroulants liés
"""
linked_drop_down_list
"""
Menu déroulant à choix multiples
"""
multiple_drop_down_list
"""
Nombre entier
"""
number
"""
Pays
"""
pays
"""
Téléphone
"""
phone
"""
Pièce justificative
"""
piece_justificative
"""
Régions
"""
regions
"""
Bloc répétable
"""
repetition
"""
SIRET
"""
siret
"""
Texte
"""
text
"""
Zone de texte
"""
textarea
"""
Oui/Non
"""
yes_no
}
"""
A valid URL, transported as a string
"""
scalar URL

View file

@ -0,0 +1,12 @@
module Types
class AvisType < Types::BaseObject
global_id_field :id
field :email, String, null: false
field :question, String, null: false, method: :introduction
field :answer, String, null: true
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :attachment_url, Types::URL, null: true, extensions: [
{ Extensions::Attachment => { attachment: :piece_justificative_file } }
]
end
end

View file

@ -0,0 +1,4 @@
module Types
class BaseEnum < GraphQL::Schema::Enum
end
end

View file

@ -0,0 +1,4 @@
module Types
class BaseInputObject < GraphQL::Schema::InputObject
end
end

View file

@ -0,0 +1,5 @@
module Types
module BaseInterface
include GraphQL::Schema::Interface
end
end

View file

@ -0,0 +1,25 @@
module Types
class BaseObject < GraphQL::Schema::Object
def self.authorized_demarche?(demarche, context)
# We are caching authorization logic because it is called for each node
# of the requested graph and can be expensive. Context is reset per request so it is safe.
context[:authorized] ||= {}
if context[:authorized][demarche.id]
return true
end
administrateur = demarche.administrateurs.find do |administrateur|
if context[:token]
administrateur.valid_api_token?(context[:token])
else
administrateur.id == context[:administrateur_id]
end
end
if administrateur && Flipper.enabled?(:administrateur_graphql, administrateur.user)
context[:authorized][demarche.id] = true
true
end
end
end
end

View file

@ -0,0 +1,4 @@
module Types
class BaseScalar < GraphQL::Schema::Scalar
end
end

View file

@ -0,0 +1,4 @@
module Types
class BaseUnion < GraphQL::Schema::Union
end
end

View file

@ -0,0 +1,17 @@
module Types
class ChampDescriptorType < Types::BaseObject
class TypeDeChampType < Types::BaseEnum
TypeDeChamp.type_champs.each do |symbol_name, string_name|
value(string_name,
I18n.t(symbol_name, scope: [:activerecord, :attributes, :type_de_champ, :type_champs]),
value: symbol_name)
end
end
global_id_field :id
field :type, TypeDeChampType, null: false, method: :type_champ
field :label, String, null: false, method: :libelle
field :description, String, null: true
field :required, Boolean, null: false, method: :mandatory?
end
end

View file

@ -0,0 +1,40 @@
module Types
module ChampType
include Types::BaseInterface
global_id_field :id
field :label, String, null: false, method: :libelle
field :string_value, String, null: true, method: :for_api_v2
definition_methods do
def resolve_type(object, context)
case object
when ::Champs::EngagementChamp, ::Champs::YesNoChamp, ::Champs::CheckboxChamp
Types::Champs::CheckboxChampType
when ::Champs::DateChamp, ::Champs::DatetimeChamp
Types::Champs::DateChampType
when ::Champs::DossierLinkChamp
Types::Champs::DossierLinkChampType
when ::Champs::PieceJustificativeChamp
Types::Champs::PieceJustificativeChampType
when ::Champs::CarteChamp
Types::Champs::CarteChampType
when ::Champs::NumberChamp, ::Champs::IntegerNumberChamp
Types::Champs::IntegerNumberChampType
when ::Champs::DecimalNumberChamp
Types::Champs::DecimalNumberChampType
when ::Champs::SiretChamp
Types::Champs::SiretChampType
when ::Champs::RepetitionChamp
Types::Champs::RepetitionChampType
when ::Champs::MultipleDropDownListChamp
Types::Champs::MultipleDropDownListChampType
when ::Champs::LinkedDropDownListChamp
Types::Champs::LinkedDropDownListChampType
else
Types::Champs::TextChampType
end
end
end
end
end

View file

@ -0,0 +1,11 @@
module Types::Champs
class CarteChampType < Types::BaseObject
implements Types::ChampType
field :geo_areas, [Types::GeoAreaType], null: false
def geo_areas
Loaders::Association.for(Champs::CarteChamp, :geo_areas).load(object)
end
end
end

View file

@ -0,0 +1,16 @@
module Types::Champs
class CheckboxChampType < Types::BaseObject
implements Types::ChampType
field :value, Boolean, null: false
def value
case object.value
when 'true', 'on', '1'
true
else
false
end
end
end
end

View file

@ -0,0 +1,13 @@
module Types::Champs
class DateChampType < Types::BaseObject
implements Types::ChampType
field :value, GraphQL::Types::ISO8601DateTime, null: true
def value
if object.value.present?
Time.zone.parse(object.value)
end
end
end
end

View file

@ -0,0 +1,13 @@
module Types::Champs
class DecimalNumberChampType < Types::BaseObject
implements Types::ChampType
field :value, Float, null: true
def value
if object.value.present?
object.value.to_f
end
end
end
end

View file

@ -0,0 +1,13 @@
module Types::Champs
class DossierLinkChampType < Types::BaseObject
implements Types::ChampType
field :dossier, Types::DossierType, null: true
def dossier
if object.value.present?
Loaders::Record.for(Dossier).load(object.value)
end
end
end
end

View file

@ -0,0 +1,13 @@
module Types::Champs
class IntegerNumberChampType < Types::BaseObject
implements Types::ChampType
field :value, Int, null: true
def value
if object.value.present?
object.value.to_i
end
end
end
end

View file

@ -0,0 +1,8 @@
module Types::Champs
class LinkedDropDownListChampType < Types::BaseObject
implements Types::ChampType
field :primary_value, String, null: true
field :secondary_value, String, null: true
end
end

View file

@ -0,0 +1,7 @@
module Types::Champs
class MultipleDropDownListChampType < Types::BaseObject
implements Types::ChampType
field :values, [String], null: false, method: :selected_options
end
end

View file

@ -0,0 +1,10 @@
module Types::Champs
class PieceJustificativeChampType < Types::BaseObject
include Rails.application.routes.url_helpers
implements Types::ChampType
field :url, Types::URL, null: true, extensions: [
{ Extensions::Attachment => { attachment: :piece_justificative_file } }
]
end
end

View file

@ -0,0 +1,11 @@
module Types::Champs
class RepetitionChampType < Types::BaseObject
implements Types::ChampType
field :champs, [Types::ChampType], null: false
def champs
Loaders::Association.for(object.class, :champs).load(object)
end
end
end

View file

@ -0,0 +1,13 @@
module Types::Champs
class SiretChampType < Types::BaseObject
implements Types::ChampType
field :etablissement, Types::PersonneMoraleType, null: true
def etablissement
if object.etablissement_id.present?
Loaders::Record.for(Etablissement).load(object.etablissement_id)
end
end
end
end

View file

@ -0,0 +1,7 @@
module Types::Champs
class TextChampType < Types::BaseObject
implements Types::ChampType
field :value, String, null: true
end
end

View file

@ -0,0 +1,57 @@
module Types
class DemarcheType < Types::BaseObject
class DemarcheState < Types::BaseEnum
Procedure.aasm.states.reject { |state| state.name == :hidden }.each do |state|
value(state.name.to_s, state.display_name, value: state.name)
end
end
description "Une demarche"
global_id_field :id
field :number, ID, "Le numero de la démarche.", null: false, method: :id
field :title, String, null: false, method: :libelle
field :description, String, "Déscription de la démarche.", null: false
field :state, DemarcheState, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
field :archived_at, GraphQL::Types::ISO8601DateTime, null: true
field :instructeurs, [Types::ProfileType], null: false
field :dossiers, Types::DossierType.connection_type, "Liste de tous les dossiers d'une démarche.", null: false do
argument :ids, [ID], required: false, description: "Filtrer les dossiers par ID."
argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers crées depuis la date."
end
field :champ_descriptors, [Types::ChampDescriptorType], null: false, method: :types_de_champ
field :annotation_descriptors, [Types::ChampDescriptorType], null: false, method: :types_de_champ_private
def state
object.aasm.current_state
end
def instructeurs
Loaders::Association.for(Procedure, :instructeurs).load(object)
end
def dossiers(ids: nil, since: nil)
dossiers = object.dossiers.for_api_v2
if ids.present?
dossiers = dossiers.where(id: ids)
end
if since.present?
dossiers = dossiers.since(since)
end
dossiers
end
def self.authorized?(object, context)
authorized_demarche?(object, context)
end
end
end

View file

@ -0,0 +1,68 @@
module Types
class DossierType < Types::BaseObject
class DossierState < Types::BaseEnum
Dossier.aasm.states.reject { |state| state.name == :brouillon }.each do |state|
value(state.name.to_s, state.display_name, value: state.name.to_s)
end
end
description "Un dossier"
global_id_field :id
field :number, ID, "Le numero du dossier.", null: false, method: :id
field :state, DossierState, "L'état du dossier.", null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, "Date de dernière mise à jour.", null: false
field :date_passage_en_construction, GraphQL::Types::ISO8601DateTime, "Date de dépôt.", null: false, method: :en_construction_at
field :date_passage_en_instruction, GraphQL::Types::ISO8601DateTime, "Date de passage en instruction.", null: true, method: :en_instruction_at
field :date_traitement, GraphQL::Types::ISO8601DateTime, "Date de traitement.", null: true, method: :processed_at
field :archived, Boolean, null: false
field :motivation, String, null: true
field :motivation_attachment_url, Types::URL, null: true, extensions: [
{ Extensions::Attachment => { attachment: :justificatif_motivation } }
]
field :usager, Types::ProfileType, null: false
field :instructeurs, [Types::ProfileType], null: false
field :champs, [Types::ChampType], null: false
field :annotations, [Types::ChampType], null: false
field :messages, [Types::MessageType], null: false
field :avis, [Types::AvisType], null: false
def state
object.state
end
def usager
Loaders::Record.for(User).load(object.user_id)
end
def instructeurs
Loaders::Association.for(object.class, :followers_instructeurs).load(object)
end
def messages
Loaders::Association.for(object.class, commentaires: [:instructeur, :user]).load(object)
end
def avis
Loaders::Association.for(object.class, avis: [:instructeur, :claimant]).load(object)
end
def champs
Loaders::Association.for(object.class, :champs).load(object)
end
def annotations
Loaders::Association.for(object.class, :champs_private).load(object)
end
def self.authorized?(object, context)
authorized_demarche?(object.procedure, context)
end
end
end

View file

@ -0,0 +1,30 @@
module Types
module GeoAreaType
include Types::BaseInterface
class GeoAreaSource < Types::BaseEnum
GeoArea.sources.each do |symbol_name, string_name|
value(string_name,
I18n.t(symbol_name, scope: [:activerecord, :attributes, :geo_area, :source]),
value: symbol_name)
end
end
global_id_field :id
field :source, GeoAreaSource, null: false
field :geometry, Types::GeoJSON, null: false
definition_methods do
def resolve_type(object, context)
case object.source
when GeoArea.sources.fetch(:cadastre)
Types::GeoAreas::ParcelleCadastraleType
when GeoArea.sources.fetch(:quartier_prioritaire)
Types::GeoAreas::QuartierPrioritaireType
when GeoArea.sources.fetch(:selection_utilisateur)
Types::GeoAreas::SelectionUtilisateurType
end
end
end
end
end

View file

@ -0,0 +1,15 @@
module Types::GeoAreas
class ParcelleCadastraleType < Types::BaseObject
implements Types::GeoAreaType
field :surface_intersection, Float, null: false
field :surface_parcelle, Float, null: false
field :numero, String, null: false
field :feuille, Int, null: false
field :section, String, null: false
field :code_dep, String, null: false
field :nom_com, String, null: false
field :code_com, String, null: false
field :code_arr, String, null: false
end
end

View file

@ -0,0 +1,9 @@
module Types::GeoAreas
class QuartierPrioritaireType < Types::BaseObject
implements Types::GeoAreaType
field :code, String, null: false
field :nom, String, null: false
field :commune, String, null: false
end
end

View file

@ -0,0 +1,5 @@
module Types::GeoAreas
class SelectionUtilisateurType < Types::BaseObject
implements Types::GeoAreaType
end
end

View file

@ -0,0 +1,14 @@
module Types
class GeoJSON < Types::BaseObject
class CoordinatesType < Types::BaseScalar
description "GeoJSON coordinates"
def self.coerce_result(ruby_value, context)
ruby_value
end
end
field :type, String, null: false
field :coordinates, CoordinatesType, null: false
end
end

View file

@ -0,0 +1,15 @@
module Types
class MessageType < Types::BaseObject
global_id_field :id
field :email, String, null: false
field :body, String, null: false
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :attachment_url, Types::URL, null: true, extensions: [
{ Extensions::Attachment => { attachment: :piece_jointe } }
]
def body
object.body.nil? ? "" : object.body
end
end
end

View file

@ -0,0 +1,4 @@
module Types
class MutationType < Types::BaseObject
end
end

View file

@ -0,0 +1,16 @@
module Types
class PersonneMoraleType < Types::BaseObject
field :siret, String, null: false
field :siege_social, String, null: false
field :naf, String, null: false
field :libelle_naf, String, null: false
field :adresse, String, null: false
field :numero_voie, String, null: false
field :type_voie, String, null: false
field :nom_voie, String, null: false
field :complement_adresse, String, null: false
field :code_postal, String, null: false
field :localite, String, null: false
field :code_insee_localite, String, null: false
end
end

View file

@ -0,0 +1,6 @@
module Types
class ProfileType < Types::BaseObject
global_id_field :id
field :email, String, null: false
end
end

View file

@ -0,0 +1,27 @@
module Types
class QueryType < Types::BaseObject
field :demarche, DemarcheType, null: false, description: "Informations concernant une démarche." do
argument :number, ID, "Numéro de la démarche.", required: true
end
field :dossier, DossierType, null: false, description: "Informations sur un dossier d'une démarche." do
argument :number, ID, "Numéro du dossier.", required: true
end
def demarche(number:)
Procedure.for_api_v2.find(number)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def dossier(number:)
Dossier.for_api_v2.find(number)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def self.accessible?(context)
context[:token] || context[:administrateur_id]
end
end
end

18
app/graphql/types/url.rb Normal file
View file

@ -0,0 +1,18 @@
module Types
class URL < Types::BaseScalar
description "A valid URL, transported as a string"
def self.coerce_input(input_value, context)
url = URI.parse(input_value)
if url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS)
url
else
raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid URL"
end
end
def self.coerce_result(ruby_value, context)
ruby_value.to_s
end
end
end

View file

@ -1,3 +1,19 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.record_from_typed_id(id)
class_name, record_id = GraphQL::Schema::UniqueWithinType.decode(id)
if defined?(class_name)
Object.const_get(class_name).find(record_id)
else
raise ActiveRecord::RecordNotFound, "Unexpected object: #{class_name}"
end
rescue => e
raise ActiveRecord::RecordNotFound, e.message
end
def to_typed_id
GraphQL::Schema::UniqueWithinType.encode(self.class.name, id)
end
end

View file

@ -54,6 +54,10 @@ class Champ < ApplicationRecord
value
end
def for_api_v2
to_s
end
def for_tag
value.present? ? value.to_s : ''
end
@ -62,6 +66,10 @@ class Champ < ApplicationRecord
:value
end
def to_typed_id
type_de_champ.to_typed_id
end
private
def needs_dossier_id?

View file

@ -21,6 +21,10 @@ class Champs::YesNoChamp < Champ
value == 'true'
end
def for_api_v2
true? ? 'true' : 'false'
end
private
def processed_value

View file

@ -163,6 +163,7 @@ class Dossier < ApplicationRecord
}
scope :for_procedure, -> (procedure) { includes(:user, :groupe_instructeur).where(groupe_instructeurs: { procedure: procedure }) }
scope :for_api_v2, -> { includes(procedure: [:administrateurs], etablissement: [], individual: []) }
accepts_nested_attributes_for :individual

View file

@ -62,6 +62,10 @@ class Procedure < ApplicationRecord
accepte: 'accepte'
}
scope :for_api_v2, -> {
includes(administrateurs: :user)
}
validates :libelle, presence: true, allow_blank: false, allow_nil: false
validates :description, presence: true, allow_blank: false, allow_nil: false
validates :administrateurs, presence: true

View file

@ -185,6 +185,10 @@ class TypeDeChamp < ApplicationRecord
self.drop_down_list_attributes = { value: value }
end
def to_typed_id
GraphQL::Schema::UniqueWithinType.encode('Champ', stable_id)
end
private
def set_default_drop_down_list

View file

@ -26,6 +26,7 @@ end
# A list of features to be deployed on first push
features = [
:administrateur_champ_integer_number,
:administrateur_graphql,
:administrateur_web_hook,
:insee_api_v3,
:instructeur_bypass_email_login_token,

View file

@ -0,0 +1,5 @@
GraphQL::RailsLogger.configure do |config|
config.white_list = {
'API::V2::GraphqlController' => ['execute']
}
end

View file

@ -17,3 +17,9 @@ fr:
refuse: "Refusé"
sans_suite: "Sans suite"
autorisation_donnees: Acceptation des CGU
state/brouillon: Brouillon
state/en_construction: En construction
state/en_instruction: En instruction
state/accepte: Accepté
state/refuse: Refusé
state/sans_suite: Sans suite

View file

@ -10,3 +10,7 @@ fr:
organisation: Organisme
duree_conservation_dossiers_dans_ds: Durée de conservation des dossiers sur demarches-simplifiees.fr
duree_conservation_dossiers_hors_ds: Durée de conservation des dossiers hors demarches-simplifiees.fr
aasm_state/brouillon: Brouillon
aasm_state/publiee: Publiée
aasm_state/archivee: Archivée
aasm_state/hidden: Suprimée

View file

@ -219,12 +219,20 @@ Rails.application.routes.draw do
# API
#
authenticated :user, lambda { |user| user.administrateur_id && Flipper.enabled?(:administrateur_graphql, user) } do
mount GraphiQL::Rails::Engine, at: "/graphql", graphql_path: "/api/v2/graphql", via: :get
end
namespace :api do
namespace :v1 do
resources :procedures, only: [:index, :show] do
resources :dossiers, only: [:index, :show]
end
end
namespace :v2 do
post :graphql, to: "graphql#execute"
end
end
#

2
lib/tasks/graphql.rake Normal file
View file

@ -0,0 +1,2 @@
require "graphql/rake_task"
GraphQL::RakeTask.new(schema_name: "Api::V2::Schema", directory: 'app/graphql')

View file

@ -45,7 +45,7 @@
"webpack-dev-server": "^3.7.2"
},
"scripts": {
"lint:ec": "eclint check $({ git ls-files ; find vendor -type f ; echo 'db/schema.rb' ; } | sort | uniq -u)",
"lint:ec": "eclint check $({ git ls-files | grep -v app/graphql/schema.graphql ; find vendor -type f ; echo 'db/schema.rb' ; } | sort | uniq -u)",
"lint:js": "eslint ./app/javascript ./app/assets/javascripts ./config/webpack"
},
"engines": {

View file

@ -0,0 +1,179 @@
require 'spec_helper'
describe API::V2::GraphqlController do
let(:admin) { create(:administrateur) }
let(:token) { admin.renew_api_token }
let(:procedure) { create(:procedure, :with_all_champs, administrateurs: [admin]) }
let(:dossier) do
dossier = create(:dossier,
:en_construction,
:with_all_champs,
procedure: procedure)
create(:commentaire, dossier: dossier, email: 'test@test.com')
dossier
end
let(:query) do
"{
demarche(number: #{procedure.id}) {
id
number
title
description
state
createdAt
updatedAt
archivedAt
champDescriptors {
id
type
label
description
required
}
dossiers {
nodes {
id
}
}
}
}"
end
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 } }
before do
Flipper.enable(:administrateur_graphql, admin.user)
end
context "when authenticated" do
let(:authorization_header) { ActionController::HttpAuthentication::Token.encode_credentials(token) }
before do
request.env['HTTP_AUTHORIZATION'] = authorization_header
end
it "should return demarche" do
expect(gql_errors).to eq(nil)
expect(gql_data).to eq(demarche: {
id: procedure.to_typed_id,
number: procedure.id.to_s,
title: procedure.libelle,
description: procedure.description,
state: 'brouillon',
archivedAt: nil,
createdAt: procedure.created_at.iso8601,
updatedAt: procedure.updated_at.iso8601,
champDescriptors: procedure.types_de_champ.map do |tdc|
{
id: tdc.to_typed_id,
label: tdc.libelle,
type: tdc.type_champ,
description: tdc.description,
required: tdc.mandatory?
}
end,
dossiers: {
nodes: []
}
})
end
context "dossier" do
let(:query) do
"{
dossier(number: #{dossier.id}) {
id
number
state
updatedAt
datePassageEnConstruction
datePassageEnInstruction
dateTraitement
motivation
motivationAttachmentUrl
usager {
id
email
}
instructeurs {
id
email
}
messages {
email
body
attachmentUrl
}
avis {
email
question
answer
attachmentUrl
}
champs {
id
label
stringValue
}
}
}"
end
it "should return dossier" do
expect(gql_errors).to eq(nil)
expect(gql_data).to eq(dossier: {
id: dossier.to_typed_id,
number: dossier.id.to_s,
state: 'en_construction',
updatedAt: dossier.updated_at.iso8601,
datePassageEnConstruction: dossier.en_construction_at.iso8601,
datePassageEnInstruction: nil,
dateTraitement: nil,
motivation: nil,
motivationAttachmentUrl: nil,
usager: {
id: dossier.user.to_typed_id,
email: dossier.user.email
},
instructeurs: [],
messages: dossier.commentaires.map do |commentaire|
{
body: commentaire.body,
attachmentUrl: nil,
email: commentaire.email
}
end,
avis: [],
champs: dossier.champs.map do |champ|
{
id: champ.to_typed_id,
label: champ.libelle,
stringValue: champ.for_api_v2
}
end
})
expect(gql_data[:dossier][:champs][0][:id]).to eq(dossier.champs[0].type_de_champ.to_typed_id)
end
end
end
context "when not authenticated" do
it "should return error" do
expect(gql_data).to eq(nil)
expect(gql_errors).not_to eq(nil)
end
context "dossier" do
let(:query) { "{ dossier(number: #{dossier.id}) { id number usager { email } } }" }
it "should return error" do
expect(gql_data).to eq(nil)
expect(gql_errors).not_to eq(nil)
end
end
end
end

View file

@ -0,0 +1,11 @@
require 'spec_helper'
describe 'graphql' do
let(:current_defn) { Api::V2::Schema.to_definition }
let(:printout_defn) { File.read(Rails.root.join('app', 'graphql', 'schema.graphql')) }
it "update the printed schema with `bin/rake graphql:schema:idl`" do
result = GraphQL::SchemaComparator.compare(current_defn, printout_defn)
expect(result.identical?).to be_truthy
end
end