ecoportal-api-graphql 2.2.0 → 3.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +448 -31
- data/README.gem.md +53 -0
- data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
- data/lib/ecoportal/api/common/graphql/client.rb +38 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
- data/lib/ecoportal/api/common/graphql/model/diffable/leaf_diff_service.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
- data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
- data/lib/ecoportal/api/graphql/base.rb +1 -0
- data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +8 -3
- data/lib/ecoportal/api/graphql/fragment/permissions.rb +0 -2
- data/lib/ecoportal/api/graphql/input/page/update.rb +109 -6
- data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_template_container_uid.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/manage_copy_page_configuration.rb +1 -1
- data/lib/ecoportal/api/graphql/input/workflow_command/move_section_to_stage.rb +33 -0
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +2 -0
- data/lib/ecoportal/api/graphql/logic/base_query.rb +1 -1
- data/lib/ecoportal/api/graphql/model/permissions.rb +6 -0
- data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
- data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
- data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
- data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
- data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
- data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
- data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
- data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
- data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
- data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
- data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
- data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
- data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
- data/lib/ecoportal/api/graphql/model/template.rb +33 -0
- data/lib/ecoportal/api/graphql/model.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
- data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
- data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
- data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
- data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
- data/lib/ecoportal/api/graphql/mutation.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql/query/pages.rb +1 -1
- data/lib/ecoportal/api/graphql/query/permissions.rb +1 -1
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +36 -2
- data/README.md +0 -24
data/README.gem.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ecoportal-api-graphql
|
|
2
|
+
|
|
3
|
+
A Ruby client for the ecoPortal GraphQL API: typed models for pages, templates, data fields and
|
|
4
|
+
registers, query and mutation helpers, and the workflow command bus for granular template edits.
|
|
5
|
+
|
|
6
|
+
This is the README shipped inside the published gem. The source repository keeps its own
|
|
7
|
+
developer README.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'ecoportal-api-graphql', require: %w[ecoportal/api-graphql]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ bundle
|
|
17
|
+
# or: $ gem install ecoportal-api-graphql
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
| | version |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Ruby | `>= 3.2.2` |
|
|
25
|
+
| `ecoportal-api` | `~> 0.10, >= 0.10.17` |
|
|
26
|
+
| `ecoportal-api-v2` | `~> 3.3, >= 3.3.5` |
|
|
27
|
+
| `graphlient` | `>= 0.9.0, < 0.10` |
|
|
28
|
+
|
|
29
|
+
## Example: load and edit a template
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
api = Ecoportal::API::GraphQL.new(email: ..., pass: ..., org_id: ...)
|
|
33
|
+
template = Ecoportal::API::GraphQL::Model::Template.load(api, id: template_page_id)
|
|
34
|
+
template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
|
|
35
|
+
template.save!(api, simulate: false) # simulate: true is the default -- inspect first
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`save!` simulates by default: it reports the commands it would send without writing anything.
|
|
39
|
+
Pass `simulate: false` to apply them.
|
|
40
|
+
|
|
41
|
+
## Optional extensions
|
|
42
|
+
|
|
43
|
+
Some capabilities are distributed separately as optional extensions and are not part of this
|
|
44
|
+
gem. The gem works fully without them; when an extension is installed, it is picked up
|
|
45
|
+
automatically.
|
|
46
|
+
|
|
47
|
+
## Changes
|
|
48
|
+
|
|
49
|
+
See `CHANGELOG.md`, shipped alongside this file.
|
|
50
|
+
|
|
51
|
+
## Licence
|
|
52
|
+
|
|
53
|
+
MIT -- see `LICENSE`.
|
|
@@ -25,7 +25,7 @@ module Ecoportal
|
|
|
25
25
|
def session_token_renewed(host: server, refresh_token: nil)
|
|
26
26
|
unless refresh_token
|
|
27
27
|
return unless (body = session_token_data(host: host))
|
|
28
|
-
return unless (refresh_token = body['
|
|
28
|
+
return unless (refresh_token = body['refresh_token'])
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
session_refresh_token_data(
|
|
@@ -56,12 +56,50 @@ module Ecoportal
|
|
|
56
56
|
@org_id || fetch_env_required('ORGANIZATION_ID')
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Graphlient/graphql-client build the client-side schema from the server's raw
|
|
60
|
+
# introspection JSON (GraphQL::Schema.from_introspection -> GraphQL::Schema::Loader).
|
|
61
|
+
# That loader defines one GraphQL::Schema::InputObject subclass per INPUT_OBJECT type,
|
|
62
|
+
# but it never calls `has_no_arguments(true)` on the ones whose `inputFields` came back
|
|
63
|
+
# empty. graphql-ruby then warns (and, in a future version, raises) the first time
|
|
64
|
+
# anything asks such a type for its arguments -- which happens whenever this schema is
|
|
65
|
+
# re-serialized (e.g. `schema.to_json`/`to_definition`, as the `-graphql-schema` use
|
|
66
|
+
# case in eco-helpers does). The live server never emits this warning because its own
|
|
67
|
+
# schema classes are defined directly in Ruby with real argument lists (or with
|
|
68
|
+
# `has_no_arguments(true)` already set) -- it is only the client-side schema, rebuilt
|
|
69
|
+
# purely from introspection data, that loses that annotation.
|
|
70
|
+
#
|
|
71
|
+
# Patching the built classes here (rather than pre-processing the introspection JSON)
|
|
72
|
+
# keeps the schema's shape untouched and uses graphql-ruby's own documented escape
|
|
73
|
+
# hatch for this exact situation.
|
|
74
|
+
def schema
|
|
75
|
+
mark_argumentless_input_objects!(super)
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
private
|
|
60
79
|
|
|
61
80
|
def url
|
|
62
81
|
base_url = Ecoportal::API::Common::GraphQL::HttpClient.base_url(host)
|
|
63
82
|
"#{base_url}/api/#{org_id}/#{ENDPOINT_PATH}"
|
|
64
83
|
end
|
|
84
|
+
|
|
85
|
+
def mark_argumentless_input_objects!(loaded_schema)
|
|
86
|
+
return loaded_schema if @argumentless_input_objects_marked
|
|
87
|
+
|
|
88
|
+
graphql_schema_types(loaded_schema).each_value do |type|
|
|
89
|
+
next unless type.respond_to?(:kind) && type.kind.input_object?
|
|
90
|
+
next unless type.respond_to?(:has_no_arguments) && type.respond_to?(:any_arguments?)
|
|
91
|
+
|
|
92
|
+
type.has_no_arguments(true) unless type.any_arguments?
|
|
93
|
+
end
|
|
94
|
+
@argumentless_input_objects_marked = true
|
|
95
|
+
|
|
96
|
+
loaded_schema
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def graphql_schema_types(loaded_schema)
|
|
100
|
+
target = loaded_schema.respond_to?(:graphql_schema) ? loaded_schema.graphql_schema : loaded_schema
|
|
101
|
+
target.types
|
|
102
|
+
end
|
|
65
103
|
end
|
|
66
104
|
end
|
|
67
105
|
end
|
|
@@ -96,9 +96,11 @@ module Ecoportal
|
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
ENDPOINT_PATH
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
ENDPOINT_PATH = 'external/graphql'.freeze
|
|
100
|
+
CONNECT_TIMEOUT = 30
|
|
101
|
+
READ_TIMEOUT = 90
|
|
102
|
+
WRITE_TIMEOUT = 90
|
|
103
|
+
KEEP_ALIVE_TIMEOUT = 5
|
|
102
104
|
|
|
103
105
|
attr_reader :host, :version
|
|
104
106
|
|
|
@@ -137,6 +139,36 @@ module Ecoportal
|
|
|
137
139
|
|
|
138
140
|
# Creates a HTTP object adding the `X-ApiKey` or `X-ECOPORTAL-API-KEY` param to the header, depending on the API version.
|
|
139
141
|
# @note It configures HTTP so it only allows body data in json format.
|
|
142
|
+
# @note This is the ONLY place in the gem's dependency chain that talks to the
|
|
143
|
+
# server via http.rb (`require 'http'`, pulled in by `Ecoportal::API::Common::Client`
|
|
144
|
+
# from the `ecoportal-api` gem) -- every live GraphQL query goes through `#execute`
|
|
145
|
+
# below, called from `Logic::BaseQuery#graphql_query` via `client.http_client.execute`,
|
|
146
|
+
# and `AuthService#auth_http_client` (`version: 'http'`) reuses this same
|
|
147
|
+
# `base_request` for the OAuth token POST. `Common::GraphQL::Client` (graphlient) is a
|
|
148
|
+
# separate, unrelated path: it never reaches http.rb at all.
|
|
149
|
+
#
|
|
150
|
+
# Without an explicit `connect:`, http.rb's `HTTP::Timeout::PerOperation` falls back
|
|
151
|
+
# to its own default of 0.25s for CONNECT even though read/write were already given
|
|
152
|
+
# explicitly (per_operation.rb: `options.fetch(:connect_timeout, CONNECT_TIMEOUT)`,
|
|
153
|
+
# `CONNECT_TIMEOUT = 0.25`). `connect_ssl` (`http/timeout/per_operation.rb`) uses
|
|
154
|
+
# that same `@connect_timeout` for the TLS handshake via `rescue_readable`/
|
|
155
|
+
# `rescue_writable` (`http/timeout/null.rb`) -- whose error text is unconditionally
|
|
156
|
+
# "Read timed out after #{@read_timeout} seconds", so a slow TLS handshake on the EU
|
|
157
|
+
# instance surfaced as `HTTP::TimeoutError: Read timed out after 0.25 seconds`
|
|
158
|
+
# even though `@read_timeout` itself was correctly 90 the whole time. Live-confirmed
|
|
159
|
+
# 2026-09-08 via backtrace: `connect_ssl` -> `start_tls` (`http/connection.rb`) ->
|
|
160
|
+
# `Common::Client#post` -> `AuthService#session_token_data` ->
|
|
161
|
+
# `Common::GraphQL::Client#initialize`. Read/write were never the problem; only
|
|
162
|
+
# connect was missing.
|
|
163
|
+
# @note The client is made PERSISTENT (`HTTP.persistent`) to `base_url`, not a
|
|
164
|
+
# plain one-shot client. Confirmed live 2026-09-08: eu.live.ecoportal.com
|
|
165
|
+
# closes an HTTP/1.1 request carrying `Connection: close` -- what a
|
|
166
|
+
# non-persistent http.rb client always sends -- without ever responding. A
|
|
167
|
+
# persistent client sends `Connection: keep-alive` and reuses the TCP/TLS
|
|
168
|
+
# connection instead, which the SAME Ruby stack proved fine against EU. This
|
|
169
|
+
# also matches Cloudflare keep-alive hygiene; Sydney is fine either way. One
|
|
170
|
+
# `HttpClient` is bound to one `host`, so `HTTP::StateError` (raised only on a
|
|
171
|
+
# cross-origin request against a persistent client) cannot occur here.
|
|
140
172
|
# @return [HTTP] HTTP object.
|
|
141
173
|
def base_request
|
|
142
174
|
@base_request ||=
|
|
@@ -151,9 +183,10 @@ module Ecoportal
|
|
|
151
183
|
HTTP.headers('Authorization' => "Bearer #{session_token(host: host)}")
|
|
152
184
|
end.then do |request|
|
|
153
185
|
request ||= HTTP
|
|
154
|
-
request.accept(:json).timeout(
|
|
155
|
-
|
|
156
|
-
|
|
186
|
+
request.persistent(base_url, timeout: KEEP_ALIVE_TIMEOUT).accept(:json).timeout(
|
|
187
|
+
connect: CONNECT_TIMEOUT,
|
|
188
|
+
read: READ_TIMEOUT,
|
|
189
|
+
write: WRITE_TIMEOUT
|
|
157
190
|
)
|
|
158
191
|
end
|
|
159
192
|
end
|
|
@@ -15,7 +15,7 @@ module Ecoportal::API::Common::GraphQL::Model::Diffable
|
|
|
15
15
|
# `CollectionModel` — neither is a `GraphQL::Model`, so the re-add never happens and
|
|
16
16
|
# the array change becomes **invisible to `as_update`** (scalar `passthrough` fields
|
|
17
17
|
# are unaffected). This is the systemic "array/leaf fields don't diff" root cause
|
|
18
|
-
# (see
|
|
18
|
+
# (see the repo's internal docs §3.1).
|
|
19
19
|
#
|
|
20
20
|
# This service fixes it for leaf models by:
|
|
21
21
|
# 1. keeping cascaded keys in the flat classic diff (`doc_with_non_cascaded_attributes`
|
|
@@ -31,7 +31,7 @@ module Ecoportal::API::GraphQL::Base::Page
|
|
|
31
31
|
|
|
32
32
|
# Find first field matching label (case-insensitive). Optional type: restricts to
|
|
33
33
|
# that field type first (v2 compat: components.get_by_name(name, type:) — used by
|
|
34
|
-
#
|
|
34
|
+
# a customer cutover use case which calls it directly, not via OozeRedirect).
|
|
35
35
|
def get_by_name(label, type: nil)
|
|
36
36
|
pool = type ? get_by_type(type) : @fields
|
|
37
37
|
pool.find { |f| f.label.to_s.downcase == label.to_s.downcase }
|
|
@@ -1,26 +1,174 @@
|
|
|
1
1
|
module Ecoportal::API::GraphQL::Base::Page
|
|
2
2
|
class DataField
|
|
3
3
|
# Image gallery field.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
#
|
|
5
|
+
# ★ FIXED 2026-09 (previously broken -- see `spec/.../image_gallery_characterization_spec.rb`
|
|
6
|
+
# git history for the prior "WRITE PATH BROKEN" documentation of this defect). Confirmed
|
|
7
|
+
# against the LIVE ecoPortal server source (read-only, /tmp/work/ecoPortal) that a
|
|
8
|
+
# gallery image is genuinely NOT a `FileContainer`:
|
|
9
|
+
# * `app/graphql/types/fill_in_page/inputs/data_fields/image_gallery_input.rb:5-11` --
|
|
10
|
+
# `ImageGalleryInput` has NO `fileContainerIds`; it takes `images: [ImageInput]`.
|
|
11
|
+
# * `.../image_galleries/image_input.rb:6-15` -- `ImageInput` takes `sourceId` (a
|
|
12
|
+
# `TempImage` id, from the SEPARATE `uploadImage` mutation -- see
|
|
13
|
+
# `Mutation::Image::Upload` / `FileUpload::Client#upload_image`), never
|
|
14
|
+
# `fileContainerId`.
|
|
15
|
+
# * `app/services/new_ep/pages/assign_attributes_service.rb:152-176`
|
|
16
|
+
# (`set_image_gallery_attrs`) -- full-replace-by-omission, the SAME trap as
|
|
17
|
+
# `FileField`: any current image whose `id` is not echoed back in the write is
|
|
18
|
+
# destroyed (`format_removed_ids`).
|
|
19
|
+
# * `app/graphql/types/pages/data_fields/image_galleries/image_type.rb` -- `fileName`/
|
|
20
|
+
# `fileSize` are NULLABLE even on a successful upload -- a documented upstream bug
|
|
21
|
+
# ("images are successfully uploaded but the file name and size are not set in the
|
|
22
|
+
# database", per the live schema's own field comment). Any comparison built on these
|
|
23
|
+
# two fields must treat a nil on either side as "not a confident match", never a
|
|
24
|
+
# positive one.
|
|
25
|
+
#
|
|
26
|
+
# READ: `images { id weight downloadUrl caption fileName fileSize uploadId }`
|
|
27
|
+
# (`imageGalleryField` fragment, `fragment/pages/common_page_union.rb` -- `id`/`weight`
|
|
28
|
+
# were ADDED alongside this fix; the fragment previously omitted both, which is why no
|
|
29
|
+
# correct reader was possible before now).
|
|
30
|
+
#
|
|
31
|
+
# WRITE: `ImageGalleryInput { id images: [ImageInput] }`; `ImageInput { id weight
|
|
32
|
+
# sourceId caption fileName sensitiveContent inaccurateDescription
|
|
33
|
+
# inaccurateExtractedText }`. `as_input` sends every KEPT image back with its FULL
|
|
34
|
+
# `ImageInput` field set (`#kept_image_input`) -- NOT a bare `{id:}` -- and every NEW one
|
|
35
|
+
# as `{sourceId:, weight:, fileName:}` -- see `#add_source_images`, the merge-safe writer
|
|
36
|
+
# (mirrors `FileField#file_container_ids=`'s kept-vs-new split).
|
|
37
|
+
#
|
|
38
|
+
# ★ CORRECTED 2026-09 (pre-release, MR !278 unreleased): the bare `{id:}` echo for a kept
|
|
39
|
+
# image, above, silently dropped every OTHER attribute on that image on the NEXT write
|
|
40
|
+
# server-side -- `assign_attributes_service.rb#set_image_gallery_attrs` applies `images`
|
|
41
|
+
# as a FULL REPLACE (see the class header above), so a bare `{id:}` is indistinguishable
|
|
42
|
+
# from "clear caption/sensitiveContent/etc back to their defaults" for that image, not
|
|
43
|
+
# "leave it as it is". Matches the org-side reference implementation (a downstream script repo,
|
|
44
|
+
# commit 482db9c) and the live schema's own `ImageInput` argument list -- the shipped
|
|
45
|
+
# `ep-api-collections` sample only demonstrates the NEW-image shape (no existing images to
|
|
46
|
+
# keep), so this shape is pinned to server source + that proven reference, not a captured
|
|
47
|
+
# kept-echo payload (see EVIDENCE PRECEDENCE note on `#kept_image_input`).
|
|
48
|
+
#
|
|
49
|
+
# `images` is deliberately a PLAIN doc reader, not `passarray` -- same reasoning as
|
|
50
|
+
# `FileField#items` (see that class's header): materialising an ArrayModel writes
|
|
51
|
+
# `images: []` into the doc of a field read without `$content`, and a phantom-dirty empty
|
|
52
|
+
# `images` here doesn't always no-op (only the empty-vs-missing-key case is neutralised
|
|
53
|
+
# by `Diffable::LeafDiffService`'s array-diff fix, MR !106) -- it can still synthesise an
|
|
54
|
+
# unwanted write for a non-empty phantom read. A plain reader has no such hazard.
|
|
9
55
|
class ImageGallery < DataField
|
|
10
|
-
|
|
56
|
+
# each: { 'id' =>, 'weight' =>, 'downloadUrl' =>, 'caption' =>, 'fileName' =>,
|
|
57
|
+
# 'fileSize' =>, 'uploadId' => }
|
|
58
|
+
def images
|
|
59
|
+
Array(doc['images'])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Array<String>] every image's own item id (its server-assigned id, NOT the
|
|
63
|
+
# `uploadId`/`TempImage` id it was created from).
|
|
64
|
+
def image_ids
|
|
65
|
+
images.filter_map { |image| image['id'] if image.is_a?(Hash) }
|
|
66
|
+
end
|
|
11
67
|
|
|
12
|
-
|
|
13
|
-
|
|
68
|
+
# @return [Integer] one past the highest `weight` currently on the field (`0` if the
|
|
69
|
+
# field is empty). There is no server-side auto-increment for `weight` (a plain
|
|
70
|
+
# Integer field, default `0`) -- callers that want sequential ordering for several new
|
|
71
|
+
# images in one write should still call this ONCE and increment locally
|
|
72
|
+
# (`#add_source_images` already does).
|
|
73
|
+
def next_weight
|
|
74
|
+
return 0 if images.empty?
|
|
75
|
+
|
|
76
|
+
images.map { |image| image.is_a?(Hash) ? image['weight'].to_i : 0 }.max + 1
|
|
14
77
|
end
|
|
15
78
|
|
|
16
|
-
|
|
17
|
-
|
|
79
|
+
# Merge-safe write: adds NEW images (each referenced by `source_id`, the `TempImage`
|
|
80
|
+
# id from `FileUpload::Client#upload_image`) while KEEPING every image already loaded
|
|
81
|
+
# on this field. `images` on `ImageGalleryInput` is a FULL REPLACE server-side -- any
|
|
82
|
+
# current image whose `id` is not echoed back is destroyed -- so this always keeps
|
|
83
|
+
# every currently-loaded image's FULL doc entry (untouched -- `#images_input` picks the
|
|
84
|
+
# `ImageInput`-writable subset out of it later; kept HERE in full so that subset still
|
|
85
|
+
# has every field to read, rather than collapsing to a bare `{'id' => ...}` that would
|
|
86
|
+
# throw the other attributes away before `#as_input` ever runs) and appends the new
|
|
87
|
+
# ones after it, at sequential weights starting at `#next_weight`. Marks the field dirty
|
|
88
|
+
# (same underlying mechanism as `FileField#file_container_ids=` -- a raw `doc` mutation,
|
|
89
|
+
# picked up by the generic diff regardless of the `passarray`/plain-reader choice above).
|
|
90
|
+
#
|
|
91
|
+
# @param sources [Array<Hash>] one entry per NEW image. Accepts either key style:
|
|
92
|
+
# `{source_id:, file_name:}` or `{'sourceId' =>, 'fileName' =>}`. `weight` is assigned
|
|
93
|
+
# here, not read from the input -- the caller does not need to compute it.
|
|
94
|
+
# @return [Array<Hash>] the full resulting `images` doc (kept + added), same value now
|
|
95
|
+
# readable via `#images`.
|
|
96
|
+
def add_source_images(sources)
|
|
97
|
+
kept = images.select { |image| image.is_a?(Hash) && image['id'] }
|
|
98
|
+
start = next_weight
|
|
99
|
+
added = Array(sources).each_with_index.map do |source, index|
|
|
100
|
+
{
|
|
101
|
+
'sourceId' => fetch_key(source, :source_id, 'sourceId'),
|
|
102
|
+
'weight' => start + index,
|
|
103
|
+
'fileName' => fetch_key(source, :file_name, 'fileName')
|
|
104
|
+
}.compact
|
|
105
|
+
end
|
|
106
|
+
doc['images'] = kept + added
|
|
18
107
|
end
|
|
19
108
|
|
|
20
109
|
def as_input
|
|
21
110
|
return nil unless dirty?
|
|
22
111
|
|
|
23
|
-
{
|
|
112
|
+
{imageGallery: {id: id, images: images_input}}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# `images` holds the write shape for a NEW item (`{'sourceId' =>, 'weight' =>,
|
|
118
|
+
# 'fileName' =>}`, from `#add_source_images`) as-is, and the FULL read doc for a KEPT
|
|
119
|
+
# item (untouched since the last read) -- `#kept_image_input` narrows the latter down to
|
|
120
|
+
# exactly what `ImageInput` accepts.
|
|
121
|
+
def images_input
|
|
122
|
+
images.filter_map do |image|
|
|
123
|
+
next unless image.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
if image['sourceId']
|
|
126
|
+
{sourceId: image['sourceId'], weight: image['weight'], fileName: image['fileName']}.compact
|
|
127
|
+
elsif image['id']
|
|
128
|
+
kept_image_input(image)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Full-field echo for a KEPT (currently-existing, untouched) image -- REQUIRED because
|
|
134
|
+
# `ImageGalleryInput.images` is a full replace server-side (`assign_attributes_service.
|
|
135
|
+
# rb#set_image_gallery_attrs` -> `format_removed_ids`): a bare `{id:}` is read by the
|
|
136
|
+
# server as "clear every other attribute", not "leave this image as it is".
|
|
137
|
+
#
|
|
138
|
+
# EVIDENCE PRECEDENCE (shipped sample -> server source -> gem model): the shipped
|
|
139
|
+
# a customer collection's "Attach Image to Gallery Field"
|
|
140
|
+
# sample only demonstrates a BRAND-NEW image (`sourceId`/`weight`/`fileName`, no
|
|
141
|
+
# existing images on the field to keep) -- it does not capture a kept-image echo. This
|
|
142
|
+
# shape is therefore pinned to the next level down: the live schema's own `ImageInput`
|
|
143
|
+
# argument list (`app/graphql/types/fill_in_page/inputs/data_fields/image_galleries/
|
|
144
|
+
# image_input.rb`: `id weight sourceId caption fileName sensitiveContent
|
|
145
|
+
# inaccurateDescription inaccurateExtractedText`) plus the org-side reference
|
|
146
|
+
# implementation already built against those same server facts and merged
|
|
147
|
+
# (a downstream script repo, commit 482db9c, `#kept_gallery_image_input`) -- not an inference
|
|
148
|
+
# from this gem's own prior (incorrect) behaviour.
|
|
149
|
+
#
|
|
150
|
+
# `sourceId` here is read back from the CURRENT read's `uploadId` -- the read-side name
|
|
151
|
+
# for the same identifier (a customer collection's integration guide
|
|
152
|
+
# section 7: "the id changes name between reading and writing"). Every key is sent as
|
|
153
|
+
# read (never `.compact`-ed away) -- this is an ECHO of the field's current state, so a
|
|
154
|
+
# nil here means "this attribute is genuinely unset", which is exactly what should be
|
|
155
|
+
# (re-)written to preserve it unset, not omitted and misread as "clear it".
|
|
156
|
+
def kept_image_input(image)
|
|
157
|
+
{
|
|
158
|
+
id: image['id'],
|
|
159
|
+
sourceId: image['uploadId'],
|
|
160
|
+
weight: image['weight'],
|
|
161
|
+
caption: image['caption'],
|
|
162
|
+
fileName: image['fileName'],
|
|
163
|
+
sensitiveContent: image['sensitiveContent'],
|
|
164
|
+
inaccurateDescription: image['inaccurateDescription'],
|
|
165
|
+
inaccurateExtractedText: image['inaccurateExtractedText']
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def fetch_key(hash, *keys)
|
|
170
|
+
keys.each { |k| return hash[k] if hash.is_a?(Hash) && hash.key?(k) }
|
|
171
|
+
nil
|
|
24
172
|
end
|
|
25
173
|
end
|
|
26
174
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Base
|
|
5
|
+
# The upload-staging model behind `uploadImage` -- explicitly NOT a `FileContainer`.
|
|
6
|
+
# Verified against the live ecoPortal server schema (read-only,
|
|
7
|
+
# /tmp/work/ecoPortal): `Types::Files::TempImageType` (`app/graphql/types/files/
|
|
8
|
+
# temp_image_type.rb`) declares exactly `id s3Key error complete`, backed by
|
|
9
|
+
# `Enzyme::ImageReference::TempImage` (`app/models/enzyme/image_reference.rb`), a
|
|
10
|
+
# separate upload-staging Mongoid model from `FileContainer`.
|
|
11
|
+
#
|
|
12
|
+
# `complete` starts `false` immediately after `uploadImage` returns and is filled in
|
|
13
|
+
# later by background processing. A consumer does NOT need to wait for it: confirmed
|
|
14
|
+
# server-side (`app/services/new_ep/pages/assign_attributes_service.rb`'s
|
|
15
|
+
# `set_image_gallery_attrs`) that attaching a `TempImage` to an Image Gallery field via
|
|
16
|
+
# `ImageInput.sourceId` succeeds whether or not `complete?`/`error?` is true yet -- an
|
|
17
|
+
# incomplete image is accepted and filled in on the record later.
|
|
18
|
+
class TempImage < Logic::BaseModel
|
|
19
|
+
read_only!
|
|
20
|
+
|
|
21
|
+
passkey :id
|
|
22
|
+
passthrough :s3Key, :error
|
|
23
|
+
passboolean :complete
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -15,6 +15,7 @@ require_relative 'base/location_structure'
|
|
|
15
15
|
require_relative 'base/person_member'
|
|
16
16
|
require_relative 'base/file_attachment'
|
|
17
17
|
require_relative 'base/file_container'
|
|
18
|
+
require_relative 'base/temp_image'
|
|
18
19
|
require_relative 'base/field'
|
|
19
20
|
require_relative 'base/page'
|
|
20
21
|
require_relative 'base/resource'
|
|
@@ -9,12 +9,17 @@ module Ecoportal
|
|
|
9
9
|
@client = client
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# @param register_id [String, nil] bind the new template to this register. Optional in
|
|
13
|
+
# the schema, but in practice REQUIRED for the create to be authorized at all — see
|
|
14
|
+
# the WHY in {Mutation::Template::Create}. Omitted when nil.
|
|
15
|
+
def create(commands:, register_id: nil, &block)
|
|
16
|
+
createMutation.query(commands: commands, register_id: register_id, &block)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
def update(model, commands:, patch_ver: model.patchVer, &block)
|
|
17
|
-
|
|
19
|
+
def update(model, commands:, patch_ver: model.patchVer, client_mutation_id: nil, &block)
|
|
20
|
+
kwargs = { id: model.id, patch_ver: patch_ver, commands: commands }
|
|
21
|
+
kwargs[:client_mutation_id] = client_mutation_id unless client_mutation_id.nil?
|
|
22
|
+
updateMutation.query(**kwargs, &block)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def publish(id:, &block)
|
|
@@ -18,7 +18,7 @@ module Ecoportal
|
|
|
18
18
|
# tag_filter passed through (same ES backend, same format)
|
|
19
19
|
# register_filter passed through
|
|
20
20
|
# and_filter / or_filter passed through
|
|
21
|
-
# Operation/param shapes per
|
|
21
|
+
# Operation/param shapes per the repo's internal docs
|
|
22
22
|
class FilterTranslator
|
|
23
23
|
class << self
|
|
24
24
|
def to_graphql(filter)
|