graphql-stitching 1.2.1 → 1.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cb8c0d3b16cda5db8b82d0d0f9eba93a4537c3faa584b1b301dc84b34b5fd0e
4
- data.tar.gz: 83f4b436479307ac8575f718ace5a82288024a6e5741f786151e55920d7ef61f
3
+ metadata.gz: 6753e205aac88d3dd3be1c906b743cc49ae0de84ff8ab56c7e9be6e889429aa3
4
+ data.tar.gz: 59440f9d70cb365ef124604c42012bf655934555dfb76905328368353f5e310b
5
5
  SHA512:
6
- metadata.gz: e247e539e223a1fbefcd398c1aeb3940fa91320b81c45caeaa21b3ddca50631ffb6ddbd3963cde07f11609cbaacc1f814a167fefebab8f888979afe929bcb93f
7
- data.tar.gz: 66de3bacb73749bd90b4d8e7ade4bf35cc144e0ae60b3ff1c81a5f548b20d74e204454b6727875984da293b4b3853aa9bf6215283666f2b037a7dc148e8aa64b
6
+ metadata.gz: ac218d2218c14b8debff552ffb11e83e00293dfebb404d25296ed9e408232e488c44575c701ee3fd933331d84755062517f2c2da8df2db98ac7c5e8594595846
7
+ data.tar.gz: ec4f5051ebd511e72536c8e3246302079b2eb65ab5edab37c7c7d7de5488cdc7581318c69a0ea54047bd79ff42bf9e9c5af3434a27fc5667cf029340527c8117
data/README.md CHANGED
@@ -78,9 +78,7 @@ While the `Client` constructor is an easy quick start, the library also has seve
78
78
 
79
79
  - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
80
80
  - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
