cpro-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rubocop.yml +42 -0
  4. data/CHANGELOG.md +29 -0
  5. data/CODE_OF_CONDUCT.md +84 -0
  6. data/Gemfile +13 -0
  7. data/Gemfile.lock +79 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +310 -0
  10. data/Rakefile +25 -0
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/cpro-client.gemspec +32 -0
  14. data/lib/cpro/account.rb +32 -0
  15. data/lib/cpro/archive.rb +44 -0
  16. data/lib/cpro/auth/token.rb +36 -0
  17. data/lib/cpro/auth/token_provider.rb +59 -0
  18. data/lib/cpro/cdv/message.rb +230 -0
  19. data/lib/cpro/cdv/statut.rb +80 -0
  20. data/lib/cpro/client.rb +44 -0
  21. data/lib/cpro/configuration.rb +89 -0
  22. data/lib/cpro/connection.rb +19 -0
  23. data/lib/cpro/entities/base.rb +71 -0
  24. data/lib/cpro/entities/changement_statut.rb +27 -0
  25. data/lib/cpro/entities/depot_flux.rb +16 -0
  26. data/lib/cpro/entities/etablissement.rb +96 -0
  27. data/lib/cpro/entities/facture.rb +85 -0
  28. data/lib/cpro/entities/ligne_annuaire.rb +42 -0
  29. data/lib/cpro/entities/lignes_annuaire.rb +45 -0
  30. data/lib/cpro/entities/motif_rejet.rb +24 -0
  31. data/lib/cpro/entities/note_statut.rb +24 -0
  32. data/lib/cpro/entities/resultat_recherche.rb +55 -0
  33. data/lib/cpro/entities/statut_flux.rb +54 -0
  34. data/lib/cpro/entities/unite_legale.rb +47 -0
  35. data/lib/cpro/enveloppe.rb +73 -0
  36. data/lib/cpro/errors.rb +25 -0
  37. data/lib/cpro/facturx.rb +53 -0
  38. data/lib/cpro/middleware/authentication.rb +51 -0
  39. data/lib/cpro/middleware/raise_error.rb +50 -0
  40. data/lib/cpro/resources/annuaire.rb +110 -0
  41. data/lib/cpro/resources/base.rb +61 -0
  42. data/lib/cpro/resources/factures.rb +65 -0
  43. data/lib/cpro/resources/flux.rb +60 -0
  44. data/lib/cpro/version.rb +5 -0
  45. data/lib/cpro-client.rb +3 -0
  46. data/lib/cpro.rb +56 -0
  47. metadata +106 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module Cpro
