graphql-stitching 1.0.0 → 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +21 -1
- data/lib/graphql/stitching/boundary.rb +29 -0
- data/lib/graphql/stitching/client.rb +5 -5
- data/lib/graphql/stitching/composer/validate_boundaries.rb +6 -6
- data/lib/graphql/stitching/composer/validate_interfaces.rb +2 -2
- data/lib/graphql/stitching/composer.rb +14 -14
- data/lib/graphql/stitching/executor/boundary_source.rb +24 -16
- data/lib/graphql/stitching/executor/root_source.rb +18 -10
- data/lib/graphql/stitching/executor.rb +12 -14
- data/lib/graphql/stitching/plan.rb +67 -0
- data/lib/graphql/stitching/planner.rb +91 -125
- data/lib/graphql/stitching/planner_step.rb +71 -0
- data/lib/graphql/stitching/request.rb +9 -51
- data/lib/graphql/stitching/selection_hint.rb +31 -0
- data/lib/graphql/stitching/shaper.rb +4 -2
- data/lib/graphql/stitching/skip_include.rb +81 -0
- data/lib/graphql/stitching/supergraph.rb +29 -23
- data/lib/graphql/stitching/util.rb +49 -38
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +5 -2
- metadata +7 -3
- data/lib/graphql/stitching/planner_operation.rb +0 -63
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4a0b6a79a253614edadb1151490e42e32164e9f10c8e110806978160efd8b482
|
4
|
+
data.tar.gz: ff9d5a50b4bc41392a2777fc7bbdf401820a0fea899ef43736047f0efb196742
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ffb8d232df37ff5b858f95344566774c314132c89bd4353b84ad603d54f1242581df452aef50db0ec4193428a6de72179063bf1213cd74ce0038b0a5cd5aad9b
|
7
|
+
data.tar.gz: 585e94aaaf146a642f36cb184c91e69aacd8ed9988fd4c4cfc350feb83662b4955cb72d5efe2bdf7606f198a5f03b51d0f809fade8ba8e25367479d3d5902c44
|
data/README.md
CHANGED
@@ -9,7 +9,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
9
9
|
- Multiple keys per merged type.
|
10
10
|
- Shared objects, fields, enums, and inputs across locations.
|
11
11
|
- Combining local and remote schemas.
|
12
|
-
- Type merging via federation `_entities` protocol.
|
12
|
+
- Type merging via arbitrary queries or federation `_entities` protocol.
|
13
13
|
|
14
14
|
**NOT Supported:**
|
15
15
|
- Computed fields (ie: federation-style `@requires`).
|
@@ -400,6 +400,26 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
|
|
400
400
|
|
401
401
|
The `GraphQL::Stitching::HttpExecutable` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
|
402
402
|
|
403
|
+
## Batching
|
404
|
+
|
405
|
+
The stitching executor automatically batches subgraph requests so that only one request is made per location per generation of data. This is done using batched queries that combine all data access for a given a location. For example:
|
406
|
+
|
407
|
+
```graphql
|
408
|
+
query MyOperation_2 {
|
409
|
+
_0_result: widgets(ids:["a","b","c"]) { ... } # << 3 Widget
|
410
|
+
_1_0_result: sprocket(id:"x") { ... } # << 1 Sprocket
|
411
|
+
_1_1_result: sprocket(id:"y") { ... } # << 1 Sprocket
|
412
|
+
_1_2_result: sprocket(id:"z") { ... } # << 1 Sprocket
|
413
|
+
}
|
414
|
+
```
|
415
|
+
|
416
|
+
Tips:
|
417
|
+
|
418
|
+
* List queries (like the `widgets` selection above) are more compact for accessing multiple records, and are therefore preferable as stitching accessors.
|
419
|
+
* Assure that root field resolvers across your subgraph implement batching to anticipate cases like the three `sprocket` selections above.
|
420
|
+
|
421
|
+
Otherwise, there's no developer intervention necessary (or generally possible) to improve upon data access. Note that multiple generations of data may still force the executor to return to a previous location for more data.
|
422
|
+
|
403
423
|
## Concurrency
|
404
424
|
|
405
425
|
The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based implementation of `GraphQL::Dataloader`. Non-blocking concurrency requires setting a fiber scheduler via `Fiber.set_scheduler`, see [graphql-ruby docs](https://graphql-ruby.org/dataloader/nonblocking.html). You may also need to build your own remote clients using corresponding HTTP libraries.
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL
|
4
|
+
module Stitching
|
5
|
+
# Defines a boundary query that provides direct access to an entity type.
|
6
|
+
Boundary = Struct.new(
|
7
|
+
:location,
|
8
|
+
:type_name,
|
9
|
+
:key,
|
10
|
+
:field,
|
11
|
+
:arg,
|
12
|
+
:list,
|
13
|
+
:federation,
|
14
|
+
keyword_init: true
|
15
|
+
) do
|
16
|
+
def as_json
|
17
|
+
{
|
18
|
+
location: location,
|
19
|
+
type_name: type_name,
|
20
|
+
key: key,
|
21
|
+
field: field,
|
22
|
+
arg: arg,
|
23
|
+
list: list,
|
24
|
+
federation: federation,
|
25
|
+
}.tap(&:compact!)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -41,7 +41,7 @@ module GraphQL
|
|
41
41
|
GraphQL::Stitching::Planner.new(
|
42
42
|
supergraph: @supergraph,
|
43
43
|
request: request,
|
44
|
-
).perform
|
44
|
+
).perform
|
45
45
|
end
|
46
46
|
|
47
47
|
GraphQL::Stitching::Executor.new(
|
@@ -76,16 +76,16 @@ module GraphQL
|
|
76
76
|
def fetch_plan(request)
|
77
77
|
if @on_cache_read
|
78
78
|
cached_plan = @on_cache_read.call(request.digest, request.context)
|
79
|
-
return JSON.parse(cached_plan) if cached_plan
|
79
|
+
return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
|
80
80
|
end
|
81
81
|
|
82
|
-
|
82
|
+
plan = yield
|
83
83
|
|
84
84
|
if @on_cache_write
|
85
|
-
@on_cache_write.call(request.digest, JSON.generate(
|
85
|
+
@on_cache_write.call(request.digest, JSON.generate(plan.as_json), request.context)
|
86
86
|
end
|
87
87
|
|
88
|
-
|
88
|
+
plan
|
89
89
|
end
|
90
90
|
|
91
91
|
def error_result(errors)
|
@@ -32,16 +32,16 @@ module GraphQL
|
|
32
32
|
|
33
33
|
# only one boundary allowed per type/location/key
|
34
34
|
boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
|
35
|
-
if memo.dig(boundary
|
36
|
-
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary
|
37
|
-
"found in #{boundary
|
35
|
+
if memo.dig(boundary.location, boundary.key)
|
36
|
+
raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary.key}` "\
|
37
|
+
"found in #{boundary.location}. Limit one boundary query per type and key in each location. "\
|
38
38
|
"Abstract boundaries provide all possible types."
|
39
39
|
end
|
40
|
-
memo[boundary
|
41
|
-
memo[boundary
|
40
|
+
memo[boundary.location] ||= {}
|
41
|
+
memo[boundary.location][boundary.key] = boundary
|
42
42
|
end
|
43
43
|
|
44
|
-
boundary_keys = boundaries.map { _1
|
44
|
+
boundary_keys = boundaries.map { _1.key }.uniq
|
45
45
|
key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
|
46
46
|
subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
|
47
47
|
end
|
@@ -32,12 +32,12 @@ module GraphQL
|
|
32
32
|
interface_type_structure.each_with_index do |interface_struct, index|
|
33
33
|
possible_struct = possible_type_structure[index]
|
34
34
|
|
35
|
-
if possible_struct
|
35
|
+
if possible_struct.name != interface_struct.name
|
36
36
|
raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
|
37
37
|
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
38
38
|
end
|
39
39
|
|
40
|
-
if possible_struct
|
40
|
+
if possible_struct.null? && interface_struct.non_null?
|
41
41
|
raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
|
42
42
|
"#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
|
43
43
|
end
|
@@ -430,21 +430,21 @@ module GraphQL
|
|
430
430
|
raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
|
431
431
|
end
|
432
432
|
|
433
|
-
if alt_structure.last
|
433
|
+
if alt_structure.last.name != basis_structure.last.name
|
434
434
|
raise ComposerError, "Cannot compose mixed types at `#{path}`."
|
435
435
|
end
|
436
436
|
end
|
437
437
|
|
438
438
|
type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
|
439
|
-
basis_structure.last
|
440
|
-
build_type_binding(basis_structure.last
|
439
|
+
basis_structure.last.name,
|
440
|
+
build_type_binding(basis_structure.last.name)
|
441
441
|
)
|
442
442
|
|
443
443
|
basis_structure.reverse!.each_with_index do |basis, index|
|
444
444
|
rev_index = basis_structure.length - index - 1
|
445
|
-
non_null = alt_structures.each_with_object([
|
445
|
+
non_null = alt_structures.each_with_object([basis.non_null?]) { |s, m| m << s[rev_index].non_null? }
|
446
446
|
|
447
|
-
type = type.to_list_type if basis
|
447
|
+
type = type.to_list_type if basis.list?
|
448
448
|
type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
|
449
449
|
end
|
450
450
|
|
@@ -509,15 +509,15 @@ module GraphQL
|
|
509
509
|
end
|
510
510
|
|
511
511
|
@boundary_map[impl_type_name] ||= []
|
512
|
-
@boundary_map[impl_type_name] <<
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
512
|
+
@boundary_map[impl_type_name] << Boundary.new(
|
513
|
+
location: location,
|
514
|
+
type_name: impl_type_name,
|
515
|
+
key: key_selections[0].name,
|
516
|
+
field: field_candidate.name,
|
517
|
+
arg: argument_name,
|
518
|
+
list: boundary_structure.first.list?,
|
519
|
+
federation: kwargs[:federation],
|
520
|
+
)
|
521
521
|
end
|
522
522
|
end
|
523
523
|
end
|
@@ -10,20 +10,24 @@ module GraphQL
|
|
10
10
|
|
11
11
|
def fetch(ops)
|
12
12
|
origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
|
13
|
-
origin_set = op
|
13
|
+
origin_set = op.path.reduce([@executor.data]) do |set, path_segment|
|
14
14
|
set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
|
15
15
|
end
|
16
16
|
|
17
|
-
if op
|
17
|
+
if op.if_type
|
18
18
|
# operations planned around unused fragment conditions should not trigger requests
|
19
|
-
origin_set.select! { _1[
|
19
|
+
origin_set.select! { _1[SelectionHint.typename_node.alias] == op.if_type }
|
20
20
|
end
|
21
21
|
|
22
22
|
memo[op] = origin_set if origin_set.any?
|
23
23
|
end
|
24
24
|
|
25
25
|
if origin_sets_by_operation.any?
|
26
|
-
query_document, variable_names = build_document(
|
26
|
+
query_document, variable_names = build_document(
|
27
|
+
origin_sets_by_operation,
|
28
|
+
@executor.request.operation_name,
|
29
|
+
@executor.request.operation_directives,
|
30
|
+
)
|
27
31
|
variables = @executor.request.variables.slice(*variable_names)
|
28
32
|
raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
|
29
33
|
@executor.query_count += 1
|
@@ -34,7 +38,7 @@ module GraphQL
|
|
34
38
|
@executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
|
35
39
|
end
|
36
40
|
|
37
|
-
ops.map { origin_sets_by_operation[_1] ? _1
|
41
|
+
ops.map { origin_sets_by_operation[_1] ? _1.step : nil }
|
38
42
|
end
|
39
43
|
|
40
44
|
# Builds batched boundary queries
|
@@ -44,24 +48,24 @@ module GraphQL
|
|
44
48
|
# _1_1_result: item(key:"y") { boundarySelections... }
|
45
49
|
# _1_2_result: item(key:"z") { boundarySelections... }
|
46
50
|
# }"
|
47
|
-
def build_document(origin_sets_by_operation, operation_name = nil)
|
51
|
+
def build_document(origin_sets_by_operation, operation_name = nil, operation_directives = nil)
|
48
52
|
variable_defs = {}
|
49
53
|
query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
|
50
|
-
variable_defs.merge!(op
|
51
|
-
boundary = op
|
54
|
+
variable_defs.merge!(op.variables)
|
55
|
+
boundary = op.boundary
|
52
56
|
|
53
|
-
if boundary
|
57
|
+
if boundary.list
|
54
58
|
input = origin_set.each_with_index.reduce(String.new) do |memo, (origin_obj, index)|
|
55
59
|
memo << "," if index > 0
|
56
|
-
memo << build_key(boundary
|
60
|
+
memo << build_key(boundary.key, origin_obj, federation: boundary.federation)
|
57
61
|
memo
|
58
62
|
end
|
59
63
|
|
60
|
-
"_#{batch_index}_result: #{boundary
|
64
|
+
"_#{batch_index}_result: #{boundary.field}(#{boundary.arg}:[#{input}]) #{op.selections}"
|
61
65
|
else
|
62
66
|
origin_set.map.with_index do |origin_obj, index|
|
63
|
-
input = build_key(boundary
|
64
|
-
"_#{batch_index}_#{index}_result: #{boundary
|
67
|
+
input = build_key(boundary.key, origin_obj, federation: boundary.federation)
|
68
|
+
"_#{batch_index}_#{index}_result: #{boundary.field}(#{boundary.arg}:#{input}) #{op.selections}"
|
65
69
|
end
|
66
70
|
end
|
67
71
|
end
|
@@ -71,7 +75,7 @@ module GraphQL
|
|
71
75
|
if operation_name
|
72
76
|
doc << " #{operation_name}"
|
73
77
|
origin_sets_by_operation.each_key do |op|
|
74
|
-
doc << "_#{op
|
78
|
+
doc << "_#{op.step}"
|
75
79
|
end
|
76
80
|
end
|
77
81
|
|
@@ -80,15 +84,19 @@ module GraphQL
|
|
80
84
|
doc << "(#{variable_str})"
|
81
85
|
end
|
82
86
|
|
87
|
+
if operation_directives
|
88
|
+
doc << " #{operation_directives} "
|
89
|
+
end
|
90
|
+
|
83
91
|
doc << "{ #{query_fields.join(" ")} }"
|
84
92
|
|
85
93
|
return doc, variable_defs.keys
|
86
94
|
end
|
87
95
|
|
88
96
|
def build_key(key, origin_obj, federation: false)
|
89
|
-
key_value = JSON.generate(origin_obj[
|
97
|
+
key_value = JSON.generate(origin_obj[SelectionHint.key(key)])
|
90
98
|
if federation
|
91
|
-
"{ __typename: \"#{origin_obj[
|
99
|
+
"{ __typename: \"#{origin_obj[SelectionHint.typename_node.alias]}\", #{key}: #{key_value} }"
|
92
100
|
else
|
93
101
|
key_value
|
94
102
|
end
|
@@ -11,9 +11,13 @@ module GraphQL
|
|
11
11
|
def fetch(ops)
|
12
12
|
op = ops.first # There should only ever be one per location at a time
|
13
13
|
|
14
|
-
query_document = build_document(
|
15
|
-
|
16
|
-
|
14
|
+
query_document = build_document(
|
15
|
+
op,
|
16
|
+
@executor.request.operation_name,
|
17
|
+
@executor.request.operation_directives,
|
18
|
+
)
|
19
|
+
query_variables = @executor.request.variables.slice(*op.variables.keys)
|
20
|
+
result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request.context)
|
17
21
|
@executor.query_count += 1
|
18
22
|
|
19
23
|
@executor.data.merge!(result["data"]) if result["data"]
|
@@ -22,25 +26,29 @@ module GraphQL
|
|
22
26
|
@executor.errors.concat(result["errors"])
|
23
27
|
end
|
24
28
|
|
25
|
-
ops.map
|
29
|
+
ops.map(&:step)
|
26
30
|
end
|
27
31
|
|
28
32
|
# Builds root source documents
|
29
33
|
# "query MyOperation_1($var:VarType) { rootSelections ... }"
|
30
|
-
def build_document(op, operation_name = nil)
|
34
|
+
def build_document(op, operation_name = nil, operation_directives = nil)
|
31
35
|
doc = String.new
|
32
|
-
doc << op
|
36
|
+
doc << op.operation_type
|
33
37
|
|
34
38
|
if operation_name
|
35
|
-
doc << " #{operation_name}_#{op
|
39
|
+
doc << " #{operation_name}_#{op.step}"
|
36
40
|
end
|
37
41
|
|
38
|
-
if op
|
39
|
-
variable_defs = op
|
42
|
+
if op.variables.any?
|
43
|
+
variable_defs = op.variables.map { |k, v| "$#{k}:#{v}" }.join(",")
|
40
44
|
doc << "(#{variable_defs})"
|
41
45
|
end
|
42
46
|
|
43
|
-
|
47
|
+
if operation_directives
|
48
|
+
doc << " #{operation_directives} "
|
49
|
+
end
|
50
|
+
|
51
|
+
doc << op.selections
|
44
52
|
doc
|
45
53
|
end
|
46
54
|
end
|
@@ -5,13 +5,13 @@ require "json"
|
|
5
5
|
module GraphQL
|
6
6
|
module Stitching
|
7
7
|
class Executor
|
8
|
-
attr_reader :supergraph, :request, :data, :errors
|
8
|
+
attr_reader :supergraph, :request, :plan, :data, :errors
|
9
9
|
attr_accessor :query_count
|
10
10
|
|
11
11
|
def initialize(supergraph:, request:, plan:, nonblocking: false)
|
12
12
|
@supergraph = supergraph
|
13
13
|
@request = request
|
14
|
-
@
|
14
|
+
@plan = plan
|
15
15
|
@data = {}
|
16
16
|
@errors = []
|
17
17
|
@query_count = 0
|
@@ -39,22 +39,20 @@ module GraphQL
|
|
39
39
|
|
40
40
|
private
|
41
41
|
|
42
|
-
def exec!(
|
43
|
-
if @exec_cycles > @
|
42
|
+
def exec!(next_steps = [0])
|
43
|
+
if @exec_cycles > @plan.ops.length
|
44
44
|
# sanity check... if we've exceeded queue size, then something went wrong.
|
45
45
|
raise StitchingError, "Too many execution requests attempted."
|
46
46
|
end
|
47
47
|
|
48
48
|
@dataloader.append_job do
|
49
|
-
tasks = @
|
50
|
-
.
|
51
|
-
.
|
49
|
+
tasks = @plan
|
50
|
+
.ops
|
51
|
+
.select { next_steps.include?(_1.after) }
|
52
|
+
.group_by { [_1.location, _1.boundary.nil?] }
|
52
53
|
.map do |(location, root_source), ops|
|
53
|
-
|
54
|
-
|
55
|
-
else
|
56
|
-
@dataloader.with(BoundarySource, self, location).request_all(ops)
|
57
|
-
end
|
54
|
+
source_type = root_source ? RootSource : BoundarySource
|
55
|
+
@dataloader.with(source_type, self, location).request_all(ops)
|
58
56
|
end
|
59
57
|
|
60
58
|
tasks.each(&method(:exec_task))
|
@@ -65,8 +63,8 @@ module GraphQL
|
|
65
63
|
end
|
66
64
|
|
67
65
|
def exec_task(task)
|
68
|
-
|
69
|
-
exec!(
|
66
|
+
next_steps = task.load.tap(&:compact!)
|
67
|
+
exec!(next_steps) if next_steps.any?
|
70
68
|
end
|
71
69
|
end
|
72
70
|
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL
|
4
|
+
module Stitching
|
5
|
+
# Immutable structures representing a query plan.
|
6
|
+
# May serialize to/from JSON.
|
7
|
+
class Plan
|
8
|
+
Op = Struct.new(
|
9
|
+
:step,
|
10
|
+
:after,
|
11
|
+
:location,
|
12
|
+
:operation_type,
|
13
|
+
:selections,
|
14
|
+
:variables,
|
15
|
+
:path,
|
16
|
+
:if_type,
|
17
|
+
:boundary,
|
18
|
+
keyword_init: true
|
19
|
+
) do
|
20
|
+
def as_json
|
21
|
+
{
|
22
|
+
step: step,
|
23
|
+
after: after,
|
24
|
+
location: location,
|
25
|
+
operation_type: operation_type,
|
26
|
+
selections: selections,
|
27
|
+
variables: variables,
|
28
|
+
path: path,
|
29
|
+
if_type: if_type,
|
30
|
+
boundary: boundary&.as_json
|
31
|
+
}.tap(&:compact!)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class << self
|
36
|
+
def from_json(json)
|
37
|
+
ops = json["ops"]
|
38
|
+
ops = ops.map do |op|
|
39
|
+
boundary = op["boundary"]
|
40
|
+
Op.new(
|
41
|
+
step: op["step"],
|
42
|
+
after: op["after"],
|
43
|
+
location: op["location"],
|
44
|
+
operation_type: op["operation_type"],
|
45
|
+
selections: op["selections"],
|
46
|
+
variables: op["variables"],
|
47
|
+
path: op["path"],
|
48
|
+
if_type: op["if_type"],
|
49
|
+
boundary: boundary ? GraphQL::Stitching::Boundary.new(**boundary) : nil,
|
50
|
+
)
|
51
|
+
end
|
52
|
+
new(ops: ops)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
attr_reader :ops
|
57
|
+
|
58
|
+
def initialize(ops: [])
|
59
|
+
@ops = ops
|
60
|
+
end
|
61
|
+
|
62
|
+
def as_json
|
63
|
+
{ ops: @ops.map(&:as_json) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|