81
- - [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
82
- - [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
83
- - [Executor](./docs/executor.md) - executes a query plan with given request variables.
81
+ - [Request](./docs/request.md) - manages the lifecycle of a stitched GraphQL request.
84
82
  - [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.
85
83
 
86
84
  ## Merged types
@@ -134,7 +132,7 @@ client = GraphQL::Stitching::Client.new(locations: {
134
132
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
135
133
  },
136
134
  catalog: {
137
- schema: GraphQL::Schema.from_definition(shipping_schema),
135
+ schema: GraphQL::Schema.from_definition(catalog_schema),
138
136
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
139
137
  },
140
138
  })
@@ -155,7 +153,7 @@ type Query {
155
153
  * The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
156
154
  * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#multiple-query-arguments) later).
157
155
 
158
- Each location that provides a unique variant of a type must provide at least one stitching query. The exception to this requirement are types that contain only a single key field:
156
+ Each location that provides a unique variant of a type must provide at least one stitching query for the type. The exception to this requirement are [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain only a single key field:
159
157
 
160
158
  ```graphql
161
159
  type Product {
@@ -163,7 +161,7 @@ type Product {
163
161
  }
164
162
  ```
165
163
 
166
- The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted. This pattern of providing key-only types is very common in stitching: it allows a foreign key to be represented as an object stub that may be enriched by data collected from other locations.
164
+ The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
167
165
 
168
166
  #### List queries
169
167
 
@@ -427,6 +425,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
427
425
 
428
426
  ## Additional topics
429
427
 
428
+ - [Modeling foreign keys for stitching](./docs/mechanics.md##modeling-foreign-keys-for-stitching)
430
429
  - [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
431
430
  - [Field selection routing](./docs/mechanics.md#field-selection-routing)
432
431
  - [Root selection routing](./docs/mechanics.md#root-selection-routing)
data/docs/README.md CHANGED
@@ -10,8 +10,6 @@ Major components include:
10
10
  - [Composer](./composer.md) - merges and validates many schemas into one graph.
11
11
  - [Supergraph](./supergraph.md) - manages the combined schema and location routing maps. Can be exported, cached, and rehydrated.
12
12
  - [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
13
- - [Planner](./planner.md) - builds a cacheable query plan for a request document.
14
- - [Executor](./executor.md) - executes a query plan with given request variables.
15
13
  - [HttpExecutable](./http_executable.md) - proxies requests to remotes with multipart file upload support.
16
14
 
17
15
  Additional topics:
data/docs/mechanics.md CHANGED
@@ -1,5 +1,47 @@
1
1
  ## Schema Stitching, mechanics
2
2
 
3
+ ### Modeling foreign keys for stitching
4
+
5
+ Foreign keys in a GraphQL schema typically look like the `Product.imageId` field here:
6
+
7
+ ```graphql
8
+ # -- Products schema:
9
+
10
+ type Product {
11
+ id: ID!
12
+ imageId: ID!
13
+ }
14
+
15
+ # -- Images schema:
16
+
17
+ type Image {
18
+ id: ID!
19
+ url: String!
20
+ }
21
+ ```
22
+
23
+ However, this design does not lend itself to stitching where types need to _merge_ across locations. A simple schema refactor makes this foreign key more expressive as an entity type, and turns the key into an _object_ that will merge with analogous object types in other locations:
24
+
25
+ ```graphql
26
+ # -- Products schema:
27
+
28
+ type Product {
29
+ id: ID!
30
+ image: Image!
31
+ }
32
+
33
+ type Image {
34
+ id: ID!
35
+ }
36
+
37
+ # -- Images schema:
38
+
39
+ type Image {
40
+ id: ID!
41
+ url: String!
42
+ }
43
+ ```
44
+
3
45
  ### Deploying a stitched schema
4
46
 
5
47
  Among the simplest and most effective ways to manage a stitched schema is to compose it locally, write the composed SDL as a `.graphql` file in your repo, and then load the composed schema into a stitching client at runtime. For example, setup a `rake` task that loads/fetches subgraph schemas, composes them, and then writes the composed schema definition as a file committed to the repo:
data/docs/request.md CHANGED
@@ -25,37 +25,12 @@ A `Request` provides the following information:
25
25
  - `req.variable_definitions`: a mapping of variable names to their type definitions
26
26
  - `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
27
27
 
28
- ### Preparing requests
28
+ ### Request lifecycle
29
29
 
30
- A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
30
+ A request manages the flow of stitching behaviors. These are sequenced by the `Client`
31
+ component, or you may invoke them manually:
31
32
 
32
- ```ruby
33
- document = <<~GRAPHQL
34
- query FetchMovie($id: ID!, $lang: String = "en", $withShowtimes: Boolean = true) {
35
- movie(id:$id) {
36
- id
37
- title(lang: $lang)
38
- showtimes @include(if: $withShowtimes) {
39
- time
40
- }
41
- }
42
- }
43
- GRAPHQL
44
-
45
- request = GraphQL::Stitching::Request.new(
46
- supergraph,
47
- document,
48
- variables: { "id" => "1" },
49
- operation_name: "FetchMovie",
50
- )
51
-
52
- errors = MySchema.validate(request.document)
53
- # return early with any static validation errors...
54
-
55
- request.prepare!
56
- ```
57
-
58
- Preparing a request will apply several destructive transformations:
59
-
60
- - Default values from variable definitions will be added to request variables.
61
- - The document will be pre-shaped based on `@skip` and `@include` directives.
33
+ 1. `request.validate`: runs static validations on the request using the combined schema.
34
+ 2. `request.prepare!`: inserts variable defaults and pre-renders skip/include conditional shaping.
35
+ 3. `request.plan`: builds a plan for the request. May act as a setting for plans pulled from cache.
36
+ 4. `request.execute`: executes the request, and returns the resulting data.
@@ -263,7 +263,6 @@ module GraphQL
263
263
  enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
264
264
  type_candidate.enum_values.each do |enum_value_candidate|
265
265
  memo[enum_value_candidate.graphql_name] ||= {}
266
- memo[enum_value_candidate.graphql_name][location] ||= {}
267
266
  memo[enum_value_candidate.graphql_name][location] = enum_value_candidate
268
267
  end
269
268
  end
@@ -376,7 +375,6 @@ module GraphQL
376
375
  @field_map[type_name][field_candidate.name] << location
377
376
 
378
377
  memo[field_name] ||= {}
379
- memo[field_name][location] ||= {}
380
378
  memo[field_name][location] = field_candidate
381
379
  end
382
380
  end
@@ -406,7 +404,6 @@ module GraphQL
406
404
  args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
407
405
  member_candidate.arguments.each do |argument_name, argument|
408
406
  memo[argument_name] ||= {}
409
- memo[argument_name][location] ||= {}
410
407
  memo[argument_name][location] = argument
411
408
  end
412
409
  end
@@ -461,7 +458,6 @@ module GraphQL
461
458
  directives_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
462
459
  member_candidate.directives.each do |directive|
463
460
  memo[directive.graphql_name] ||= {}
464
- memo[directive.graphql_name][location] ||= {}
465
461
  memo[directive.graphql_name][location] = directive
466
462
  end
467
463
  end
@@ -652,22 +648,22 @@ module GraphQL
652
648
 
653
649
  schemas.each do |schema|
654
650
  introspection_types = schema.introspection_system.types.keys
655
- schema.types.values.each do |type|
651
+ schema.types.each_value do |type|
656
652
  next if introspection_types.include?(type.graphql_name)
657
653
 
658
654
  if type.kind.object? || type.kind.interface?
659
- type.fields.values.each do |field|
655
+ type.fields.each_value do |field|
660
656
  field_type = field.type.unwrap
661
657
  reads << field_type.graphql_name if field_type.kind.enum?
662
658
 
663
- field.arguments.values.each do |argument|
659
+ field.arguments.each_value do |argument|
664
660
  argument_type = argument.type.unwrap
665
661
  writes << argument_type.graphql_name if argument_type.kind.enum?
666
662
  end
667
663
  end
668
664
 
669
665
  elsif type.kind.input_object?
670
- type.arguments.values.each do |argument|
666
+ type.arguments.each_value do |argument|
671
667
  argument_type = argument.type.unwrap
672
668
  writes << argument_type.graphql_name if argument_type.kind.enum?
673
669
  end
@@ -20,10 +20,22 @@ module GraphQL::Stitching
20
20
  result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21
21
  @executor.query_count += 1
22
22
 
23
- @executor.data.merge!(result["data"]) if result["data"]
23
+ if result["data"]
24
+ if op.path.any?
25
+ # Nested root scopes must expand their pathed origin set
26
+ origin_set = op.path.reduce([@executor.data]) do |set, ns|
27
+ set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!)
28
+ end
29
+
30
+ origin_set.each { _1.merge!(result["data"]) }
31
+ else
32
+ # Actual root scopes merge directly into results data
33
+ @executor.data.merge!(result["data"])
34
+ end
35
+ end
36
+
24
37
  if result["errors"]&.any?
25
- result["errors"].each { _1.delete("locations") }
26
- @executor.errors.concat(result["errors"])
38
+ @executor.errors.concat(format_errors!(result["errors"], op.path))
27
39
  end
28
40
 
29
41
  ops.map(&:step)
@@ -51,6 +63,16 @@ module GraphQL::Stitching
51
63
  doc << op.selections
52
64
  doc
53
65
  end
66
+
67
+ # Format response errors without a document location (because it won't match the request doc),
68
+ # and prepend any insertion path for the scope into error paths.
69
+ def format_errors!(errors, path)
70
+ errors.each do |err|
71
+ err.delete("locations")
72
+ err["path"].unshift(*path) if err["path"] && path.any?
73
+ end
74
+ errors
75
+ end
54
76
  end
55
77
  end
56
78
  end
@@ -276,19 +276,21 @@ module GraphQL
276
276
  routes.each_value do |route|
277
277
  route.reduce(locale_selections) do |parent_selections, boundary|
278
278
  # E.1) Add the key of each boundary query into the prior location's selection set.
279
- foreign_key = ExportSelection.key(boundary.key)
280
- has_key = false
281
- has_typename = false
282
-
283
- parent_selections.each do |node|
284
- next unless node.is_a?(GraphQL::Language::Nodes::Field)
285
- has_key ||= node.alias == foreign_key
286
- has_typename ||= node.alias == ExportSelection.typename_node.alias
279
+ if boundary.key
280
+ foreign_key = ExportSelection.key(boundary.key)
281
+ has_key = false
282
+ has_typename = false
283
+
284
+ parent_selections.each do |node|
285
+ next unless node.is_a?(GraphQL::Language::Nodes::Field)
286
+ has_key ||= node.alias == foreign_key
287
+ has_typename ||= node.alias == ExportSelection.typename_node.alias
288
+ end
289
+
290
+ parent_selections << ExportSelection.key_node(boundary.key) unless has_key
291
+ parent_selections << ExportSelection.typename_node unless has_typename
287
292
  end
288
293
 
289
- parent_selections << ExportSelection.key_node(boundary.key) unless has_key
290
- parent_selections << ExportSelection.typename_node unless has_typename
291
-
292
294
  # E.2) Add a planner step for each new entrypoint location.
293
295
  add_step(
294
296
  location: boundary.location,
@@ -296,7 +298,7 @@ module GraphQL
296
298
  parent_type: parent_type,
297
299
  selections: remote_selections_by_location[boundary.location] || [],
298
300
  path: path.dup,
299
- boundary: boundary,
301
+ boundary: boundary.key ? boundary : nil,
300
302
  ).selections
301
303
  end
302
304
  end
@@ -120,12 +120,13 @@ module GraphQL
120
120
  end
121
121
 
122
122
  # Validates the request using the combined supergraph schema.
123
+ # @return [Array<GraphQL::ExecutionError>] an array of static validation errors
123
124
  def validate
124
125
  result = @supergraph.static_validator.validate(@query)
125
126
  result[:errors]
126
127
  end
127
128
 
128
- # Prepares the request for stitching by rendering variable defaults and applying @skip/@include conditionals.
129
+ # Prepares the request for stitching by inserting variable defaults and applying @skip/@include conditionals.
129
130
  def prepare!
130
131
  operation.variables.each do |v|
131
132
  @variables[v.name] = v.default_value if @variables[v.name].nil? && !v.default_value.nil?
@@ -143,7 +144,17 @@ module GraphQL
143
144
  self
144
145
  end
145
146
 
146
- # Gets and sets the query plan for the request. Assigned query plans may pull from cache.
147
+ # Gets and sets the query plan for the request. Assigned query plans may pull from a cache,
148
+ # which is useful for redundant GraphQL documents (commonly sent by frontend clients).
149
+ # ```ruby
150
+ # if cached_plan = $cache.get(request.digest)
151
+ # plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
152
+ # request.plan(plan)
153
+ # else
154
+ # plan = request.plan
155
+ # $cache.set(request.digest, JSON.generate(plan.as_json))
156
+ # end
157
+ # ```
147
158
  # @param new_plan [Plan, nil] a cached query plan for the request.
148
159
  # @return [Plan] query plan for the request.
149
160
  def plan(new_plan = nil)
@@ -245,7 +245,11 @@ module GraphQL
245
245
  # ("Type") => ["id", ...]
246
246
  def possible_keys_for_type(type_name)
247
247
  @possible_keys_by_type[type_name] ||= begin
248
- @boundaries[type_name].map(&:key).tap(&:uniq!)
248
+ if type_name == @schema.query.graphql_name
249
+ GraphQL::Stitching::EMPTY_ARRAY
250
+ else
251
+ @boundaries[type_name].map(&:key).tap(&:uniq!)
252
+ end
249
253
  end
250
254
  end
251
255
 
@@ -262,16 +266,25 @@ module GraphQL
262
266
  # For a given type, route from one origin location to one or more remote locations
263
267
  # used to connect a partial type across locations via boundary queries
264
268
  def route_type_to_locations(type_name, start_location, goal_locations)
265
- if possible_keys_for_type(type_name).length > 1
269
+ key_count = possible_keys_for_type(type_name).length
270
+
271
+ if key_count.zero?
272
+ # nested root scopes have no boundary keys and just return a location
273
+ goal_locations.each_with_object({}) do |goal_location, memo|
274
+ memo[goal_location] = [Boundary.new(location: goal_location)]
275
+ end
276
+
277
+ elsif key_count > 1
266
278
  # multiple keys use an A* search to traverse intermediary locations
267
- return route_type_to_locations_via_search(type_name, start_location, goal_locations)
268
- end
279
+ route_type_to_locations_via_search(type_name, start_location, goal_locations)
269
280
 
270
- # types with a single key attribute must all be within a single hop of each other,
271
- # so can use a simple match to collect boundaries for the goal locations.
272
- @boundaries[type_name].each_with_object({}) do |boundary, memo|
273
- if goal_locations.include?(boundary.location)
274
- memo[boundary.location] = [boundary]
281
+ else
282
+ # types with a single key attribute must all be within a single hop of each other,
283
+ # so can use a simple match to collect boundaries for the goal locations.
284
+ @boundaries[type_name].each_with_object({}) do |boundary, memo|
285
+ if goal_locations.include?(boundary.location)
286
+ memo[boundary.location] = [boundary]
287
+ end
275
288
  end
276
289
  end
277
290
  end
@@ -54,7 +54,7 @@ module GraphQL
54
54
  return parent_type.possible_types if parent_type.kind.union?
55
55
 
56
56
  result = []
57
- schema.types.values.each do |type|
57
+ schema.types.each_value do |type|
58
58
  next unless type <= GraphQL::Schema::Interface && type != parent_type
59
59
  next unless type.interfaces.include?(parent_type)
60
60
  result << type
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.2.1"
5
+ VERSION = "1.2.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-13 00:00:00.000000000 Z
11
+ date: 2024-02-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -82,13 +82,11 @@ files:
82
82
  - docs/README.md
83
83
  - docs/client.md
84
84
  - docs/composer.md
85
- - docs/executor.md
86
85
  - docs/http_executable.md
87
86
  - docs/images/library.png
88
87
  - docs/images/merging.png
89
88
  - docs/images/stitching.png
90
89
  - docs/mechanics.md
91
- - docs/planner.md
92
90
  - docs/request.md
93
91
  - docs/supergraph.md
94
92
  - examples/file_uploads/Gemfile
data/docs/executor.md DELETED
@@ -1,68 +0,0 @@
1
- ## GraphQL::Stitching::Executor
2
-
3
- An `Executor` accepts a [`Supergraph`](./supergraph.md), a [query plan hash](./planner.md), and optional request variables. It handles executing requests and merging results collected from across graph locations.
4
-
5
- ```ruby
6
- query = <<~GRAPHQL
7
- query MyQuery($id: ID!) {
8
- product(id:$id) {
9
- title
10
- brands { name }
11
- }
12
- }
13
- GRAPHQL
14
-
15
- request = GraphQL::Stitching::Request.new(
16
- supergraph,
17
- query,
18
- variables: { "id" => "123" },
19
- operation_name: "MyQuery",
20
- context: { ... },
21
- )
22
-
23
- # Via Request:
24
- result = request.execute
25
-
26
- # Via Executor:
27
- result = GraphQL::Stitching::Executor.new(request).perform
28
- ```
29
-
30
- ### Raw results
31
-
32
- By default, execution results are always returned with document shaping (stitching additions removed, missing fields added, null bubbling applied). You may access the raw execution result by calling the `perform` method with a `raw: true` argument:
33
-
34
- ```ruby
35
- # get the raw result without shaping using either form:
36
- raw_result = request.execute(raw: true)
37
- raw_result = GraphQL::Stitching::Executor.new(request).perform(raw: true)
38
- ```
39
-
40
- The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
41
-
42
- ```ruby
43
- {
44
- "data" => {
45
- "product" => {
46
- "upc" => "1",
47
- "_export_upc" => "1",
48
- "_export_typename" => "Product",
49
- "name" => "iPhone",
50
- "price" => nil,
51
- }
52
- }
53
- }
54
- ```
55
-
56
- ### Batching
57
-
58
- The Executor batches together as many requests as possible to a given location at a given time. Batched queries are written with the operation name suffixed by all operation keys in the batch, and root stitching fields are each prefixed by their batch index and collection index (for non-list fields):
59
-
60
- ```graphql
61
- query MyOperation_2_3($lang:String!,$currency:Currency!){
62
- _0_result: storefronts(ids:["7","8"]) { name(lang:$lang) }
63
- _1_0_result: product(upc:"abc") { price(currency:$currency) }
64
- _1_1_result: product(upc:"xyz") { price(currency:$currency) }
65
- }
66
- ```
67
-
68
- All told, the executor will make one request per location per generation of data. Generations started on separate forks of the resolution tree will be resolved independently.
data/docs/planner.md DELETED
@@ -1,45 +0,0 @@
1
- ## GraphQL::Stitching::Planner
2
-
3
- A `Planner` generates a query plan for a given [`Supergraph`](./supergraph.md) and [`Request`](./request.md). The generated plan breaks down all the discrete GraphQL operations that must be delegated across locations and their sequencing.
4
-
5
- ```ruby
6
- document = <<~GRAPHQL
7
- query MyQuery($id: ID!) {
8
- product(id:$id) {
9
- title
10
- brands { name }
11
- }
12
- }
13
- GRAPHQL
14
-
15
- request = GraphQL::Stitching::Request.new(
16
- supergraph,
17
- document,
18
- variables: { "id" => "1" },
19
- operation_name: "MyQuery",
20
- ).prepare!
21
-
22
- # Via Request:
23
- plan = request.plan
24
-
25
- # Via Planner:
26
- plan = GraphQL::Stitching::Planner.new(request).perform
27
- ```
28
-
29
- ### Caching
30
-
31
- Plans are designed to be cacheable. This is very useful for redundant GraphQL documents (commonly sent by frontend clients) where there's no sense in planning every request individually. It's far more efficient to generate a plan once and cache it, then simply retreive the plan and execute it for future requests.
32
-
33
- ```ruby
34
- cached_plan = $cache.get(request.digest)
35
-
36
- if cached_plan
37
- plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
38
- request.plan(plan)
39
- else
40
- plan = request.plan
41
- $cache.set(request.digest, JSON.generate(plan.as_json))
42
- end
43
-
44
- # execute the plan...
45
- ```