8
+ module Entities
9
+ class Base
10
+ attr_reader :payload
11
+
12
+ def initialize(payload = nil)
13
+ @payload = payload.is_a?(Hash) ? payload : {}
14
+ end
15
+
16
+ def to_h
17
+ payload
18
+ end
19
+
20
+ def ==(other)
21
+ other.instance_of?(self.class) && other.payload == payload
22
+ end
23
+ alias eql? ==
24
+
25
+ def hash
26
+ [self.class, payload].hash
27
+ end
28
+
29
+ private
30
+
31
+ def dig_payload(*keys)
32
+ keys.reduce(payload) { |value, key| value.is_a?(Hash) ? value[key] : nil }
33
+ end
34
+
35
+ SANS_FUSEAU = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?\z/.freeze
36
+ private_constant :SANS_FUSEAU
37
+
38
+ # Les API G2B datent en UTC mais omettent le marqueur de fuseau. Sans ce rattrapage,
39
+ # Time.parse lirait la valeur comme une heure locale et le même payload donnerait un instant
40
+ # différent selon le fuseau du serveur hôte. Vérifié en sandbox : un dépôt à 20:25:41 UTC est
41
+ # daté « 2026-08-14T20:26:02.125919 » par l'API.
42
+ def parse_time(value)
43
+ return nil if value.nil? || value.to_s.empty?
44
+
45
+ texte = value.to_s
46
+ texte = "#{texte}Z" if texte.match?(SANS_FUSEAU)
47
+ Time.parse(texte)
48
+ rescue ArgumentError
49
+ nil
50
+ end
51
+
52
+ def parse_date(value)
53
+ return nil if value.nil? || value.to_s.empty?
54
+
55
+ Date.parse(value.to_s)
56
+ rescue ArgumentError
57
+ nil
58
+ end
59
+
60
+ # Les montants arrivent en nombres JSON : on repasse par leur représentation textuelle pour
61
+ # éviter de traîner les approximations du flottant sur des sommes d'argent.
62
+ def parse_montant(value)
63
+ return nil if value.nil?
64
+
65
+ BigDecimal(value.to_s)
66
+ rescue ArgumentError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class ChangementStatut < Base
6
+ def statut
7
+ payload["statut"]
8
+ end
9
+
10
+ def date
11
+ @date ||= parse_time(payload["dateEtHeureMiseAJourStatut"])
12
+ end
13
+
14
+ def motifs_rejet
15
+ @motifs_rejet ||= Array(payload["detailStatut"]).map { |detail| MotifRejet.new(detail) }
16
+ end
17
+
18
+ def rejet?
19
+ !motifs_rejet.empty?
20
+ end
21
+
22
+ def to_s
23
+ statut.to_s
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class DepotFlux < Base
6
+ def uid
7
+ payload["uidFlux"]
8
+ end
9
+ alias uid_flux uid
10
+
11
+ def to_s
12
+ uid.to_s
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class Etablissement < Base
6
+ ETABLISSEMENT_PRINCIPAL = "P"
7
+ ETAT_ACTIF = "A"
8
+
9
+ def siret
10
+ payload["siret"]
11
+ end
12
+
13
+ def siren
14
+ payload["siren"]
15
+ end
16
+
17
+ def denomination
18
+ payload["denomination"]
19
+ end
20
+
21
+ def type_etablissement
22
+ payload["typeEtablissement"]
23
+ end
24
+
25
+ def principal?
26
+ type_etablissement == ETABLISSEMENT_PRINCIPAL
27
+ end
28
+
29
+ def etat_administratif
30
+ payload["etatAdministratif"]
31
+ end
32
+
33
+ def actif?
34
+ etat_administratif == ETAT_ACTIF
35
+ end
36
+
37
+ def diffusible
38
+ payload["diffusible"]
39
+ end
40
+
41
+ def adresse
42
+ payload["adresse"] || {}
43
+ end
44
+
45
+ def code_postal
46
+ adresse["codePostal"]
47
+ end
48
+
49
+ def localite
50
+ adresse["localite"]
51
+ end
52
+
53
+ def unite_legale
54
+ return nil unless payload["uniteLegale"]
55
+
56
+ @unite_legale ||= UniteLegale.new(payload["uniteLegale"])
57
+ end
58
+
59
+ def publique?
60
+ unite_legale&.publique?
61
+ end
62
+
63
+ def donnees_b2g
64
+ payload["donneesB2gComplementaires"] || {}
65
+ end
66
+
67
+ def moa?
68
+ donnees_b2g["moa"]
69
+ end
70
+
71
+ def moa_uniquement?
72
+ donnees_b2g["moaUniquement"]
73
+ end
74
+
75
+ def gestion_engagement_juridique?
76
+ donnees_b2g["gestionEngagementJuridique"]
77
+ end
78
+
79
+ def gestion_engagement_juridique_ou_service?
80
+ donnees_b2g["gestionEngagementJuridiqueOuService"]
81
+ end
82
+
83
+ def gestion_code_service?
84
+ donnees_b2g["gestionCodeService"]
85
+ end
86
+
87
+ def gestion_statut_mise_en_paiement?
88
+ donnees_b2g["gestionStatutMiseEnPaiement"]
89
+ end
90
+
91
+ def lignes_annuaire
92
+ @lignes_annuaire ||= LignesAnnuaire.new(payload["lignesAnnuaire"])
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class Facture < Base
6
+ STATUTS = %w[
7
+ DEPOSEE RECUE_DE_LA_PLATEFORME REFUSEE PAIEMENT_TRANSMIS ENCAISSEE REJETEE ANNULEE
8
+ EMISE_PAR_LA_PLATEFORME MISE_A_DISPOSITION PRISE_EN_CHARGE APPROUVEE APPROUVEE_PARTIELLEMENT
9
+ EN_LITIGE SUSPENDUE COMPLETEE VISEE ERREUR_ROUTAGE DEMANDE_DE_PAIEMENT_DIRECT
10
+ CHANGEMENT_DE_COMPTE_A_PAYER NON_AFFACTUREE AFFACTUREE AFFACTUREE_CONFIDENTIEL
11
+ ].freeze
12
+
13
+ CHAMPS = {
14
+ uid: "uidFacture",
15
+ numero: "numeroFacture",
16
+ code_type: "codeTypeFacture",
17
+ devise: "codeDeviseFacture",
18
+ objet: "idObjetFacture",
19
+ type_processus_metier: "typeProcessusMetier",
20
+ numero_facture_anterieure: "numeroFactureAnterieure",
21
+ raison_sociale_vendeur: "raisonSocialeVendeur",
22
+ siren_vendeur: "sirenVendeur",
23
+ siret_vendeur: "siretVendeur",
24
+ raison_sociale_agent_vendeur: "raisonSocialeAgentVendeur",
25
+ siren_agent_vendeur: "sirenAgentVendeur",
26
+ siret_agent_vendeur: "siretAgentVendeur",
27
+ raison_sociale_acheteur: "raisonSocialeAcheteur",
28
+ siren_acheteur: "sirenAcheteur",
29
+ siret_acheteur: "siretAcheteur",
30
+ id_adressage_acheteur: "idAdressageAcheteur",
31
+ siren_tiers_facturant: "sirenTiersFacturant",
32
+ siret_tiers_facturant: "siretTiersFacturant",
33
+ statut: "statutFacture",
34
+ nom_flux: "nomFlux",
35
+ uid_facture_principale: "uidFacturePrincipale"
36
+ }.freeze
37
+
38
+ MONTANTS = {
39
+ montant_total: "montantTotal",
40
+ montant_total_hors_tva: "montantTotalHorsTVA",
41
+ montant_tva: "montantTVA",
42
+ montant_paye: "montantPaye",
43
+ montant_a_payer: "montantAPayer"
44
+ }.freeze
45
+
46
+ CHAMPS.each { |name, key| define_method(name) { payload[key] } }
47
+ MONTANTS.each { |name, key| define_method(name) { parse_montant(payload[key]) } }
48
+ STATUTS.each { |valeur| define_method(:"#{valeur.downcase}?") { statut == valeur } }
49
+
50
+ def initialize(payload = nil, historique: nil)
51
+ super(payload)
52
+ @historique_payload = historique
53
+ end
54
+
55
+ def date_emission
56
+ @date_emission ||= parse_date(payload["dateEmissionFacture"])
57
+ end
58
+
59
+ def date_echeance
60
+ @date_echeance ||= parse_date(payload["dateEcheanceFacture"])
61
+ end
62
+
63
+ def date_statut
64
+ @date_statut ||= parse_time(payload["dateStatutFacture"])
65
+ end
66
+
67
+ def multivendeurs?
68
+ payload["factureMultivendeurs"]
69
+ end
70
+
71
+ # Renseigné par `Factures#find` seulement : la recherche ne retourne pas l'historique.
72
+ def historique
73
+ @historique ||= Array(@historique_payload).map { |changement| ChangementStatut.new(changement) }
74
+ end
75
+
76
+ def motifs_rejet
77
+ historique.flat_map(&:motifs_rejet)
78
+ end
79
+
80
+ def to_s
81
+ [numero, statut].compact.join(" ")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class LigneAnnuaire < Base
6
+ PLATEFORME_ACTIVE = "Plateforme active"
7
+ PLATEFORME_EN_ATTENTE = "Plateforme en attente d'activation"
8
+
9
+ def identifiant_adressage
10
+ payload["identifiantAdressage"]
11
+ end
12
+
13
+ def siren
14
+ payload["siren"]
15
+ end
16
+
17
+ def siret
18
+ payload["siret"]
19
+ end
20
+
21
+ def identifiant_routage
22
+ payload["identifiantRoutage"]
23
+ end
24
+
25
+ def suffixe_adressage
26
+ payload["suffixeAdressage"]
27
+ end
28
+
29
+ def statut_plateforme
30
+ payload["statutPlateforme"]
31
+ end
32
+
33
+ def plateforme_active?
34
+ statut_plateforme == PLATEFORME_ACTIVE
35
+ end
36
+
37
+ def plateforme_en_attente?
38
+ statut_plateforme == PLATEFORME_EN_ATTENTE
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class LignesAnnuaire < Base
6
+ include Enumerable
7
+
8
+ def items
9
+ @items ||= Array(payload["items"]).map { |item| LigneAnnuaire.new(item) }
10
+ end
11
+
12
+ def each(&block)
13
+ items.each(&block)
14
+ end
15
+
16
+ def nombre_total
17
+ payload["nombreTotal"]
18
+ end
19
+
20
+ def limite
21
+ payload["limite"]
22
+ end
23
+
24
+ def ignorer
25
+ payload["ignorer"]
26
+ end
27
+
28
+ def size
29
+ items.size
30
+ end
31
+ alias length size
32
+
33
+ def empty?
34
+ items.empty?
35
+ end
36
+
37
+ # Vrai tant que la page courante ne couvre pas la totalité des lignes rattachées.
38
+ def suite?
39
+ return false if nombre_total.nil?
40
+
41
+ (ignorer.to_i + size) < nombre_total
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class MotifRejet < Base
6
+ # Code du motif de rejet du CDV, selon la norme AFNOR.
7
+ def code
8
+ payload["codeMotifRejet"]
9
+ end
10
+
11
+ def libelle
12
+ payload["libelleMotifRejet"]
13
+ end
14
+
15
+ def notes
16
+ @notes ||= Array(payload["noteStatut"]).map { |note| NoteStatut.new(note) }
17
+ end
18
+
19
+ def to_s
20
+ [code, libelle].compact.join(" — ")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class NoteStatut < Base
6
+ def code
7
+ payload["codeContenu"]
8
+ end
9
+
10
+ def contenu
11
+ payload["contenu"]
12
+ end
13
+
14
+ # Nom du fichier ayant provoqué l'irrecevabilité, lorsque la note porte sur un flux.
15
+ def sujet
16
+ payload["sujet"]
17
+ end
18
+
19
+ def to_s
20
+ [code, contenu].compact.join(" — ")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class ResultatRecherche < Base
6
+ include Enumerable
7
+
8
+ # Les deux API ne nomment pas le total de la même façon — `nombre_total_resultats` pour
9
+ # l'Annuaire, `nombreTotalResultats` pour Recherche Factures — d'où la clé explicite.
10
+ attr_reader :item_class, :total_key
11
+
12
+ def initialize(payload, item_class:, total_key:)
13
+ super(payload)
14
+ @item_class = item_class
15
+ @total_key = total_key
16
+ end
17
+
18
+ def resultats
19
+ @resultats ||= Array(payload["resultats"]).map { |item| item_class.new(item) }
20
+ end
21
+
22
+ def each(&block)
23
+ resultats.each(&block)
24
+ end
25
+
26
+ def nombre_total
27
+ payload[total_key]
28
+ end
29
+
30
+ # Renseignés par Recherche Factures seulement : la recherche d'annuaire ne retourne pas ses
31
+ # paramètres de pagination. Combinés à `nombre_total`, ils permettent de paginer.
32
+ def limite
33
+ payload["limite"]
34
+ end
35
+
36
+ def ignores
37
+ payload["ignores"]
38
+ end
39
+
40
+ def size
41
+ resultats.size
42
+ end
43
+ alias length size
44
+
45
+ def empty?
46
+ resultats.empty?
47
+ end
48
+
49
+ def ==(other)
50
+ super && other.item_class == item_class
51
+ end
52
+ alias eql? ==
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class StatutFlux < Base
6
+ RECEVABLE = "RECEVABLE"
7
+ IRRECEVABLE = "IRRECEVABLE"
8
+ EN_COURS = "ENTRANT_ENVELOPPE_VALIDE"
9
+
10
+ def statut
11
+ payload["statut"]
12
+ end
13
+
14
+ # 500 recevable, 501 irrecevable. Ajouté par Chorus Pro 5.2.2 (19/08/2026).
15
+ def code_statut
16
+ payload["codeStatut"]
17
+ end
18
+
19
+ def recevable?
20
+ statut == RECEVABLE
21
+ end
22
+
23
+ def irrecevable?
24
+ statut == IRRECEVABLE
25
+ end
26
+
27
+ def en_cours?
28
+ statut == EN_COURS
29
+ end
30
+
31
+ # Renseigné par le PPF uniquement lorsque le flux est recevable ; c'est cette valeur qui
32
+ # permet ensuite de retrouver la facture via l'API Recherche Factures G2B.
33
+ def nom_flux
34
+ payload["nomFlux"]
35
+ end
36
+
37
+ def date_maj_statut
38
+ @date_maj_statut ||= parse_time(payload["dateMajStatut"])
39
+ end
40
+
41
+ def motifs_rejet
42
+ @motifs_rejet ||= Array(payload["detailStatut"]).map { |detail| MotifRejet.new(detail) }
43
+ end
44
+
45
+ def rejet?
46
+ !motifs_rejet.empty?
47
+ end
48
+
49
+ def to_s
50
+ statut.to_s
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class UniteLegale < Base
6
+ TYPE_PUBLIQUE = "Publique"
7
+ TYPE_PRIVEE = "Privée assujettie"
8
+ ETAT_ACTIF = "A"
9
+
10
+ def siren
11
+ payload["siren"]
12
+ end
13
+
14
+ def raison_sociale
15
+ payload["raisonSociale"]
16
+ end
17
+
18
+ def type_entite
19
+ payload["typeEntite"]
20
+ end
21
+
22
+ def publique?
23
+ type_entite == TYPE_PUBLIQUE
24
+ end
25
+
26
+ def privee?
27
+ type_entite == TYPE_PRIVEE
28
+ end
29
+
30
+ def etat_administratif
31
+ payload["etatAdministratif"]
32
+ end
33
+
34
+ def actif?
35
+ etat_administratif == ETAT_ACTIF
36
+ end
37
+
38
+ def diffusible
39
+ payload["diffusible"]
40
+ end
41
+
42
+ def lignes_annuaire
43
+ @lignes_annuaire ||= LignesAnnuaire.new(payload["lignesAnnuaire"])
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+
6
+ module Cpro
7
+ class Enveloppe
8
+ # Codes interfaces acceptés par POST /flux. Le dépôt de CDV emprunte le même endpoint que
9
+ # la facture : émettre un statut, c'est déposer un flux.
10
+ CODES_INTERFACE = {
11
+ ubl: "FSO3110A",
12
+ facturx: "FSO3117A",
13
+ ereporting: "FSO6000A",
14
+ cdv: "FSO3300A"
15
+ }.freeze
16
+
17
+ attr_reader :content, :name, :code_interface
18
+
19
+ def self.from(source, code_interface:, name: nil)
20
+ if source.respond_to?(:read)
21
+ resolved = name || (File.basename(source.path) if source.respond_to?(:path))
22
+ new(content: source.read, name: resolved, code_interface: code_interface)
23
+ else
24
+ path = source.to_s
25
+ new(content: File.binread(path), name: name || File.basename(path),
26
+ code_interface: code_interface)
27
+ end
28
+ end
29
+
30
+ def initialize(content:, name:, code_interface:)
31
+ raise ArgumentError, "le contenu du flux est vide" if content.nil? || content.empty?
32
+ raise ArgumentError, "un nom de fichier est requis" if name.nil? || name.to_s.empty?
33
+
34
+ @content = content
35
+ @name = name.to_s
36
+ @code_interface = resolve_code_interface(code_interface)
37
+ end
38
+
39
+ def payload
40
+ {
41
+ "codeInterface" => code_interface,
42
+ "fichierFlux" => fichier_flux,
43
+ "checksum" => checksum
44
+ }
45
+ end
46
+
47
+ def archive
48
+ @archive ||= Archive.gzip([{ name: name, content: content }])
49
+ end
50
+
51
+ def fichier_flux
52
+ @fichier_flux ||= Base64.strict_encode64(archive)
53
+ end
54
+
55
+ # Le swagger Dépôt flux G2B définit le checksum comme le sha256 « du fichier encodé », donc
56
+ # de la chaîne base64 et non des octets de l'archive. Confirmé en sandbox : un flux dont le
57
+ # checksum est calculé ainsi ressort RECEVABLE.
58
+ def checksum
59
+ @checksum ||= Digest::SHA256.hexdigest(fichier_flux)
60
+ end
61
+
62
+ private
63
+
64
+ def resolve_code_interface(value)
65
+ return CODES_INTERFACE.fetch(value) if CODES_INTERFACE.key?(value)
66
+ return value if CODES_INTERFACE.value?(value)
67
+
68
+ raise ArgumentError,
69
+ "code interface inconnu : #{value.inspect} " \
70
+ "(attendus : #{CODES_INTERFACE.keys.join(", ")})"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class ApiError < Error
9
+ attr_reader :status, :body, :correlation_id
10
+
11
+ def initialize(message, status: nil, body: nil, correlation_id: nil)
12
+ @status = status
13
+ @body = body
14
+ @correlation_id = correlation_id
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ class RequestError < ApiError; end
20
+ class AuthenticationError < ApiError; end
21
+ class AuthorizationError < ApiError; end
22
+ class NotFoundError < ApiError; end
23
+ class RateLimitError < ApiError; end
24
+ class ServerError < ApiError; end
25
+ end