changed LICENSE.md
 
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2020 Potion Software
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Potion Software
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
changed README.md
 
@@ -1,11 +1,11 @@
1
- # Potionx ⚗️
2
-
3
- ## A Toolkit for rapidly building and deploying full-stack applications with Elixir and Vue
4
- Potionx is a set of generators and functions that speeds up the process of setting up and deploying full-stack applications that uses Elixir with GraphQL for the server-side component and Vue + Vite + TailwindCSS for the frontend component.
5
-
6
- ## Documentation
7
- Please visit [docs.potionapps.com](https://docs.potionapps.com) for documentation
8
-
9
- ---
10
- ### License
1
+ # Potionx ⚗️
2
+
3
+ ## A Toolkit for rapidly building and deploying full-stack applications with Elixir and Vue
4
+ Potionx is a set of generators and functions that speeds up the process of setting up and deploying full-stack applications that uses Elixir with GraphQL for the server-side component and Vue + Vite + TailwindCSS for the frontend component.
5
+
6
+ ## Documentation
7
+ Please visit [docs.potionapps.com](https://docs.potionapps.com) for documentation
8
+
9
+ ---
10
+ ### License
11
11
MIT
\ No newline at end of file
changed hex_metadata.config
 
@@ -4,64 +4,63 @@
4
4
<<"Potionx is a set of generators and functions that speeds up the process of setting up and deploying a full-stack application that uses Elixir with GraphQL for the server-side component and Vue for the frontend component.">>}.
5
5
{<<"elixir">>,<<"~> 1.11">>}.
6
6
{<<"files">>,
7
- [<<"lib">>,<<"lib/mix">>,<<"lib/mix/tasks">>,
7
+ [<<"lib">>,<<"lib/potionx.ex">>,<<"lib/mix">>,<<"lib/mix/tasks">>,
8
8
<<"lib/mix/tasks/potionx.gen.gql_for_model.ex">>,<<"lib/potionx">>,
9
- <<"lib/potionx/auth">>,
10
- <<"lib/potionx/auth/assent_azure_common_strategy.ex">>,
11
- <<"lib/potionx/auth/auth.ex">>,<<"lib/potionx/auth/auth_dev_provider.ex">>,
12
- <<"lib/potionx/auth/auth_identity.ex">>,
13
- <<"lib/potionx/auth/auth_identity_service.ex">>,
14
- <<"lib/potionx/auth/auth_plug.ex">>,
15
- <<"lib/potionx/auth/auth_resolvers.ex">>,
16
- <<"lib/potionx/auth/auth_session.ex">>,
9
+ <<"lib/potionx/middleware">>,
10
+ <<"lib/potionx/middleware/roles_authorization_middleware.ex">>,
11
+ <<"lib/potionx/middleware/changeset_errors_middleware.ex">>,
12
+ <<"lib/potionx/middleware/scope_user_middleware.ex">>,
13
+ <<"lib/potionx/middleware/me_middleware.ex">>,
14
+ <<"lib/potionx/middleware/user_required_middleware.ex">>,
15
+ <<"lib/potionx/middleware/service_context_middleware.ex">>,
16
+ <<"lib/potionx/middleware/mutation_middleware.ex">>,
17
+ <<"lib/potionx/middleware/scope_organization_middleware.ex">>,
18
+ <<"lib/potionx/resolvers.ex">>,<<"lib/potionx/utils_ecto.ex">>,
19
+ <<"lib/potionx/types.ex">>,<<"lib/potionx/auth">>,
20
+ <<"lib/potionx/auth/auth_dev_provider.ex">>,
17
21
<<"lib/potionx/auth/auth_session_service.ex">>,
22
+ <<"lib/potionx/auth/auth_session.ex">>,
18
23
<<"lib/potionx/auth/auth_test_provider.ex">>,
19
- <<"lib/potionx/auth/auth_user.ex">>,<<"lib/potionx/controllers">>,
20
- <<"lib/potionx/controllers/controller.ex">>,<<"lib/potionx/doc_utils.ex">>,
21
- <<"lib/potionx/endpoint.ex">>,<<"lib/potionx/graphql">>,
24
+ <<"lib/potionx/auth/auth_user.ex">>,
25
+ <<"lib/potionx/auth/assent_azure_common_strategy.ex">>,
26
+ <<"lib/potionx/auth/auth.ex">>,<<"lib/potionx/auth/auth_plug.ex">>,
27
+ <<"lib/potionx/auth/auth_identity_service.ex">>,
28
+ <<"lib/potionx/auth/auth_identity.ex">>,
29
+ <<"lib/potionx/auth/auth_resolvers.ex">>,
30
+ <<"lib/potionx/service_context.ex">>,<<"lib/potionx/socket.ex">>,
31
+ <<"lib/potionx/plugs">>,<<"lib/potionx/plugs/urql_upload_plug.ex">>,
32
+ <<"lib/potionx/plugs/assets_manifest_plug.ex">>,
33
+ <<"lib/potionx/plugs/health_plug.ex">>,
34
+ <<"lib/potionx/plugs/absinthe_plug.ex">>,
35
+ <<"lib/potionx/plugs/service_context_plug.ex">>,
36
+ <<"lib/potionx/plugs/maybe_disable_introspection_plug.ex">>,
37
+ <<"lib/potionx/plugs/require_auth_plug.ex">>,
38
+ <<"lib/potionx/plugs/require_unauth_plug.ex">>,<<"lib/potionx/graphql">>,
22
39
<<"lib/potionx/graphql/swell">>,<<"lib/potionx/graphql/swell/products">>,
23
40
<<"lib/potionx/graphql/swell/products/product_queries.ex">>,
24
41
<<"lib/potionx/graphql/swell/products/product_types.ex">>,
25
- <<"lib/potionx/middleware">>,
26
- <<"lib/potionx/middleware/changeset_errors_middleware.ex">>,
27
- <<"lib/potionx/middleware/me_middleware.ex">>,
28
- <<"lib/potionx/middleware/mutation_middleware.ex">>,
29
- <<"lib/potionx/middleware/roles_authorization_middleware.ex">>,
30
- <<"lib/potionx/middleware/scope_organization_middleware.ex">>,
31
- <<"lib/potionx/middleware/scope_user_middleware.ex">>,
32
- <<"lib/potionx/middleware/service_context_middleware.ex">>,
33
- <<"lib/potionx/middleware/user_required_middleware.ex">>,
34
- <<"lib/potionx/plugs">>,<<"lib/potionx/plugs/absinthe_plug.ex">>,
35
- <<"lib/potionx/plugs/assets_manifest_plug.ex">>,
36
- <<"lib/potionx/plugs/health_plug.ex">>,
37
- <<"lib/potionx/plugs/maybe_disable_introspection_plug.ex">>,
38
- <<"lib/potionx/plugs/require_auth_plug.ex">>,
39
- <<"lib/potionx/plugs/require_unauth_plug.ex">>,
40
- <<"lib/potionx/plugs/service_context_plug.ex">>,
41
- <<"lib/potionx/plugs/urql_upload_plug.ex">>,<<"lib/potionx/redis.ex">>,
42
- <<"lib/potionx/repo.ex">>,<<"lib/potionx/resolvers.ex">>,
43
- <<"lib/potionx/schema.ex">>,<<"lib/potionx/service_context.ex">>,
44
- <<"lib/potionx/socket.ex">>,<<"lib/potionx/types.ex">>,
45
- <<"lib/potionx/utils_ecto.ex">>,<<"lib/potionx.ex">>,<<"priv">>,
46
- <<"priv/repo">>,<<"priv/templates">>,
42
+ <<"lib/potionx/schema.ex">>,<<"lib/potionx/endpoint.ex">>,
43
+ <<"lib/potionx/redis.ex">>,<<"lib/potionx/controllers">>,
44
+ <<"lib/potionx/controllers/controller.ex">>,<<"lib/potionx/repo.ex">>,
45
+ <<"lib/potionx/doc_utils.ex">>,<<"priv">>,<<"priv/templates">>,
47
46
<<"priv/templates/potion.gen.gql_for_model">>,
48
- <<"priv/templates/potion.gen.gql_for_model/app_schema.ex">>,
49
- <<"priv/templates/potion.gen.gql_for_model/collection.gql">>,
50
- <<"priv/templates/potion.gen.gql_for_model/delete.gql">>,
51
- <<"priv/templates/potion.gen.gql_for_model/model.json">>,
52
- <<"priv/templates/potion.gen.gql_for_model/model_mock.ex">>,
53
47
<<"priv/templates/potion.gen.gql_for_model/model_mock.json">>,
54
- <<"priv/templates/potion.gen.gql_for_model/mutation.gql">>,
48
+ <<"priv/templates/potion.gen.gql_for_model/model.json">>,
55
49
<<"priv/templates/potion.gen.gql_for_model/mutations.ex">>,
56
- <<"priv/templates/potion.gen.gql_for_model/mutations_test.exs">>,
57
- <<"priv/templates/potion.gen.gql_for_model/queries.ex">>,
58
50
<<"priv/templates/potion.gen.gql_for_model/queries_test.exs">>,
59
- <<"priv/templates/potion.gen.gql_for_model/resolver.ex">>,
60
- <<"priv/templates/potion.gen.gql_for_model/schema.ex">>,
51
+ <<"priv/templates/potion.gen.gql_for_model/mutation.gql">>,
52
+ <<"priv/templates/potion.gen.gql_for_model/types.ex">>,
61
53
<<"priv/templates/potion.gen.gql_for_model/service.ex">>,
54
+ <<"priv/templates/potion.gen.gql_for_model/app_schema.ex">>,
55
+ <<"priv/templates/potion.gen.gql_for_model/mutations_test.exs">>,
56
+ <<"priv/templates/potion.gen.gql_for_model/delete.gql">>,
57
+ <<"priv/templates/potion.gen.gql_for_model/schema.ex">>,
58
+ <<"priv/templates/potion.gen.gql_for_model/model_mock.ex">>,
59
+ <<"priv/templates/potion.gen.gql_for_model/resolver.ex">>,
60
+ <<"priv/templates/potion.gen.gql_for_model/queries.ex">>,
62
61
<<"priv/templates/potion.gen.gql_for_model/single.gql">>,
63
- <<"priv/templates/potion.gen.gql_for_model/types.ex">>,<<"LICENSE.md">>,
64
- <<"mix.exs">>,<<"README.md">>,<<".formatter.exs">>]}.
62
+ <<"priv/templates/potion.gen.gql_for_model/collection.gql">>,
63
+ <<"LICENSE.md">>,<<"mix.exs">>,<<"README.md">>,<<".formatter.exs">>]}.
65
64
{<<"licenses">>,[<<"MIT">>]}.
66
65
{<<"links">>,[{<<"github">>,<<"https://github.com/PotionApps/potionx">>}]}.
67
66
{<<"name">>,<<"potionx">>}.
 
@@ -121,4 +120,4 @@
121
120
{<<"optional">>,false},
122
121
{<<"repository">>,<<"hexpm">>},
123
122
{<<"requirement">>,<<">= 1.6.0">>}]]}.
124
- {<<"version">>,<<"0.8.13">>}.
123
+ {<<"version">>,<<"0.8.14">>}.
changed lib/mix/tasks/potionx.gen.gql_for_model.ex
 
@@ -1,941 +1,941 @@
1
- defmodule Mix.Tasks.Potionx.Gen.GqlForModel do
2
- alias __MODULE__
3
- @shortdoc "Generates GraphQL mutations, queries and types for an Ecto model"
4
- @task_name "potion.gen.gql_for_model"
5
- @default_opts [schema: true, context: true]
6
- @switches [binary_id: :boolean, table: :string, web: :string,
7
- schema: :boolean, context: :boolean, context_app: :string, no_associations: :boolean]
8
- defstruct app_otp: nil,
9
- context_name: nil,
10
- context_name_snakecase: nil,
11
- module_name_data: nil,
12
- module_name_graphql: nil,
13
- dir_context: nil,
14
- dir_graphql: nil,
15
- dir_test: nil,
16
- graphql_fields: nil,
17
- lines: %{},
18
- potion_name: "Potionx",
19
- mock: nil,
20
- mock_patch: nil,
21
- model: nil,
22
- model_name_atom: nil,
23
- model_file_path: nil,
24
- model_name: nil,
25
- model_name_graphql_case: nil,
26
- model_name_snakecase: nil,
27
- no_associations: false,
28
- no_frontend: false,
29
- no_mutations: false,
30
- no_queries: false,
31
- validations: []
32
-
33
- use Mix.Task
34
- alias Mix.Phoenix.{Context, Schema}
35
- alias Potionx.DocUtils
36
-
37
- def add_lines_to_block(block, lines_to_add, start_line, indent_size) when is_binary(start_line) do
38
- start_index = Enum.find_index(block, fn l ->
39
- String.starts_with?(l, start_line)
40
- end)
41
- add_lines_to_block(block, lines_to_add, start_index, indent_size)
42
- end
43
- def add_lines_to_block(block, lines_to_add, start_index, indent_size) do
44
- end_index =
45
- Enum.slice(block, start_index..-1)
46
- |> Enum.find_index(fn l ->
47
- String.starts_with?(l, DocUtils.indent_to_string(indent_size) <> "end")
48
- end)
49
- end_index = end_index + start_index
50
- Enum.concat(
51
- [
52
- Enum.slice(block, 0..(end_index - 1)),
53
- lines_to_add,
54
- Enum.slice(block, end_index..-1)
55
- ]
56
- )
57
- end
58
-
59
- @doc false
60
- def build(args) do
61
- {opts, parsed, _} = parse_opts(args)
62
- {opts, validate_args!(parsed)}
63
- end
64
-
65
- def common_fields do
66
- [:description, :title]
67
- end
68
-
69
- def ensure_files_and_directories_exist(%GqlForModel{} = state) do
70
- if (not File.dir?(state.dir_context)) do
71
- Mix.raise """
72
- Context directory #{state.dir_context} is missing
73
- """
74
- end
75
- files_to_be_generated(state)
76
- |> Enum.map(fn {k, path_enum} ->
77
- File.mkdir_p!(Path.join(Enum.slice(path_enum, 0..-2)))
78
- path = Path.join(path_enum)
79
- file_name =
80
- cond do
81
- String.ends_with?(to_string(k), "_test") ->
82
- to_string(k) <> ".exs"
83
- String.ends_with?(to_string(k), "_json") ->
84
- String.replace_trailing(to_string(k), "_json", "") <> ".json"
85
- true ->
86
- to_string(k) <> ".ex"
87
- end
88
- # if does not exist, create using templates
89
- if not File.exists?(path) do
90
- EEx.eval_string(
91
- Application.app_dir(
92
- :potionx,
93
- "priv/templates/#{@task_name}/#{file_name}"
94
- )
95
- |> File.read!,
96
- Enum.map(Map.from_struct(state), &(&1))
97
- )
98
- |> (fn res ->
99
- File.write!(path, res)
100
- end).()
101
- end
102
- end)
103
-
104
- state
105
- end
106
- def field_type_from_validations(type, validations) do
107
- Enum.find(validations, fn
108
- %{name: :inclusion} -> true
109
- _ -> false
110
- end)
111
- |> case do
112
- nil -> type
113
- type -> type
114
- end
115
- end
116
-
117
-
118
- @doc false
119
- def files_to_be_generated(%GqlForModel{} = state) do
120
- %{
121
- app_schema: [state.dir_graphql, "schema.ex"],
122
- model_mock: [state.dir_context, "#{state.model_name_snakecase}_mock.ex"],
123
- model_mock_json: [
124
- "shared",
125
- "src",
126
- "models",
127
- state.context_name,
128
- state.model_name,
129
- "#{state.model_name_graphql_case}.mock.json"
130
- ],
131
- model_json: [
132
- "shared",
133
- "src",
134
- "models",
135
- state.context_name,
136
- state.model_name,
137
- "#{state.model_name_graphql_case}.json"
138
- ],
139
- mutations: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_mutations.ex"],
140
- mutations_test: [state.dir_test, "mutations", "#{state.model_name_snakecase}_mutations_test.exs"],
141
- queries: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_queries.ex"],
142
- queries_test: [state.dir_test, "queries", "#{state.model_name_snakecase}_queries_test.exs"],
143
- resolver: [state.dir_graphql, "resolvers", "#{state.model_name_snakecase}_resolver.ex"],
144
- service: [state.dir_context, "#{state.model_name_snakecase}_service.ex"],
145
- types: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_types.ex"],
146
- }
147
- |> (fn res ->
148
- [{:no_mutations, "mutations"}, {:no_queries, "queries"}]
149
- |> Enum.reduce(res, fn {k, v}, acc ->
150
- if (Map.get(state, k)) do
151
- Map.drop(acc, [String.to_atom(v), String.to_atom(v <> "_test")])
152
- else
153
- acc
154
- end
155
- end)
156
- end).()
157
- end
158
-
159
- def keyword_list_to_map(list) do
160
- if (Keyword.keyword?(list)) do
161
- Enum.into(list, %{})
162
- else
163
- list
164
- end
165
- end
166
-
167
- def load_lines(%GqlForModel{} = state) do
168
- files_to_be_generated(state)
169
- |> Enum.filter(fn {k, _} -> Enum.member?([:app_schema, :mutations, :types, :queries], k) end)
170
- |> Enum.reduce(state, fn {k, path_enum}, state ->
171
- lines =
172
- File.read!(
173
- Path.join(path_enum)
174
- )
175
- |> String.trim
176
- |> String.split(~r{(\r?)\n})
177
- %{
178
- state |
179
- lines: Map.put(state.lines, k, lines)
180
- }
181
- end)
182
- end
183
-
184
- def load_model(%GqlForModel{} = state, nil) do
185
- %{
186
- state | model: [
187
- "Elixir",
188
- state.module_name_data,
189
- state.context_name,
190
- state.model_name
191
- ]
192
- |> Enum.join(".")
193
- |> String.to_atom
194
- }
195
- end
196
- def load_model(%GqlForModel{} = state, model), do: %{state | model: model}
197
-
198
- def load_validations(%GqlForModel{} = state) do
199
- %{
200
- state | validations: validations(
201
- state.model.changeset(struct(state.model, %{}), %{})
202
- )
203
- }
204
- end
205
-
206
- def maybe_add_default_types(%GqlForModel{} = state) do
207
- [
208
- {
209
- "input_object :#{state.model_name_atom}_filters_single do",
210
- [
211
- "field :id, non_null(:global_id)"
212
- ]
213
- },
214
- {
215
- "object :#{state.model_name_snakecase}_mutation_result do",
216
- [
217
- "field :errors, list_of(:string)",
218
- "field :errors_fields, list_of(:error)",
219
- "field :node, :#{state.model_name_snakecase}",
220
- "field :success_msg, :string"
221
- ]
222
- }
223
- # {
224
- # "object #{state.model_name_snakecase}_collection_result do",
225
- # [
226
- # ":errors, list_of(:string)",
227
- # ":nodes, list_of(#{state.model_name_snakecase})",
228
- # ":query_info, :query_info"
229
- # ]
230
- # },
231
- ]
232
- |> Enum.reduce(state, fn {head, lines}, acc ->
233
- if Enum.find(acc.lines.types, fn l -> String.contains?(l, head) end) do
234
- acc
235
- else
236
- block_to_add = Enum.concat([
237
- [DocUtils.indent_to_string(2) <> head],
238
- Enum.map(lines, fn l -> DocUtils.indent_to_string(4) <> l end),
239
- [DocUtils.indent_to_string(2) <> "end"]
240
- ])
241
- %{
242
- acc |
243
- lines: Map.put(acc.lines, :types,
244
- add_lines_to_block(acc.lines.types, block_to_add, Enum.at(acc.lines.types, 0), 0)
245
- )
246
- }
247
- end
248
- end)
249
- end
250
-
251
- def maybe_add_line(block, line, indent_size, close \\ false) do
252
- lines_to_add =
253
- block
254
- |> Enum.find_index(fn l ->
255
- String.contains?(l, line)
256
- end)
257
- |> case do
258
- nil ->
259
- [DocUtils.indent_to_string(indent_size) <> line]
260
- |> (fn res ->
261
- if close do
262
- Enum.concat([res, [DocUtils.indent_to_string(indent_size) <> "end"]])
263
- else
264
- res
265
- end
266
- end).()
267
- _ ->
268
- []
269
- end
270
- add_lines_to_block(block, lines_to_add, Enum.at(block, 0), indent_size - 2)
271
- end
272
-
273
- def maybe_add_node_interface_type_resolve(%GqlForModel{} = state) do
274
- # look for node interface, next line is resolve type
275
- # insert into resolve_type block
276
- interface_block_start_index = Enum.find_index(state.lines.app_schema, fn l ->
277
- String.starts_with?(
278
- l,
279
- DocUtils.indent_to_string(2) <> "node interface"
280
- )
281
- end)
282
- Enum.find(state.lines.app_schema, fn l ->
283
- String.contains?(
284
- l,
285
- "#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->"
286
- )
287
- end)
288
- |> if do
289
- state
290
- else
291
- %{
292
- state |
293
- lines: Map.put(
294
- state.lines,
295
- :app_schema,
296
- Enum.concat(
297
- [
298
- Enum.slice(state.lines.app_schema, 0..interface_block_start_index+1),
299
- [
300
- DocUtils.indent_to_string(6) <> "%#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->",
301
- DocUtils.indent_to_string(8) <> ":#{state.model_name_atom}"
302
- ],
303
- Enum.slice(state.lines.app_schema, (interface_block_start_index+2)..-1)
304
- ]
305
- )
306
- )
307
- }
308
- end
309
- end
310
-
311
- def maybe_convert_type(type, is_input \\ false) do
312
- case type do
313
- Ecto.UUID -> :id
314
- :binary -> :string
315
- :binary_id -> :string
316
- :datetime -> :naive_datetime
317
- :id -> is_input && :global_id || :id
318
- :map -> :json
319
- :naive_datetime_usec -> :naive_datetime
320
- :utc_datetime_usec -> :datetime
321
- :utc_datetime -> :datetime
322
- _ -> type
323
- end
324
- end
325
-
326
- def maybe_init_types(%GqlForModel{} = state) do
327
- Enum.map(types(state), fn line ->
328
- {:types, line}
329
- end)
330
- |> Enum.reduce(state, fn {k, v}, acc ->
331
- %{
332
- acc |
333
- lines:
334
- Map.put(
335
- acc.lines,
336
- k,
337
- maybe_add_line(Map.get(acc.lines, k), v, 2, true)
338
- |> (fn lines_types ->
339
- lines_types
340
- |> Enum.find_index(fn l ->
341
- String.contains?(l, "connection node_type: :#{state.model_name_atom}")
342
- end)
343
- |> case do
344
- nil ->
345
- add_lines_to_block(
346
- lines_types,
347
- [
348
- DocUtils.indent_to_string(2) <> "connection node_type: :#{state.model_name_atom} do",
349
- DocUtils.indent_to_string(4) <> "field :count, non_null(:integer)",
350
- DocUtils.indent_to_string(4) <> "field :count_before, non_null(:integer)",
351
- DocUtils.indent_to_string(4) <> "edge do",
352
- DocUtils.indent_to_string(4) <> "end",
353
- DocUtils.indent_to_string(2) <> "end"
354
- ],
355
- Enum.at(lines_types, 0),
356
- 0
357
- )
358
- _ -> lines_types
359
- end
360
- end).()
361
- )
362
- }
363
- end)
364
- end
365
-
366
- def maybe_update_main_schema(%GqlForModel{} = state) do
367
- [
368
- {:no_mutations, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Mutations"},
369
- {:no_queries, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Queries"},
370
- {:no_types, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Types"}
371
- ]
372
- |> Enum.reduce(state.lines.app_schema, fn {k, line}, acc ->
373
- if (Map.get(state, k)) do
374
- acc
375
- else
376
- maybe_add_line(
377
- acc,
378
- line,
379
- 2
380
- )
381
- end
382
- end)
383
- |> (fn lines ->
384
- [
385
- {:no_mutations, "mutation do", "import_fields :#{state.model_name_snakecase}_mutations"},
386
- {:no_queries, "query do", "import_fields :#{state.model_name_snakecase}_queries"},
387
- {
388
- :no_mutations,
389
- "def dataloader",
390
- "|> Dataloader.add_source(#{state.module_name_graphql}.Resolver.#{state.model_name}, #{state.module_name_graphql}.Resolver.#{state.model_name}.data())"
391
- }
392
- ]
393
- |> Enum.reduce(lines, fn {flag, k, v}, acc ->
394
- (
395
- Map.get(state, flag) or
396
- Enum.find(acc, fn line -> String.contains?(line, v) end)
397
- )
398
- |> if do
399
- acc
400
- else
401
- add_lines_to_block(
402
- acc,
403
- [DocUtils.indent_to_string(4) <> v],
404
- DocUtils.indent_to_string(2) <> k,
405
- 2
406
- )
407
- end
408
- end)
409
- end).()
410
- |> (fn lines ->
411
- %{state | lines: Map.put(state.lines, :app_schema, lines)}
412
- end).()
413
- end
414
-
415
- defp parse_opts(args) do
416
- {opts, parsed, invalid} = OptionParser.parse(args, switches: @switches)
417
- merged_opts =
418
- @default_opts
419
- |> Keyword.merge(opts)
420
- |> put_context_app(opts[:context_app])
421
-
422
- {merged_opts, parsed, invalid}
423
- end
424
-
425
- def prepare_mock(params, type \\ :create) do
426
- params
427
- |> Enum.filter(fn
428
- {_, {:assoc, _}} -> false
429
- _ -> true
430
- end)
431
- |> Enum.map(fn
432
- {k, Ecto.UUID} -> {k, :uuid}
433
- {k, v} -> {k, v}
434
- end)
435
- |> Mix.Phoenix.Schema.params(type)
436
- |> Enum.map(fn
437
- {:email, _} ->
438
- {:email, type === :create && "[email protected]" || "[email protected]"}
439
- {k, v} ->
440
- {k, v}
441
- end)
442
- |> Enum.into(%{})
443
- end
444
-
445
- def pretty_print(m) do
446
- inspect(m, pretty: true)
447
- |> String.split(~r{(\r?)\n})
448
- |> Enum.map(fn l -> " " <> l end)
449
- |> Enum.join("\r\n")
450
- end
451
-
452
- defp put_context_app(opts, nil), do: opts
453
- defp put_context_app(opts, string) do
454
- Keyword.put(opts, :context_app, String.to_atom(string))
455
- end
456
-
457
- @doc false
458
- @spec raise_with_help(String.t) :: no_return()
459
- def raise_with_help(msg) do
460
- Mix.raise """
461
- #{msg}
462
-
463
- mix #{@task_name} expects a
464
- context module name, followed by the singular module name
465
-
466
- mix #{@task_name} Accounts User
467
-
468
- The context serves as the API boundary for the given resource.
469
- Multiple resources may belong to a context and a resource may be
470
- split over distinct contexts (such as Accounts.User and Payments.User).
471
- """
472
- end
473
-
474
- @doc false
475
- def run(args, model \\ nil) do
476
- args = Enum.filter(args, fn a -> not is_struct(a) end)
477
-
478
- if Mix.Project.umbrella? do
479
- Mix.raise "mix #{@task_name} can only be run inside an application directory"
480
- end
481
- if (!model) do
482
- Mix.Task.run("app.start")
483
- end
484
- {opts, [context, schema]} = build(args)
485
-
486
- this_app = Mix.Phoenix.otp_app()
487
- dir_context = Path.join(["lib", "#{this_app}", Macro.underscore(context)])
488
- %GqlForModel{
489
- app_otp: this_app,
490
- context_name: context,
491
- context_name_snakecase: Macro.underscore(context),
492
- dir_context: dir_context,
493
- dir_graphql: Path.join(["lib", "#{this_app}_graphql"]),
494
- dir_test: Path.join(["test", "#{this_app}_graphql"]),
495
- model_file_path: Path.join([dir_context, Macro.underscore(schema) <> ".ex"]),
496
- model_name: schema,
497
- model_name_atom: Macro.underscore(schema) |> String.to_atom,
498
- model_name_graphql_case: Macro.underscore(schema) |> Absinthe.Adapter.LanguageConventions.to_external_name(nil),
499
- model_name_snakecase: Macro.underscore(schema),
500
- module_name_data: Mix.Phoenix.context_base(
501
- Mix.Phoenix.context_app()
502
- ),
503
- module_name_graphql: Mix.Phoenix.context_base(
504
- Mix.Phoenix.context_app()
505
- ) <> "GraphQl",
506
- no_associations: Keyword.get(opts, :no_associations, false),
507
- no_frontend: Keyword.get(opts, :no_frontend, false),
508
- no_mutations: Keyword.get(opts, :no_mutations, false),
509
- no_queries: Keyword.get(opts, :no_queries, false)
510
- }
511
- |> ensure_files_and_directories_exist
512
- |> load_model(model)
513
- |> load_lines
514
- |> load_validations
515
- |> maybe_init_types
516
- |> sync_mocks
517
- |> sync_objects
518
- |> sync_graphql_files
519
- |> maybe_add_default_types
520
- |> maybe_add_node_interface_type_resolve
521
- |> maybe_update_main_schema
522
- |> sync_json_schema
523
- |> run_npx_generator
524
- |> write_lines_to_files
525
- end
526
-
527
- def run_npx_generator(%GqlForModel{no_frontend: true} = state), do: state
528
- def run_npx_generator(%GqlForModel{} = state) do
529
- Mix.shell().cmd(
530
- "npx @potionapps/templates@latest model #{state.context_name} #{state.model_name} --destination=./frontend/admin"
531
- )
532
- state
533
- end
534
-
535
- def sync_graphql_files(%GqlForModel{} = state) do
536
- fields = prepare_mock(
537
- state.model.__changeset__
538
- )
539
- fields_computed =
540
- common_fields()
541
- |> Enum.reduce(%{}, fn key, fields ->
542
- if (function_exported?(state.model, key, 1)) do
543
- Map.put(fields, key, :string)
544
- else
545
- fields
546
- end
547
- end)
548
- state = %{
549
- state |
550
- graphql_fields:
551
- Map.merge(fields_computed, fields)
552
- |> Enum.reduce([], fn
553
- {_, {:assoc, _}}, acc ->
554
- acc
555
- {k, _}, acc ->
556
- acc ++ [Absinthe.Adapter.LanguageConventions.to_external_name(to_string(k), nil)]
557
- end)
558
- }
559
- [
560
- {
561
- :no_queries,
562
- "priv/templates/#{@task_name}/collection.gql",
563
- [
564
- "shared",
565
- "src",
566
- "models",
567
- state.context_name,
568
- state.model_name,
569
- "#{state.model_name_graphql_case}Collection.gql"
570
- ]
571
- },
572
- {
573
- :no_mutations,
574
- "priv/templates/#{@task_name}/delete.gql",
575
- [
576
- "shared",
577
- "src",
578
- "models",
579
- state.context_name,
580
- state.model_name,
581
- "#{state.model_name_graphql_case}Delete.gql"
582
- ]
583
- },
584
- {
585
- :no_mutations,
586
- "priv/templates/#{@task_name}/mutation.gql",
587
- [
588
- "shared",
589
- "src",
590
- "models",
591
- state.context_name,
592
- state.model_name,
593
- "#{state.model_name_graphql_case}Mutation.gql"
594
- ]
595
- },
596
- {
597
- :no_queries,
598
- "priv/templates/#{@task_name}/single.gql",
599
- [
600
- "shared",
601
- "src",
602
- "models",
603
- state.context_name,
604
- state.model_name,
605
- "#{state.model_name_graphql_case}Single.gql"
606
- ]
607
- }
608
- ]
609
- |> Enum.each(
610
- fn {flag, template, path_parts} ->
611
- unless Map.get(state, flag) do
612
- EEx.eval_string(
613
- Application.app_dir(
614
- :potionx,
615
- template
616
- )
617
- |> File.read!,
618
- Enum.map(
619
- Map.from_struct(state),
620
- &(&1)
621
- )
622
- )
623
- |> (fn res ->
624
- File.write!(
625
- Path.join(path_parts),
626
- res
627
- )
628
- end).()
629
- end
630
- end
631
- )
632
-
633
- state
634
- end
635
-
636
- def sync_json_schema(%GqlForModel{} = state) do
637
- model_json_raw = File.read!(
638
- files_to_be_generated(state).model_json
639
- |> Path.join
640
- )
641
- model_json = Jason.decode!(model_json_raw, keys: :atoms)
642
- state.model.__changeset__
643
- |> Enum.reduce(model_json, fn
644
- {_, {:assoc, _}}, acc -> acc
645
- {k, {:array, opts}}, acc ->
646
- options =
647
- case opts do
648
- {_, _, %{values: values}} -> values
649
- _ -> []
650
- end
651
- acc ++ [%{
652
- name: k,
653
- options: options,
654
- type: "checkbox",
655
- validations:
656
- Enum.reduce(state.validations, [], fn {key, v}, acc ->
657
- if (key === k) do
658
- acc ++ [v]
659
- else
660
- acc
661
- end
662
- end)
663
- }]
664
- {k, v}, acc ->
665
- cond do
666
- Enum.member?([:inserted_at, :updated_at], k) ->
667
- acc
668
- true ->
669
- validations =
670
- Enum.reduce(state.validations, [], fn {key, v}, acc ->
671
- if (key === k) do
672
- acc ++ [v]
673
- else
674
- acc
675
- end
676
- end)
677
- |> Enum.uniq_by(fn v ->
678
- v.name
679
- end)
680
- acc ++ [%{
681
- name: k,
682
- type: field_type_from_validations(v, validations),
683
- validations: validations
684
- }]
685
- end
686
- end)
687
- |> Enum.uniq_by(fn v ->
688
- v.name
689
- end)
690
- |> Enum.map(fn %{name: name} = field ->
691
- Map.put(
692
- field,
693
- :name,
694
- Absinthe.Adapter.LanguageConventions.to_external_name(
695
- to_string(
696
- name
697
- ),
698
- nil
699
- )
700
- )
701
- |> Map.put(
702
- :type,
703
- Absinthe.Adapter.LanguageConventions.to_external_name(
704
- to_string(
705
- field.type
706
- ),
707
- nil
708
- )
709
- )
710
- end)
711
- |> Jason.encode!(pretty: true)
712
- |> (fn res ->
713
- File.write!(
714
- files_to_be_generated(state).model_json
715
- |> Path.join,
716
- res
717
- )
718
- end).()
719
-
720
- state
721
- end
722
-
723
- def sync_mocks(%GqlForModel{} = state) do
724
- fields =
725
- prepare_mock(
726
- state.model.__changeset__
727
- )
728
- fields_patch = prepare_mock(
729
- state.model.__changeset__,
730
- :update
731
- )
732
- EEx.eval_string(
733
- Application.app_dir(
734
- :potionx,
735
- "priv/templates/#{@task_name}/model_mock.ex"
736
- )
737
- |> File.read!,
738
- Enum.map(
739
- Map.from_struct(state)
740
- |> Map.put(:mock, pretty_print(fields))
741
- |> Map.put(:mock_patch, pretty_print(fields_patch)),
742
- &(&1)
743
- )
744
- )
745
- |> (fn res ->
746
- File.write!(
747
- Path.join(files_to_be_generated(state).model_mock),
748
- res
749
- )
750
- end).()
751
-
752
- EEx.eval_string(
753
- Application.app_dir(
754
- :potionx,
755
- "priv/templates/#{@task_name}/model_mock.json"
756
- )
757
- |> File.read!,
758
- Enum.map(
759
- Map.from_struct(state)
760
- |> Map.put(
761
- :mock,
762
- fields
763
- |> Enum.reduce(%{}, fn {k, v}, acc ->
764
- Map.put(
765
- acc,
766
- Absinthe.Adapter.LanguageConventions.to_external_name(
767
- to_string(
768
- k
769
- ),
770
- nil
771
- ),
772
- v
773
- )
774
- end)
775
- |> Jason.encode!(pretty: true)
776
- ),
777
- &(&1)
778
- )
779
- )
780
- |> (fn res ->
781
- File.write!(
782
- Path.join(files_to_be_generated(state).model_mock_json),
783
- res
784
- )
785
- end).()
786
-
787
- state
788
- end
789
-
790
- def sync_objects(%GqlForModel{} = state) do
791
- fields = state.model.__changeset__
792
-
793
- types(state)
794
- |> Enum.reduce(state, fn line, state ->
795
- lines = state.lines.types
796
- head_index =
797
- lines
798
- |> Enum.find_index(fn l -> String.contains?(l, line) end)
799
- tail_index =
800
- lines
801
- |> Enum.slice(head_index..-1)
802
- |> Enum.find_index(fn l -> String.contains?(l, DocUtils.indent_to_string(2) <> "end") end)
803
- |> Kernel.+(head_index)
804
-
805
- snippet =
806
- Enum.slice(lines, head_index..tail_index)
807
-
808
- Enum.reduce(fields, snippet, fn
809
- {k, {:array, _}}, acc ->
810
- acc ++ ["field :#{k}, list_of(:string)"]
811
- {k, {:assoc, ass}}, acc ->
812
- if String.starts_with?(line, "input_object") or state.no_associations do
813
- acc
814
- else
815
- model = ass.related |> to_string() |> String.split(".") |> Enum.at(-1) |> Macro.underscore()
816
- result_type = ass.cardinality === :many && "list_of(:#{model})" || model
817
- acc ++ ["field :#{to_string(k)}, :#{result_type}, resolve: dataloader(#{state.module_name_graphql}.Resolver.#{state.model_name})"]
818
- end
819
- {:id, _}, acc ->
820
- if String.starts_with?(line, "input_object") do
821
- acc
822
- else
823
- acc ++ ["field :internal_id, :id, resolve: fn parent, _, _ -> {:ok, Map.get(parent || %{}, :id)} end"]
824
- end
825
- {k, v}, acc ->
826
- acc ++ ["field :#{k}, :#{to_string(maybe_convert_type(v, String.starts_with?(line, "input_object")))}"]
827
- end)
828
- |> (fn lines ->
829
- common_fields()
830
- |> Enum.reduce(lines, fn key, lines ->
831
- if (function_exported?(state.model, key, 1) && not String.starts_with?(line, "input_object")) do
832
- lines ++ [
833
- "field :#{to_string(key)}, :string, resolve: Potionx.Resolvers.resolve_computed(#{to_string(state.module_name_data)}.#{to_string(state.context_name)}.#{to_string(state.model_name)}, :#{to_string(key)})"
834
- ]
835
- else
836
- lines
837
- end
838
- end)
839
- end).()
840
- |> Enum.reduce(snippet, fn line, acc ->
841
- maybe_add_line(acc, line, 4)
842
- end)
843
- |> (fn snippet ->
844
- head = Enum.slice(lines, 0..(head_index - 1))
845
- tail = Enum.slice(lines, (tail_index + 1)..-1)
846
- %{
847
- state | lines:
848
- Map.put(state.lines, :types, Enum.concat([head, snippet, tail]))
849
- }
850
- end).()
851
- end)
852
- end
853
-
854
- def types(%GqlForModel{} = state) do
855
- [
856
- "node object :#{state.model_name_atom} do",
857
- "input_object :#{state.model_name_atom}_filters do",
858
- "input_object :#{state.model_name_atom}_input do"
859
- ]
860
- end
861
-
862
- defp validate_args!([context, schema] = args) do
863
- cond do
864
- not Context.valid?(context) ->
865
- raise_with_help "Expected the context, #{inspect context}, to be a valid module name"
866
- not Schema.valid?(schema) ->
867
- raise_with_help "Expected the schema, #{inspect schema}, to be a valid module name"
868
- context == schema ->
869
- raise_with_help "The context and schema should have different names"
870
- true ->
871
- args
872
- end
873
- end
874
- defp validate_args!(_args) do
875
- raise_with_help "Invalid arguments"
876
- end
877
-
878
- def validations(%Ecto.Changeset{} = cs) do
879
- cs
880
- |> Map.get(:required)
881
- |> Enum.map(fn c -> {c, :required} end)
882
- |> Kernel.++(
883
- cs
884
- |> Ecto.Changeset.validations()
885
- )
886
- |> Enum.map(fn
887
- {k, :required = n} ->
888
- {k, %{
889
- name: n,
890
- }}
891
- {k, {:format = n, r}} ->
892
- {k, %{
893
- name: n,
894
- params: %{
895
- pattern: Regex.source(r)
896
- }
897
- }}
898
- {k, {n, params}} when n == :length or n == :number ->
899
- {k, %{
900
- name: n,
901
- params: keyword_list_to_map(params)
902
- }}
903
- {k, {n, values}} when n == :inclusion or n == :exclusion or n === :subset ->
904
- {k, %{
905
- name: n,
906
- params: %{
907
- values: keyword_list_to_map(values)
908
- }
909
- }}
910
- {k, {n, _}} ->
911
- {k, %{name: n}}
912
- end)
913
- |> Enum.map(fn {name, validation} ->
914
- {
915
- name,
916
- Map.put(
917
- validation,
918
- :name,
919
- Absinthe.Adapter.LanguageConventions.to_external_name(
920
- to_string(
921
- validation.name
922
- ),
923
- nil
924
- )
925
- )
926
- }
927
- end)
928
- end
929
-
930
- def write_lines_to_files(%GqlForModel{} = state) do
931
- files = files_to_be_generated(state)
932
- Enum.each(state.lines, fn {k, lines} ->
933
- File.write(
934
- Path.join(Map.get(files, k)),
935
- Enum.join(lines, "\r\n"),
936
- [:write]
937
- )
938
- end)
939
- state
940
- end
941
- end
1
+ defmodule Mix.Tasks.Potionx.Gen.GqlForModel do
2
+ alias __MODULE__
3
+ @shortdoc "Generates GraphQL mutations, queries and types for an Ecto model"
4
+ @task_name "potion.gen.gql_for_model"
5
+ @default_opts [schema: true, context: true]
6
+ @switches [binary_id: :boolean, table: :string, web: :string,
7
+ schema: :boolean, context: :boolean, context_app: :string, no_associations: :boolean]
8
+ defstruct app_otp: nil,
9
+ context_name: nil,
10
+ context_name_snakecase: nil,
11
+ module_name_data: nil,
12
+ module_name_graphql: nil,
13
+ dir_context: nil,
14
+ dir_graphql: nil,
15
+ dir_test: nil,
16
+ graphql_fields: nil,
17
+ lines: %{},
18
+ potion_name: "Potionx",
19
+ mock: nil,
20
+ mock_patch: nil,
21
+ model: nil,
22
+ model_name_atom: nil,
23
+ model_file_path: nil,
24
+ model_name: nil,
25
+ model_name_graphql_case: nil,
26
+ model_name_snakecase: nil,
27
+ no_associations: false,
28
+ no_frontend: false,
29
+ no_mutations: false,
30
+ no_queries: false,
31
+ validations: []
32
+
33
+ use Mix.Task
34
+ alias Mix.Phoenix.{Context, Schema}
35
+ alias Potionx.DocUtils
36
+
37
+ def add_lines_to_block(block, lines_to_add, start_line, indent_size) when is_binary(start_line) do
38
+ start_index = Enum.find_index(block, fn l ->
39
+ String.starts_with?(l, start_line)
40
+ end)
41
+ add_lines_to_block(block, lines_to_add, start_index, indent_size)
42
+ end
43
+ def add_lines_to_block(block, lines_to_add, start_index, indent_size) do
44
+ end_index =
45
+ Enum.slice(block, start_index..-1)
46
+ |> Enum.find_index(fn l ->
47
+ String.starts_with?(l, DocUtils.indent_to_string(indent_size) <> "end")
48
+ end)
49
+ end_index = end_index + start_index
50
+ Enum.concat(
51
+ [
52
+ Enum.slice(block, 0..(end_index - 1)),
53
+ lines_to_add,
54
+ Enum.slice(block, end_index..-1)
55
+ ]
56
+ )
57
+ end
58
+
59
+ @doc false
60
+ def build(args) do
61
+ {opts, parsed, _} = parse_opts(args)
62
+ {opts, validate_args!(parsed)}
63
+ end
64
+
65
+ def common_fields do
66
+ [:description, :title]
67
+ end
68
+
69
+ def ensure_files_and_directories_exist(%GqlForModel{} = state) do
70
+ if (not File.dir?(state.dir_context)) do
71
+ Mix.raise """
72
+ Context directory #{state.dir_context} is missing
73
+ """
74
+ end
75
+ files_to_be_generated(state)
76
+ |> Enum.map(fn {k, path_enum} ->
77
+ File.mkdir_p!(Path.join(Enum.slice(path_enum, 0..-2)))
78
+ path = Path.join(path_enum)
79
+ file_name =
80
+ cond do
81
+ String.ends_with?(to_string(k), "_test") ->
82
+ to_string(k) <> ".exs"
83
+ String.ends_with?(to_string(k), "_json") ->
84
+ String.replace_trailing(to_string(k), "_json", "") <> ".json"
85
+ true ->
86
+ to_string(k) <> ".ex"
87
+ end
88
+ # if does not exist, create using templates
89
+ if not File.exists?(path) do
90
+ EEx.eval_string(
91
+ Application.app_dir(
92
+ :potionx,
93
+ "priv/templates/#{@task_name}/#{file_name}"
94
+ )
95
+ |> File.read!,
96
+ Enum.map(Map.from_struct(state), &(&1))
97
+ )
98
+ |> (fn res ->
99
+ File.write!(path, res)
100
+ end).()
101
+ end
102
+ end)
103
+
104
+ state
105
+ end
106
+ def field_type_from_validations(type, validations) do
107
+ Enum.find(validations, fn
108
+ %{name: :inclusion} -> true
109
+ _ -> false
110
+ end)
111
+ |> case do
112
+ nil -> type
113
+ type -> type
114
+ end
115
+ end
116
+
117
+
118
+ @doc false
119
+ def files_to_be_generated(%GqlForModel{} = state) do
120
+ %{
121
+ app_schema: [state.dir_graphql, "schema.ex"],
122
+ model_mock: [state.dir_context, "#{state.model_name_snakecase}_mock.ex"],
123
+ model_mock_json: [
124
+ "shared",
125
+ "src",
126
+ "models",
127
+ state.context_name,
128
+ state.model_name,
129
+ "#{state.model_name_graphql_case}.mock.json"
130
+ ],
131
+ model_json: [
132
+ "shared",
133
+ "src",
134
+ "models",
135
+ state.context_name,
136
+ state.model_name,
137
+ "#{state.model_name_graphql_case}.json"
138
+ ],
139
+ mutations: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_mutations.ex"],
140
+ mutations_test: [state.dir_test, "mutations", "#{state.model_name_snakecase}_mutations_test.exs"],
141
+ queries: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_queries.ex"],
142
+ queries_test: [state.dir_test, "queries", "#{state.model_name_snakecase}_queries_test.exs"],
143
+ resolver: [state.dir_graphql, "resolvers", "#{state.model_name_snakecase}_resolver.ex"],
144
+ service: [state.dir_context, "#{state.model_name_snakecase}_service.ex"],
145
+ types: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_types.ex"],
146
+ }
147
+ |> (fn res ->
148
+ [{:no_mutations, "mutations"}, {:no_queries, "queries"}]
149
+ |> Enum.reduce(res, fn {k, v}, acc ->
150
+ if (Map.get(state, k)) do
151
+ Map.drop(acc, [String.to_atom(v), String.to_atom(v <> "_test")])
152
+ else
153
+ acc
154
+ end
155
+ end)
156
+ end).()
157
+ end
158
+
159
+ def keyword_list_to_map(list) do
160
+ if (Keyword.keyword?(list)) do
161
+ Enum.into(list, %{})
162
+ else
163
+ list
164
+ end
165
+ end
166
+
167
+ def load_lines(%GqlForModel{} = state) do
168
+ files_to_be_generated(state)
169
+ |> Enum.filter(fn {k, _} -> Enum.member?([:app_schema, :mutations, :types, :queries], k) end)
170
+ |> Enum.reduce(state, fn {k, path_enum}, state ->
171
+ lines =
172
+ File.read!(
173
+ Path.join(path_enum)
174
+ )
175
+ |> String.trim
176
+ |> String.split(~r{(\r?)\n})
177
+ %{
178
+ state |
179
+ lines: Map.put(state.lines, k, lines)
180
+ }
181
+ end)
182
+ end
183
+
184
+ def load_model(%GqlForModel{} = state, nil) do
185
+ %{
186
+ state | model: [
187
+ "Elixir",
188
+ state.module_name_data,
189
+ state.context_name,
190
+ state.model_name
191
+ ]
192
+ |> Enum.join(".")
193
+ |> String.to_atom
194
+ }
195
+ end
196
+ def load_model(%GqlForModel{} = state, model), do: %{state | model: model}
197
+
198
+ def load_validations(%GqlForModel{} = state) do
199
+ %{
200
+ state | validations: validations(
201
+ state.model.changeset(struct(state.model, %{}), %{})
202
+ )
203
+ }
204
+ end
205
+
206
+ def maybe_add_default_types(%GqlForModel{} = state) do
207
+ [
208
+ {
209
+ "input_object :#{state.model_name_atom}_filters_single do",
210
+ [
211
+ "field :id, non_null(:global_id)"
212
+ ]
213
+ },
214
+ {
215
+ "object :#{state.model_name_snakecase}_mutation_result do",
216
+ [
217
+ "field :errors, list_of(:string)",
218
+ "field :errors_fields, list_of(:error)",
219
+ "field :node, :#{state.model_name_snakecase}",
220
+ "field :success_msg, :string"
221
+ ]
222
+ }
223
+ # {
224
+ # "object #{state.model_name_snakecase}_collection_result do",
225
+ # [
226
+ # ":errors, list_of(:string)",
227
+ # ":nodes, list_of(#{state.model_name_snakecase})",
228
+ # ":query_info, :query_info"
229
+ # ]
230
+ # },
231
+ ]
232
+ |> Enum.reduce(state, fn {head, lines}, acc ->
233
+ if Enum.find(acc.lines.types, fn l -> String.contains?(l, head) end) do
234
+ acc
235
+ else
236
+ block_to_add = Enum.concat([
237
+ [DocUtils.indent_to_string(2) <> head],
238
+ Enum.map(lines, fn l -> DocUtils.indent_to_string(4) <> l end),
239
+ [DocUtils.indent_to_string(2) <> "end"]
240
+ ])
241
+ %{
242
+ acc |
243
+ lines: Map.put(acc.lines, :types,
244
+ add_lines_to_block(acc.lines.types, block_to_add, Enum.at(acc.lines.types, 0), 0)
245
+ )
246
+ }
247
+ end
248
+ end)
249
+ end
250
+
251
+ def maybe_add_line(block, line, indent_size, close \\ false) do
252
+ lines_to_add =
253
+ block
254
+ |> Enum.find_index(fn l ->
255
+ String.contains?(l, line)
256
+ end)
257
+ |> case do
258
+ nil ->
259
+ [DocUtils.indent_to_string(indent_size) <> line]
260
+ |> (fn res ->
261
+ if close do
262
+ Enum.concat([res, [DocUtils.indent_to_string(indent_size) <> "end"]])
263
+ else
264
+ res
265
+ end
266
+ end).()
267
+ _ ->
268
+ []
269
+ end
270
+ add_lines_to_block(block, lines_to_add, Enum.at(block, 0), indent_size - 2)
271
+ end
272
+
273
+ def maybe_add_node_interface_type_resolve(%GqlForModel{} = state) do
274
+ # look for node interface, next line is resolve type
275
+ # insert into resolve_type block
276
+ interface_block_start_index = Enum.find_index(state.lines.app_schema, fn l ->
277
+ String.starts_with?(
278
+ l,
279
+ DocUtils.indent_to_string(2) <> "node interface"
280
+ )
281
+ end)
282
+ Enum.find(state.lines.app_schema, fn l ->
283
+ String.contains?(
284
+ l,
285
+ "#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->"
286
+ )
287
+ end)
288
+ |> if do
289
+ state
290
+ else
291
+ %{
292
+ state |
293
+ lines: Map.put(
294
+ state.lines,
295
+ :app_schema,
296
+ Enum.concat(
297
+ [
298
+ Enum.slice(state.lines.app_schema, 0..interface_block_start_index+1),
299
+ [
300
+ DocUtils.indent_to_string(6) <> "%#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->",
301
+ DocUtils.indent_to_string(8) <> ":#{state.model_name_atom}"
302
+ ],
303
+ Enum.slice(state.lines.app_schema, (interface_block_start_index+2)..-1)
304
+ ]
305
+ )
306
+ )
307
+ }
308
+ end
309
+ end
310
+
311
+ def maybe_convert_type(type, is_input \\ false) do
312
+ case type do
313
+ Ecto.UUID -> :id
314
+ :binary -> :string
315
+ :binary_id -> :string
316
+ :datetime -> :naive_datetime
317
+ :id -> is_input && :global_id || :id
318
+ :map -> :json
319
+ :naive_datetime_usec -> :naive_datetime
320
+ :utc_datetime_usec -> :datetime
321
+ :utc_datetime -> :datetime
322
+ _ -> type
323
+ end
324
+ end
325
+
326
+ def maybe_init_types(%GqlForModel{} = state) do
327
+ Enum.map(types(state), fn line ->
328
+ {:types, line}
329
+ end)
330
+ |> Enum.reduce(state, fn {k, v}, acc ->
331
+ %{
332
+ acc |
333
+ lines:
334
+ Map.put(
335
+ acc.lines,
336
+ k,
337
+ maybe_add_line(Map.get(acc.lines, k), v, 2, true)
338
+ |> (fn lines_types ->
339
+ lines_types
340
+ |> Enum.find_index(fn l ->
341
+ String.contains?(l, "connection node_type: :#{state.model_name_atom}")
342
+ end)
343
+ |> case do
344
+ nil ->
345
+ add_lines_to_block(
346
+ lines_types,
347
+ [
348
+ DocUtils.indent_to_string(2) <> "connection node_type: :#{state.model_name_atom} do",
349
+ DocUtils.indent_to_string(4) <> "field :count, non_null(:integer)",
350
+ DocUtils.indent_to_string(4) <> "field :count_before, non_null(:integer)",
351
+ DocUtils.indent_to_string(4) <> "edge do",
352
+ DocUtils.indent_to_string(4) <> "end",
353
+ DocUtils.indent_to_string(2) <> "end"
354
+ ],
355
+ Enum.at(lines_types, 0),
356
+ 0
357
+ )
358
+ _ -> lines_types
359
+ end
360
+ end).()
361
+ )
362
+ }
363
+ end)
364
+ end
365
+
366
+ def maybe_update_main_schema(%GqlForModel{} = state) do
367
+ [
368
+ {:no_mutations, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Mutations"},
369
+ {:no_queries, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Queries"},
370
+ {:no_types, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Types"}
371
+ ]
372
+ |> Enum.reduce(state.lines.app_schema, fn {k, line}, acc ->
373
+ if (Map.get(state, k)) do
374
+ acc
375
+ else
376
+ maybe_add_line(
377
+ acc,
378
+ line,
379
+ 2
380
+ )
381
+ end
382
+ end)
383
+ |> (fn lines ->
384
+ [
385
+ {:no_mutations, "mutation do", "import_fields :#{state.model_name_snakecase}_mutations"},
386
+ {:no_queries, "query do", "import_fields :#{state.model_name_snakecase}_queries"},
387
+ {
388
+ :no_mutations,
389
+ "def dataloader",
390
+ "|> Dataloader.add_source(#{state.module_name_graphql}.Resolver.#{state.model_name}, #{state.module_name_graphql}.Resolver.#{state.model_name}.data())"
391
+ }
392
+ ]
393
+ |> Enum.reduce(lines, fn {flag, k, v}, acc ->
394
+ (
395
+ Map.get(state, flag) or
396
+ Enum.find(acc, fn line -> String.contains?(line, v) end)
397
+ )
398
+ |> if do
399
+ acc
400
+ else
401
+ add_lines_to_block(
402
+ acc,
403
+ [DocUtils.indent_to_string(4) <> v],
404
+ DocUtils.indent_to_string(2) <> k,
405
+ 2
406
+ )
407
+ end
408
+ end)
409
+ end).()
410
+ |> (fn lines ->
411
+ %{state | lines: Map.put(state.lines, :app_schema, lines)}
412
+ end).()
413
+ end
414
+
415
+ defp parse_opts(args) do
416
+ {opts, parsed, invalid} = OptionParser.parse(args, switches: @switches)
417
+ merged_opts =
418
+ @default_opts
419
+ |> Keyword.merge(opts)
420
+ |> put_context_app(opts[:context_app])
421
+
422
+ {merged_opts, parsed, invalid}
423
+ end
424
+
425
+ def prepare_mock(params, type \\ :create) do
426
+ params
427
+ |> Enum.filter(fn
428
+ {_, {:assoc, _}} -> false
429
+ _ -> true
430
+ end)
431
+ |> Enum.map(fn
432
+ {k, Ecto.UUID} -> {k, :uuid}
433
+ {k, v} -> {k, v}
434
+ end)
435
+ |> Mix.Phoenix.Schema.params(type)
436
+ |> Enum.map(fn
437
+ {:email, _} ->
438
+ {:email, type === :create && "[email protected]" || "[email protected]"}
439
+ {k, v} ->
440
+ {k, v}
441
+ end)
442
+ |> Enum.into(%{})
443
+ end
444
+
445
+ def pretty_print(m) do
446
+ inspect(m, pretty: true)
447
+ |> String.split(~r{(\r?)\n})
448
+ |> Enum.map(fn l -> " " <> l end)
449
+ |> Enum.join("\r\n")
450
+ end
451
+
452
+ defp put_context_app(opts, nil), do: opts
453
+ defp put_context_app(opts, string) do
454
+ Keyword.put(opts, :context_app, String.to_atom(string))
455
+ end
456
+
457
+ @doc false
458
+ @spec raise_with_help(String.t) :: no_return()
459
+ def raise_with_help(msg) do
460
+ Mix.raise """
461
+ #{msg}
462
+
463
+ mix #{@task_name} expects a
464
+ context module name, followed by the singular module name
465
+
466
+ mix #{@task_name} Accounts User
467
+
468
+ The context serves as the API boundary for the given resource.
469
+ Multiple resources may belong to a context and a resource may be
470
+ split over distinct contexts (such as Accounts.User and Payments.User).
471
+ """
472
+ end
473
+
474
+ @doc false
475
+ def run(args, model \\ nil) do
476
+ args = Enum.filter(args, fn a -> not is_struct(a) end)
477
+
478
+ if Mix.Project.umbrella? do
479
+ Mix.raise "mix #{@task_name} can only be run inside an application directory"
480
+ end
481
+ if (!model) do
482
+ Mix.Task.run("app.start")
483
+ end
484
+ {opts, [context, schema]} = build(args)
485
+
486
+ this_app = Mix.Phoenix.otp_app()
487
+ dir_context = Path.join(["lib", "#{this_app}", Macro.underscore(context)])
488
+ %GqlForModel{
489
+ app_otp: this_app,
490
+ context_name: context,
491
+ context_name_snakecase: Macro.underscore(context),
492
+ dir_context: dir_context,
493
+ dir_graphql: Path.join(["lib", "#{this_app}_graphql"]),
494
+ dir_test: Path.join(["test", "#{this_app}_graphql"]),
495
+ model_file_path: Path.join([dir_context, Macro.underscore(schema) <> ".ex"]),
496
+ model_name: schema,
497
+ model_name_atom: Macro.underscore(schema) |> String.to_atom,
498
+ model_name_graphql_case: Macro.underscore(schema) |> Absinthe.Adapter.LanguageConventions.to_external_name(nil),
499
+ model_name_snakecase: Macro.underscore(schema),
500
+ module_name_data: Mix.Phoenix.context_base(
501
+ Mix.Phoenix.context_app()
502
+ ),
503
+ module_name_graphql: Mix.Phoenix.context_base(
504
+ Mix.Phoenix.context_app()
505
+ ) <> "GraphQl",
506
+ no_associations: Keyword.get(opts, :no_associations, false),
507
+ no_frontend: Keyword.get(opts, :no_frontend, false),
508
+ no_mutations: Keyword.get(opts, :no_mutations, false),
509
+ no_queries: Keyword.get(opts, :no_queries, false)
510
+ }
511
+ |> ensure_files_and_directories_exist
512
+ |> load_model(model)
513
+ |> load_lines
514
+ |> load_validations
515
+ |> maybe_init_types
516
+ |> sync_mocks
517
+ |> sync_objects
518
+ |> sync_graphql_files
519
+ |> maybe_add_default_types
520
+ |> maybe_add_node_interface_type_resolve
521
+ |> maybe_update_main_schema
522
+ |> sync_json_schema
523
+ |> run_npx_generator
524
+ |> write_lines_to_files
525
+ end
526
+
527
+ def run_npx_generator(%GqlForModel{no_frontend: true} = state), do: state
528
+ def run_npx_generator(%GqlForModel{} = state) do
529
+ Mix.shell().cmd(
530
+ "npx @potionapps/templates@latest model #{state.context_name} #{state.model_name} --destination=./frontend/admin"
531
+ )
532
+ state
533
+ end
534
+
535
+ def sync_graphql_files(%GqlForModel{} = state) do
536
+ fields = prepare_mock(
537
+ state.model.__changeset__
538
+ )
539
+ fields_computed =
540
+ common_fields()
541
+ |> Enum.reduce(%{}, fn key, fields ->
542
+ if (function_exported?(state.model, key, 1)) do
543
+ Map.put(fields, key, :string)
544
+ else
545
+ fields
546
+ end
547
+ end)
548
+ state = %{
549
+ state |
550
+ graphql_fields:
551
+ Map.merge(fields_computed, fields)
552
+ |> Enum.reduce([], fn
553
+ {_, {:assoc, _}}, acc ->
554
+ acc
555
+ {k, _}, acc ->
556
+ acc ++ [Absinthe.Adapter.LanguageConventions.to_external_name(to_string(k), nil)]
557
+ end)
558
+ }
559
+ [
560
+ {
561
+ :no_queries,
562
+ "priv/templates/#{@task_name}/collection.gql",
563
+ [
564
+ "shared",
565
+ "src",
566
+ "models",
567
+ state.context_name,
568
+ state.model_name,
569
+ "#{state.model_name_graphql_case}Collection.gql"
570
+ ]
571
+ },
572
+ {
573
+ :no_mutations,
574
+ "priv/templates/#{@task_name}/delete.gql",
575
+ [
576
+ "shared",
577
+ "src",
578
+ "models",
579
+ state.context_name,
580
+ state.model_name,
581
+ "#{state.model_name_graphql_case}Delete.gql"
582
+ ]
583
+ },
584
+ {
585
+ :no_mutations,
586
+ "priv/templates/#{@task_name}/mutation.gql",
587
+ [
588
+ "shared",
589
+ "src",
590
+ "models",
591
+ state.context_name,
592
+ state.model_name,
593
+ "#{state.model_name_graphql_case}Mutation.gql"
594
+ ]
595
+ },
596
+ {
597
+ :no_queries,
598
+ "priv/templates/#{@task_name}/single.gql",
599
+ [
600
+ "shared",
601
+ "src",
602
+ "models",
603
+ state.context_name,
604
+ state.model_name,
605
+ "#{state.model_name_graphql_case}Single.gql"
606
+ ]
607
+ }
608
+ ]
609
+ |> Enum.each(
610
+ fn {flag, template, path_parts} ->
611
+ unless Map.get(state, flag) do
612
+ EEx.eval_string(
613
+ Application.app_dir(
614
+ :potionx,
615
+ template
616
+ )
617
+ |> File.read!,
618
+ Enum.map(
619
+ Map.from_struct(state),
620
+ &(&1)
621
+ )
622
+ )
623
+ |> (fn res ->
624
+ File.write!(
625
+ Path.join(path_parts),
626
+ res
627
+ )
628
+ end).()
629
+ end
630
+ end
631
+ )
632
+
633
+ state
634
+ end
635
+
636
+ def sync_json_schema(%GqlForModel{} = state) do
637
+ model_json_raw = File.read!(
638
+ files_to_be_generated(state).model_json
639
+ |> Path.join
640
+ )
641
+ model_json = Jason.decode!(model_json_raw, keys: :atoms)
642
+ state.model.__changeset__
643
+ |> Enum.reduce(model_json, fn
644
+ {_, {:assoc, _}}, acc -> acc
645
+ {k, {:array, opts}}, acc ->
646
+ options =
647
+ case opts do
648
+ {_, _, %{values: values}} -> values
649
+ _ -> []
650
+ end
651
+ acc ++ [%{
652
+ name: k,
653
+ options: options,
654
+ type: "checkbox",
655
+ validations:
656
+ Enum.reduce(state.validations, [], fn {key, v}, acc ->
657
+ if (key === k) do
658
+ acc ++ [v]
659
+ else
660
+ acc
661
+ end
662
+ end)
663
+ }]
664
+ {k, v}, acc ->
665
+ cond do
666
+ Enum.member?([:inserted_at, :updated_at], k) ->
667
+ acc
668
+ true ->
669
+ validations =
670
+ Enum.reduce(state.validations, [], fn {key, v}, acc ->
671
+ if (key === k) do
672
+ acc ++ [v]
673
+ else
674
+ acc
675
+ end
676
+ end)
677
+ |> Enum.uniq_by(fn v ->
678
+ v.name
679
+ end)
680
+ acc ++ [%{
681
+ name: k,
682
+ type: field_type_from_validations(v, validations),
683
+ validations: validations
684
+ }]
685
+ end
686
+ end)
687
+ |> Enum.uniq_by(fn v ->
688
+ v.name
689
+ end)
690
+ |> Enum.map(fn %{name: name} = field ->
691
+ Map.put(
692
+ field,
693
+ :name,
694
+ Absinthe.Adapter.LanguageConventions.to_external_name(
695
+ to_string(
696
+ name
697
+ ),
698
+ nil
699
+ )
700
+ )
701
+ |> Map.put(
702
+ :type,
703
+ Absinthe.Adapter.LanguageConventions.to_external_name(
704
+ to_string(
705
+ field.type
706
+ ),
707
+ nil
708
+ )
709
+ )
710
+ end)
711
+ |> Jason.encode!(pretty: true)
712
+ |> (fn res ->
713
+ File.write!(
714
+ files_to_be_generated(state).model_json
715
+ |> Path.join,
716
+ res
717
+ )
718
+ end).()
719
+
720
+ state
721
+ end
722
+
723
+ def sync_mocks(%GqlForModel{} = state) do
724
+ fields =
725
+ prepare_mock(
726
+ state.model.__changeset__
727
+ )
728
+ fields_patch = prepare_mock(
729
+ state.model.__changeset__,
730
+ :update
731
+ )
732
+ EEx.eval_string(
733
+ Application.app_dir(
734
+ :potionx,
735
+ "priv/templates/#{@task_name}/model_mock.ex"
736
+ )
737
+ |> File.read!,
738
+ Enum.map(
739
+ Map.from_struct(state)
740
+ |> Map.put(:mock, pretty_print(fields))
741
+ |> Map.put(:mock_patch, pretty_print(fields_patch)),
742
+ &(&1)
743
+ )
744
+ )
745
+ |> (fn res ->
746
+ File.write!(
747
+ Path.join(files_to_be_generated(state).model_mock),
748
+ res
749
+ )
750
+ end).()
751
+
752
+ EEx.eval_string(
753
+ Application.app_dir(
754
+ :potionx,
755
+ "priv/templates/#{@task_name}/model_mock.json"
756
+ )
757
+ |> File.read!,
758
+ Enum.map(
759
+ Map.from_struct(state)
760
+ |> Map.put(
761
+ :mock,
762
+ fields
763
+ |> Enum.reduce(%{}, fn {k, v}, acc ->
764
+ Map.put(
765
+ acc,
766
+ Absinthe.Adapter.LanguageConventions.to_external_name(
767
+ to_string(
768
+ k
769
+ ),
770
+ nil
771
+ ),
772
+ v
773
+ )
774
+ end)
775
+ |> Jason.encode!(pretty: true)
776
+ ),
777
+ &(&1)
778
+ )
779
+ )
780
+ |> (fn res ->
781
+ File.write!(
782
+ Path.join(files_to_be_generated(state).model_mock_json),
783
+ res
784
+ )
785
+ end).()
786
+
787
+ state
788
+ end
789
+
790
+ def sync_objects(%GqlForModel{} = state) do
791
+ fields = state.model.__changeset__
792
+
793
+ types(state)
794
+ |> Enum.reduce(state, fn line, state ->
795
+ lines = state.lines.types
796
+ head_index =
797
+ lines
798
+ |> Enum.find_index(fn l -> String.contains?(l, line) end)
799
+ tail_index =
800
+ lines
801
+ |> Enum.slice(head_index..-1)
802
+ |> Enum.find_index(fn l -> String.contains?(l, DocUtils.indent_to_string(2) <> "end") end)
803
+ |> Kernel.+(head_index)
804
+
805
+ snippet =
806
+ Enum.slice(lines, head_index..tail_index)
807
+
808
+ Enum.reduce(fields, snippet, fn
809
+ {k, {:array, _}}, acc ->
810
+ acc ++ ["field :#{k}, list_of(:string)"]
811
+ {k, {:assoc, ass}}, acc ->
812
+ if String.starts_with?(line, "input_object") or state.no_associations do
813
+ acc
814
+ else
815
+ model = ass.related |> to_string() |> String.split(".") |> Enum.at(-1) |> Macro.underscore()
816
+ result_type = ass.cardinality === :many && "list_of(:#{model})" || model
817
+ acc ++ ["field :#{to_string(k)}, :#{result_type}, resolve: dataloader(#{state.module_name_graphql}.Resolver.#{state.model_name})"]
818
+ end
819
+ {:id, _}, acc ->
820
+ if String.starts_with?(line, "input_object") do
821
+ acc
822
+ else
823
+ acc ++ ["field :internal_id, :id, resolve: fn parent, _, _ -> {:ok, Map.get(parent || %{}, :id)} end"]
824
+ end
825
+ {k, v}, acc ->
826
+ acc ++ ["field :#{k}, :#{to_string(maybe_convert_type(v, String.starts_with?(line, "input_object")))}"]
827
+ end)
828
+ |> (fn lines ->
829
+ common_fields()
830
+ |> Enum.reduce(lines, fn key, lines ->
831
+ if (function_exported?(state.model, key, 1) && not String.starts_with?(line, "input_object")) do
832
+ lines ++ [
833
+ "field :#{to_string(key)}, :string, resolve: Potionx.Resolvers.resolve_computed(#{to_string(state.module_name_data)}.#{to_string(state.context_name)}.#{to_string(state.model_name)}, :#{to_string(key)})"
834
+ ]
835
+ else
836
+ lines
837
+ end
838
+ end)
839
+ end).()
840
+ |> Enum.reduce(snippet, fn line, acc ->
841
+ maybe_add_line(acc, line, 4)
842
+ end)
843
+ |> (fn snippet ->
844
+ head = Enum.slice(lines, 0..(head_index - 1))
845
+ tail = Enum.slice(lines, (tail_index + 1)..-1)
846
+ %{
847
+ state | lines:
848
+ Map.put(state.lines, :types, Enum.concat([head, snippet, tail]))
849
+ }
850
+ end).()
851
+ end)
852
+ end
853
+
854
+ def types(%GqlForModel{} = state) do
855
+ [
856
+ "node object :#{state.model_name_atom} do",
857
+ "input_object :#{state.model_name_atom}_filters do",
858
+ "input_object :#{state.model_name_atom}_input do"
859
+ ]
860
+ end
861
+
862
+ defp validate_args!([context, schema] = args) do
863
+ cond do
864
+ not Context.valid?(context) ->
865
+ raise_with_help "Expected the context, #{inspect context}, to be a valid module name"
866
+ not Schema.valid?(schema) ->
867
+ raise_with_help "Expected the schema, #{inspect schema}, to be a valid module name"
868
+ context == schema ->
869
+ raise_with_help "The context and schema should have different names"
870
+ true ->
871
+ args
872
+ end
873
+ end
874
+ defp validate_args!(_args) do
875
+ raise_with_help "Invalid arguments"
876
+ end
877
+
878
+ def validations(%Ecto.Changeset{} = cs) do
879
+ cs
880
+ |> Map.get(:required)
881
+ |> Enum.map(fn c -> {c, :required} end)
882
+ |> Kernel.++(
883
+ cs
884
+ |> Ecto.Changeset.validations()
885
+ )
886
+ |> Enum.map(fn
887
+ {k, :required = n} ->
888
+ {k, %{
889
+ name: n,
890
+ }}
891
+ {k, {:format = n, r}} ->
892
+ {k, %{
893
+ name: n,
894
+ params: %{
895
+ pattern: Regex.source(r)
896
+ }
897
+ }}
898
+ {k, {n, params}} when n == :length or n == :number ->
899
+ {k, %{
900
+ name: n,
901
+ params: keyword_list_to_map(params)
902
+ }}
903
+ {k, {n, values}} when n == :inclusion or n == :exclusion or n === :subset ->
904
+ {k, %{
905
+ name: n,
906
+ params: %{
907
+ values: keyword_list_to_map(values)
908
+ }
909
+ }}
910
+ {k, {n, _}} ->
911
+ {k, %{name: n}}
912
+ end)
913
+ |> Enum.map(fn {name, validation} ->
914
+ {
915
+ name,
916
+ Map.put(
917
+ validation,
918
+ :name,
919
+ Absinthe.Adapter.LanguageConventions.to_external_name(
920
+ to_string(
921
+ validation.name
922
+ ),
923
+ nil
924
+ )
925
+ )
926
+ }
927
+ end)
928
+ end
929
+
930
+ def write_lines_to_files(%GqlForModel{} = state) do
931
+ files = files_to_be_generated(state)
932
+ Enum.each(state.lines, fn {k, lines} ->
933
+ File.write(
934
+ Path.join(Map.get(files, k)),
935
+ Enum.join(lines, "\r\n"),
936
+ [:write]
937
+ )
938
+ end)
939
+ state
940
+ end
941
+ end
changed lib/potionx.ex
 
@@ -1,5 +1,5 @@
1
- defmodule Potionx do
2
- @moduledoc """
3
- Documentation for `Potionx`.
4
- """
5
- end
1
+ defmodule Potionx do
2
+ @moduledoc """
3
+ Documentation for `Potionx`.
4
+ """
5
+ end
changed lib/potionx/auth/assent_azure_common_strategy.ex
 
@@ -1,46 +1,46 @@
1
- defmodule Potionx.Auth.Assent.AzureADCommonStrategy do
2
- @moduledoc false
3
- use Assent.Strategy.OIDC.Base
4
-
5
- alias Assent.{Config, Strategy.OIDC}
6
-
7
- @impl true
8
- def default_config(config) do
9
- Keyword.merge(
10
- [
11
- authorization_params: [scope: "email profile", response_mode: "form_post"],
12
- client_auth_method: :client_secret_post,
13
- site: "https://login.microsoftonline.com/common/v2.0"
14
- ],
15
- config
16
- )
17
- end
18
-
19
- @impl true
20
- def normalize(_config, user), do: {:ok, user}
21
-
22
- @impl true
23
- def fetch_user(config, token) do
24
- with {:ok, issuer} <- fetch_iss(token["id_token"], config),
25
- {:ok, config} <- update_issuer_in_config(config, issuer),
26
- {:ok, jwt} <- OIDC.validate_id_token(config, token["id_token"]) do
27
- Helpers.normalize_userinfo(jwt.claims)
28
- end
29
- end
30
-
31
- defp fetch_iss(encoded, config) do
32
- with [_, encoded, _] <- String.split(encoded, "."),
33
- {:ok, json} <- Base.url_decode64(encoded, padding: false),
34
- {:ok, claims} <- Config.json_library(config).decode(json) do
35
- Map.fetch(claims, "iss")
36
- else
37
- {:error, error} -> {:error, error}
38
- _any -> {:error, "The ID Token is not a valid JWT"}
39
- end
40
- end
41
-
42
- defp update_issuer_in_config(config, issuer) do
43
- openid_configuration = Map.put(config[:openid_configuration], "issuer", issuer)
44
- {:ok, Keyword.put(config, :openid_configuration, openid_configuration)}
45
- end
46
- end
1
+ defmodule Potionx.Auth.Assent.AzureADCommonStrategy do
2
+ @moduledoc false
3
+ use Assent.Strategy.OIDC.Base
4
+
5
+ alias Assent.{Config, Strategy.OIDC}
6
+
7
+ @impl true
8
+ def default_config(config) do
9
+ Keyword.merge(
10
+ [
11
+ authorization_params: [scope: "email profile", response_mode: "form_post"],
12
+ client_auth_method: :client_secret_post,
13
+ site: "https://login.microsoftonline.com/common/v2.0"
14
+ ],
15
+ config
16
+ )
17
+ end
18
+
19
+ @impl true
20
+ def normalize(_config, user), do: {:ok, user}
21
+
22
+ @impl true
23
+ def fetch_user(config, token) do
24
+ with {:ok, issuer} <- fetch_iss(token["id_token"], config),
25
+ {:ok, config} <- update_issuer_in_config(config, issuer),
26
+ {:ok, jwt} <- OIDC.validate_id_token(config, token["id_token"]) do
27
+ Helpers.normalize_userinfo(jwt.claims)
28
+ end
29
+ end
30
+
31
+ defp fetch_iss(encoded, config) do
32
+ with [_, encoded, _] <- String.split(encoded, "."),
33
+ {:ok, json} <- Base.url_decode64(encoded, padding: false),
34
+ {:ok, claims} <- Config.json_library(config).decode(json) do
35
+ Map.fetch(claims, "iss")
36
+ else
37
+ {:error, error} -> {:error, error}
38
+ _any -> {:error, "The ID Token is not a valid JWT"}
39
+ end
40
+ end
41
+
42
+ defp update_issuer_in_config(config, issuer) do
43
+ openid_configuration = Map.put(config[:openid_configuration], "issuer", issuer)
44
+ {:ok, Keyword.put(config, :openid_configuration, openid_configuration)}
45
+ end
46
+ end
changed lib/potionx/auth/auth.ex
 
@@ -1,109 +1,109 @@
1
- defmodule Potionx.Auth do
2
- use TypedStruct
3
-
4
- def cookie_options(%Plug.Conn{}, config, max_age) do
5
- Keyword.merge(
6
- [
7
- http_only: true,
8
- max_age: max_age,
9
- secure: true
10
- ],
11
- (config || [])
12
- )
13
- end
14
-
15
- def delete_cookie(conn, %{name: name, ttl_seconds: ttl_seconds}) do
16
- conn
17
- |> Plug.Conn.delete_resp_cookie(
18
- name,
19
- cookie_options(
20
- conn,
21
- [sign: true],
22
- ttl_seconds
23
- )
24
- )
25
- end
26
-
27
- @doc """
28
- Handles setting sign up cookies used only during social login and general setting cookies.
29
- """
30
- @spec handle_user_session_cookies(struct(), Plug.Conn.t()) :: any
31
- def handle_user_session_cookies(%{uuid_renewal: nil, uuid_access: uuid_access} = session, conn) when not is_nil(uuid_access) do
32
- conn
33
- |> Potionx.Auth.set_cookie(%{
34
- name: Potionx.Auth.token_config().access_token.name,
35
- same_site: "none",
36
- token: uuid_access,
37
- ttl_seconds: session.ttl_access_seconds
38
- })
39
- end
40
- def handle_user_session_cookies(%{uuid_renewal: renewal} = session, conn) when not is_nil(renewal) do
41
- conn
42
- |> Potionx.Auth.set_cookie(%{
43
- http_only: false,
44
- name: Potionx.Auth.token_config().frontend.name,
45
- token: "1",
46
- ttl_seconds: session.ttl_access_seconds
47
- })
48
- |> Potionx.Auth.set_cookie(%{
49
- name: Potionx.Auth.token_config().access_token.name,
50
- token: session.uuid_access,
51
- ttl_seconds: session.ttl_access_seconds
52
- })
53
- |> Potionx.Auth.set_cookie(%{
54
- name: Potionx.Auth.token_config().renewal_token.name,
55
- token: session.uuid_renewal,
56
- ttl_seconds: session.ttl_renewal_seconds
57
- })
58
- end
59
- def handle_user_session_cookies(err, _conn), do: err
60
-
61
- def set_cookie(conn, %{name: name, token: token, ttl_seconds: ttl_seconds} = config) do
62
- conn
63
- |> Plug.Conn.put_resp_cookie(
64
- name,
65
- token,
66
- cookie_options(
67
- conn,
68
- [
69
- http_only: Map.get(config, :http_only, true),
70
- same_site: Map.get(config, :same_site) || "lax",
71
- sign: true
72
- ],
73
- ttl_seconds
74
- )
75
- )
76
- end
77
-
78
- def token_config(config \\ []) do
79
- Map.merge(
80
- %{
81
- access_token: %{
82
- name: "a_app",
83
- ttl_key: :ttl_access_seconds,
84
- ttl_seconds: 60 * 30, # 30 minutes,
85
- uuid_key: :uuid_access
86
- },
87
- frontend: %{
88
- name: "frontend",
89
- ttl_key: nil,
90
- ttl_seconds: 60 * 30, # 30 minutes
91
- uuid_key: nil
92
- },
93
- renewal_token: %{
94
- name: "r_app",
95
- ttl_key: :ttl_renewal_seconds,
96
- ttl_seconds: 60 * 60 * 24 * 30, # 30 days
97
- uuid_key: :uuid_renewal
98
- },
99
- sign_in_token: %{
100
- name: "a_app",
101
- ttl_key: :ttl_access_seconds,
102
- ttl_seconds: 60 * 5, # 5 minutes
103
- uuid_key: :uuid_access
104
- }
105
- },
106
- Keyword.get(config, :token_config, %{})
107
- )
108
- end
109
- end
1
+ defmodule Potionx.Auth do
2
+ use TypedStruct
3
+
4
+ def cookie_options(%Plug.Conn{}, config, max_age) do
5
+ Keyword.merge(
6
+ [
7
+ http_only: true,
8
+ max_age: max_age,
9
+ secure: true
10
+ ],
11
+ (config || [])
12
+ )
13
+ end
14
+
15
+ def delete_cookie(conn, %{name: name, ttl_seconds: ttl_seconds}) do
16
+ conn
17
+ |> Plug.Conn.delete_resp_cookie(
18
+ name,
19
+ cookie_options(
20
+ conn,
21
+ [sign: true],
22
+ ttl_seconds
23
+ )
24
+ )
25
+ end
26
+
27
+ @doc """
28
+ Handles setting sign up cookies used only during social login and general setting cookies.
29
+ """
30
+ @spec handle_user_session_cookies(struct(), Plug.Conn.t()) :: any
31
+ def handle_user_session_cookies(%{uuid_renewal: nil, uuid_access: uuid_access} = session, conn) when not is_nil(uuid_access) do
32
+ conn
33
+ |> Potionx.Auth.set_cookie(%{
34
+ name: Potionx.Auth.token_config().access_token.name,
35
+ same_site: "none",
36
+ token: uuid_access,
37
+ ttl_seconds: session.ttl_access_seconds
38
+ })
39
+ end
40
+ def handle_user_session_cookies(%{uuid_renewal: renewal} = session, conn) when not is_nil(renewal) do
41
+ conn
42
+ |> Potionx.Auth.set_cookie(%{
43
+ http_only: false,
44
+ name: Potionx.Auth.token_config().frontend.name,
45
+ token: "1",
46
+ ttl_seconds: session.ttl_access_seconds
47
+ })
48
+ |> Potionx.Auth.set_cookie(%{
49
+ name: Potionx.Auth.token_config().access_token.name,
50
+ token: session.uuid_access,
51
+ ttl_seconds: session.ttl_access_seconds
52
+ })
53
+ |> Potionx.Auth.set_cookie(%{
54
+ name: Potionx.Auth.token_config().renewal_token.name,
55
+ token: session.uuid_renewal,
56
+ ttl_seconds: session.ttl_renewal_seconds
57
+ })
58
+ end
59
+ def handle_user_session_cookies(err, _conn), do: err
60
+
61
+ def set_cookie(conn, %{name: name, token: token, ttl_seconds: ttl_seconds} = config) do
62
+ conn
63
+ |> Plug.Conn.put_resp_cookie(
64
+ name,
65
+ token,
66
+ cookie_options(
67
+ conn,
68
+ [
69
+ http_only: Map.get(config, :http_only, true),
70
+ same_site: Map.get(config, :same_site) || "lax",
71
+ sign: true
72
+ ],
73
+ ttl_seconds
74
+ )
75
+ )
76
+ end
77
+
78
+ def token_config(config \\ []) do
79
+ Map.merge(
80
+ %{
81
+ access_token: %{
82
+ name: "a_app",
83
+ ttl_key: :ttl_access_seconds,
84
+ ttl_seconds: 60 * 30, # 30 minutes,
85
+ uuid_key: :uuid_access
86
+ },
87
+ frontend: %{
88
+ name: "frontend",
89
+ ttl_key: nil,
90
+ ttl_seconds: 60 * 30, # 30 minutes
91
+ uuid_key: nil
92
+ },
93
+ renewal_token: %{
94
+ name: "r_app",
95
+ ttl_key: :ttl_renewal_seconds,
96
+ ttl_seconds: 60 * 60 * 24 * 30, # 30 days
97
+ uuid_key: :uuid_renewal
98
+ },
99
+ sign_in_token: %{
100
+ name: "a_app",
101
+ ttl_key: :ttl_access_seconds,
102
+ ttl_seconds: 60 * 5, # 5 minutes
103
+ uuid_key: :uuid_access
104
+ }
105
+ },
106
+ Keyword.get(config, :token_config, %{})
107
+ )
108
+ end
109
+ end
changed lib/potionx/auth/auth_dev_provider.ex
 
@@ -1,34 +1,34 @@
1
- defmodule Potionx.Auth.Provider.Dev do
2
- @moduledoc false
3
- @behaviour Assent.Strategy
4
-
5
- def authorize_url(config) do
6
- case config[:error] do
7
- nil -> {
8
- :ok, %{
9
- session_params: %{a: 1},
10
- url: "/api/v1/auth/dev/callback"
11
- }
12
- }
13
- error -> {:error, error}
14
- end
15
- end
16
-
17
- def callback(_config, %{"email" => email}) do
18
- {
19
- :ok,
20
- %{
21
- token: %{"access_token" => "access_token"},
22
- user: %{
23
- "sub" => email,
24
- "given_name" => String.split(email, "@") |> Enum.at(0),
25
- "email" => email,
26
- "family_name" => String.split(email, "@") |> Enum.at(1)
27
- }
28
- }
29
- }
30
- end
31
- def callback(_config, _params) do
32
- {:error, "Invalid params"}
33
- end
34
- end
1
+ defmodule Potionx.Auth.Provider.Dev do
2
+ @moduledoc false
3
+ @behaviour Assent.Strategy
4
+
5
+ def authorize_url(config) do
6
+ case config[:error] do
7
+ nil -> {
8
+ :ok, %{
9
+ session_params: %{a: 1},
10
+ url: "/api/v1/auth/dev/callback"
11
+ }
12
+ }
13
+ error -> {:error, error}
14
+ end
15
+ end
16
+
17
+ def callback(_config, %{"email" => email}) do
18
+ {
19
+ :ok,
20
+ %{
21
+ token: %{"access_token" => "access_token"},
22
+ user: %{
23
+ "sub" => email,
24
+ "given_name" => String.split(email, "@") |> Enum.at(0),
25
+ "email" => email,
26
+ "family_name" => String.split(email, "@") |> Enum.at(1)
27
+ }
28
+ }
29
+ }
30
+ end
31
+ def callback(_config, _params) do
32
+ {:error, "Invalid params"}
33
+ end
34
+ end
changed lib/potionx/auth/auth_identity.ex
 
@@ -1,20 +1,20 @@
1
- defmodule Potionx.Auth.Identity do
2
- import Ecto.Changeset
3
-
4
- def changeset(struct, params) do
5
- struct
6
- |> cast(
7
- params, [
8
- :provider,
9
- :uid,
10
- :user_id,
11
- ]
12
- )
13
- |> assoc_constraint(:user)
14
- |> validate_required([
15
- :provider,
16
- :uid,
17
- :user_id
18
- ])
19
- end
20
- end
1
+ defmodule Potionx.Auth.Identity do
2
+ import Ecto.Changeset
3
+
4
+ def changeset(struct, params) do
5
+ struct
6
+ |> cast(
7
+ params, [
8
+ :provider,
9
+ :uid,
10
+ :user_id,
11
+ ]
12
+ )
13
+ |> assoc_constraint(:user)
14
+ |> validate_required([
15
+ :provider,
16
+ :uid,
17
+ :user_id
18
+ ])
19
+ end
20
+ end
changed lib/potionx/auth/auth_identity_service.ex
 
@@ -1,67 +1,67 @@
1
- defmodule Potionx.Auth.IdentityService do
2
- alias Potionx.Context.Service
3
-
4
- @callback count(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
5
- @callback create(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
6
- @callback delete(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
7
- @callback one(Potionx.Context.Service.t()) :: struct()
8
-
9
- defmacro __using__(opts) do
10
- if !Keyword.get(opts, :repo) do
11
- raise "Potionx.Auth.SessionService requires a repo"
12
- end
13
- if !Keyword.get(opts, :identity_schema) do
14
- raise "Potionx.Auth.SessionService requires a session schema"
15
- end
16
-
17
- quote do
18
- @behaviour Potionx.Auth.IdentityService
19
- @identity_schema unquote(opts[:identity_schema])
20
- @repo unquote(opts[:repo])
21
- import Ecto.Query
22
-
23
- def count(%Service{} = ctx) do
24
- from(item in query(ctx))
25
- |> select([i], count(i.id))
26
- |> exclude(:order_by)
27
- |> @repo.one!
28
- end
29
-
30
- def create(%Service{changes: changes} = srv) do
31
- struct(@identity_schema, %{})
32
- |> @identity_schema.changeset(changes)
33
- |> @repo.insert
34
- end
35
-
36
- def delete(%Service{} = ctx) do
37
- query(ctx)
38
- |> @repo.one
39
- |> case do
40
- nil -> {:error, "not_found"}
41
- entry ->
42
- entry
43
- |> @repo.delete
44
- end
45
- end
46
-
47
- def one(%Service{} = ctx) do
48
- query(ctx)
49
- |> @repo.one
50
- end
51
-
52
- def query(%Service{} = ctx) do
53
- @identity_schema
54
- |> where(
55
- ^(
56
- ctx.filters
57
- |> Map.to_list
58
- )
59
- )
60
- |> order_by([desc: :id])
61
- end
62
- def query(q, _args), do: q
63
-
64
- defoverridable(Potionx.Auth.IdentityService)
65
- end
66
- end
67
- end
1
+ defmodule Potionx.Auth.IdentityService do
2
+ alias Potionx.Context.Service
3
+
4
+ @callback count(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
5
+ @callback create(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
6
+ @callback delete(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
7
+ @callback one(Potionx.Context.Service.t()) :: struct()
8
+
9
+ defmacro __using__(opts) do
10
+ if !Keyword.get(opts, :repo) do
11
+ raise "Potionx.Auth.SessionService requires a repo"
12
+ end
13
+ if !Keyword.get(opts, :identity_schema) do
14
+ raise "Potionx.Auth.SessionService requires a session schema"
15
+ end
16
+
17
+ quote do
18
+ @behaviour Potionx.Auth.IdentityService
19
+ @identity_schema unquote(opts[:identity_schema])
20
+ @repo unquote(opts[:repo])
21
+ import Ecto.Query
22
+
23
+ def count(%Service{} = ctx) do
24
+ from(item in query(ctx))
25
+ |> select([i], count(i.id))
26
+ |> exclude(:order_by)
27
+ |> @repo.one!
28
+ end
29
+
30
+ def create(%Service{changes: changes} = srv) do
31
+ struct(@identity_schema, %{})
32
+ |> @identity_schema.changeset(changes)
33
+ |> @repo.insert
34
+ end
35
+
36
+ def delete(%Service{} = ctx) do
37
+ query(ctx)
38
+ |> @repo.one
39
+ |> case do
40
+ nil -> {:error, "not_found"}
41
+ entry ->
42
+ entry
43
+ |> @repo.delete
44
+ end
45
+ end
46
+
47
+ def one(%Service{} = ctx) do
48
+ query(ctx)
49
+ |> @repo.one
50
+ end
51
+
52
+ def query(%Service{} = ctx) do
53
+ @identity_schema
54
+ |> where(
55
+ ^(
56
+ ctx.filters
57
+ |> Map.to_list
58
+ )
59
+ )
60
+ |> order_by([desc: :id])
61
+ end
62
+ def query(q, _args), do: q
63
+
64
+ defoverridable(Potionx.Auth.IdentityService)
65
+ end
66
+ end
67
+ end
changed lib/potionx/auth/auth_plug.ex
 
@@ -1,100 +1,100 @@
1
- defmodule Potionx.Plug.Auth do
2
- @behaviour Plug
3
- alias Potionx.Context.Service
4
-
5
- def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
- conn
7
- |> Plug.Conn.fetch_cookies(
8
- signed: Enum.map(Map.values(Potionx.Auth.token_config()), &(&1.name))
9
- )
10
- |> maybe_renew(opts)
11
- |> fetch_session_from_conn(opts)
12
- end
13
-
14
- def fetch_session_from_conn(%{halted: true} = conn, _), do: conn
15
- def fetch_session_from_conn(%{assigns: %{context: ctx}} = conn, %{session_service: session_service}) do
16
- cookie_name = Potionx.Auth.token_config.access_token.name
17
- conn
18
- |> case do
19
- %{cookies: %{^cookie_name => token}} ->
20
- session_service.one_from_cache(
21
- %Service{
22
- filters: Map.put(%{}, :uuid_access, token)
23
- }
24
- )
25
- _ ->
26
- {:error, "no_cookie"}
27
- end
28
- |> case do
29
- %{id: _} = session ->
30
- conn
31
- |> Plug.Conn.assign(
32
- :context, %{
33
- ctx |
34
- roles: (Map.get(session, :user) || %{roles: nil}).roles,
35
- session: session,
36
- user: session.user
37
- }
38
- )
39
- _ ->
40
- conn
41
- end
42
- end
43
-
44
- def init(opts) do
45
- if !Keyword.get(opts, :session_service) do
46
- raise "Potionx.Auth.Plug requires a session service"
47
- end
48
- Keyword.merge(
49
- [
50
- session_service: false
51
- ],
52
- opts
53
- )
54
- |> Enum.into(%{})
55
- end
56
-
57
-
58
- def maybe_renew(conn, opts) do
59
- cookie_name_access = Potionx.Auth.token_config.access_token.name
60
- cookie_name_renewal = Potionx.Auth.token_config.renewal_token.name
61
-
62
- conn
63
- |> case do
64
- %{cookies: %{^cookie_name_access => _}} ->
65
- conn
66
- %{cookies: %{^cookie_name_renewal => token}} ->
67
- renew_or_halt(conn, token, opts)
68
- _ ->
69
- conn
70
- end
71
- end
72
- def renew_or_halt(conn, token, %{session_service: session_service}) do
73
- session_service.patch(
74
- %Service{
75
- changes: %{
76
- ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
77
- uuid_access: Ecto.UUID.generate(),
78
- uuid_renewal: Ecto.UUID.generate(),
79
- ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
80
- },
81
- filters: %{
82
- uuid_renewal: token
83
- }
84
- }
85
- )
86
- |> case do
87
- {:ok, %{session_patch: session}} ->
88
- Potionx.Auth.handle_user_session_cookies(
89
- session,
90
- conn
91
- )
92
- {:error, :session_old, "missing_session", _} ->
93
- conn
94
- |> Potionx.Auth.delete_cookie(%{
95
- name: Potionx.Auth.token_config().renewal_token.name,
96
- ttl_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
97
- })
98
- end
99
- end
100
- end
1
+ defmodule Potionx.Plug.Auth do
2
+ @behaviour Plug
3
+ alias Potionx.Context.Service
4
+
5
+ def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
+ conn
7
+ |> Plug.Conn.fetch_cookies(
8
+ signed: Enum.map(Map.values(Potionx.Auth.token_config()), &(&1.name))
9
+ )
10
+ |> maybe_renew(opts)
11
+ |> fetch_session_from_conn(opts)
12
+ end
13
+
14
+ def fetch_session_from_conn(%{halted: true} = conn, _), do: conn
15
+ def fetch_session_from_conn(%{assigns: %{context: ctx}} = conn, %{session_service: session_service}) do
16
+ cookie_name = Potionx.Auth.token_config.access_token.name
17
+ conn
18
+ |> case do
19
+ %{cookies: %{^cookie_name => token}} ->
20
+ session_service.one_from_cache(
21
+ %Service{
22
+ filters: Map.put(%{}, :uuid_access, token)
23
+ }
24
+ )
25
+ _ ->
26
+ {:error, "no_cookie"}
27
+ end
28
+ |> case do
29
+ %{id: _} = session ->
30
+ conn
31
+ |> Plug.Conn.assign(
32
+ :context, %{
33
+ ctx |
34
+ roles: (Map.get(session, :user) || %{roles: nil}).roles,
35
+ session: session,
36
+ user: session.user
37
+ }
38
+ )
39
+ _ ->
40
+ conn
41
+ end
42
+ end
43
+
44
+ def init(opts) do
45
+ if !Keyword.get(opts, :session_service) do
46
+ raise "Potionx.Auth.Plug requires a session service"
47
+ end
48
+ Keyword.merge(
49
+ [
50
+ session_service: false
51
+ ],
52
+ opts
53
+ )
54
+ |> Enum.into(%{})
55
+ end
56
+
57
+
58
+ def maybe_renew(conn, opts) do
59
+ cookie_name_access = Potionx.Auth.token_config.access_token.name
60
+ cookie_name_renewal = Potionx.Auth.token_config.renewal_token.name
61
+
62
+ conn
63
+ |> case do
64
+ %{cookies: %{^cookie_name_access => _}} ->
65
+ conn
66
+ %{cookies: %{^cookie_name_renewal => token}} ->
67
+ renew_or_halt(conn, token, opts)
68
+ _ ->
69
+ conn
70
+ end
71
+ end
72
+ def renew_or_halt(conn, token, %{session_service: session_service}) do
73
+ session_service.patch(
74
+ %Service{
75
+ changes: %{
76
+ ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
77
+ uuid_access: Ecto.UUID.generate(),
78
+ uuid_renewal: Ecto.UUID.generate(),
79
+ ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
80
+ },
81
+ filters: %{
82
+ uuid_renewal: token
83
+ }
84
+ }
85
+ )
86
+ |> case do
87
+ {:ok, %{session_patch: session}} ->
88
+ Potionx.Auth.handle_user_session_cookies(
89
+ session,
90
+ conn
91
+ )
92
+ {:error, :session_old, "missing_session", _} ->
93
+ conn
94
+ |> Potionx.Auth.delete_cookie(%{
95
+ name: Potionx.Auth.token_config().renewal_token.name,
96
+ ttl_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
97
+ })
98
+ end
99
+ end
100
+ end
changed lib/potionx/auth/auth_resolvers.ex
 
@@ -1,401 +1,401 @@
1
- defmodule Potionx.Auth.Resolvers do
2
- alias Potionx.Context.Service
3
-
4
- def auth_config() do
5
- Application.get_env(:potionx, :auth)
6
- end
7
-
8
- @spec before_send(Plug.Conn.t(), Absinthe.Blueprint.t()) :: any
9
- def before_send(
10
- conn,
11
- %Absinthe.Blueprint{
12
- execution: %{
13
- context: %{
14
- assigns: %{
15
- tokens_to_cookies: true
16
- },
17
- session: session
18
- }
19
- }
20
- }
21
- ) when not is_nil(session) do
22
- Potionx.Auth.handle_user_session_cookies(session, conn)
23
- end
24
- def before_send(
25
- conn,
26
- %Absinthe.Blueprint{
27
- execution: %{
28
- context: %{
29
- assigns: %{
30
- sign_out: true
31
- },
32
- session: session
33
- }
34
- }
35
- }
36
- ) when not is_nil(session) do
37
- conn
38
- |> Potionx.Auth.delete_cookie(%{
39
- name: Potionx.Auth.token_config().access_token.name,
40
- ttl_seconds: session.ttl_access_seconds
41
- })
42
- |> Potionx.Auth.delete_cookie(%{
43
- name: Potionx.Auth.token_config().renewal_token.name,
44
- ttl_seconds: session.ttl_renewal_seconds
45
- })
46
- |> Potionx.Auth.delete_cookie(%{
47
- name: Potionx.Auth.token_config().frontend.name,
48
- ttl_seconds: session.ttl_access_seconds
49
- })
50
- end
51
- def before_send(conn, _) do
52
- conn
53
- end
54
-
55
- def call(conn, opts) do
56
- callback(conn, opts)
57
- end
58
-
59
- def callback(%{assigns: %{context: %Service{session: %{id: _} = session} = ctx}} = conn, opts) do
60
- after_login_path = Keyword.get(opts, :after_login_path) || "/"
61
- redirect_path = Keyword.get(opts, :redirect_path) || "/login"
62
- scheme = Keyword.get(opts, :scheme) || "https"
63
- session_service = Keyword.fetch!(opts, :session_service)
64
- redirect_url = get_redirect_url(conn, Map.get(session.data, "redirect_url") || after_login_path, scheme)
65
-
66
- conn
67
- |> verify_providers_match(session)
68
- |> process_callback(conn, opts)
69
- |> parse_callback_response(session.sign_in_provider)
70
- |> create_user_session(session, session_service, %{ctx | changes: Map.put(ctx.changes, :redirect_url, redirect_url)})
71
- |> Potionx.Auth.handle_user_session_cookies(conn)
72
- |> case do
73
- %Plug.Conn{} = conn ->
74
- conn
75
- |> Plug.Conn.put_resp_content_type("text/html")
76
- |> Plug.Conn.send_resp(
77
- 200,
78
- """
79
- <html>
80
- <head><meta http-equiv="refresh" content="0;URL='#{redirect_url}'"/></head>
81
- <body></body>
82
- </html>
83
- """
84
- )
85
-
86
- {:error, _, msg, _} ->
87
- {:error, msg}
88
- err -> err
89
- end
90
- |> case do
91
- {:error, msg} ->
92
- url = Enum.join([redirect_path, "?msg=", msg], "")
93
- conn
94
- |> Plug.Conn.put_resp_content_type("text/html")
95
- |> Plug.Conn.assign(:potionx_auth_error, msg)
96
- |> Plug.Conn.send_resp(
97
- 401,
98
- """
99
- <html>
100
- <head><meta http-equiv="refresh" content="0;URL='#{url}'"/></head>
101
- <body></body>
102
- </html>
103
- """
104
- )
105
- res -> res
106
- end
107
- end
108
- def callback(conn, opts) do
109
- redirect_path = Keyword.get(opts, :redirect_path, "/login")
110
- url = Enum.join([redirect_path, "?msg=missing_session"], "")
111
- conn
112
- |> Plug.Conn.assign(:potionx_auth_error, "missing_session")
113
- |> Plug.Conn.send_resp(
114
- 401,
115
- """
116
- <html>
117
- <head><meta http-equiv="refresh" content="1;URL='#{url}'"/></head>
118
- <body></body>
119
- </html>
120
- """
121
- )
122
- end
123
-
124
- def create_user_session(
125
- {:ok, user_identity_params, user_params},
126
- previous_session,
127
- session_service,
128
- %Service{changes: %{redirect_url: redirect_url}, ip: ip}
129
- ) do
130
- session_service.create(
131
- %Service{
132
- changes: %{
133
- identity: %{user_identity_params | "uid" => to_string(user_identity_params["uid"])},
134
- session: %{
135
- ip: ip,
136
- sign_in_provider: previous_session.sign_in_provider,
137
- ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
138
- uuid_access: Ecto.UUID.generate(),
139
- uuid_renewal: Ecto.UUID.generate(),
140
- ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
141
- },
142
- user: %{
143
- email: user_params["email"],
144
- email_verified: user_params["email_verified"],
145
- file_url: user_params["picture"],
146
- name_first: user_params["given_name"],
147
- name_last: user_params["family_name"],
148
- locale: user_params["locale"],
149
- name: user_params["name"],
150
- redirect_url: redirect_url
151
- }
152
- }
153
- },
154
- previous_session
155
- )
156
- |> case do
157
- {:ok, %{session: session}} -> session
158
- err -> err
159
- end
160
- end
161
- def create_user_session(err, _, _, _), do: err
162
-
163
- @doc """
164
- Prepare redirect_url, ensure redirect can only lead back to log in domain
165
- """
166
- def get_redirect_url(conn, redirect_url, scheme) do
167
- redirect_uri = URI.parse(redirect_url)
168
- URI.parse(Plug.Conn.request_url(conn))
169
- |> Map.put(:query, redirect_uri.query)
170
- |> Map.put(:path, redirect_uri.path)
171
- |> Map.put(:port, scheme === "https" && 443 || conn.port)
172
- |> Map.put(:scheme, scheme)
173
- |> to_string
174
- end
175
-
176
- defp handle_user_identity_params({user_identity_params, user_params}, other_params, provider) do
177
- user_identity_params = Map.put(user_identity_params, "provider", provider)
178
- other_params = for {key, value} <- other_params, into: %{}, do: {Atom.to_string(key), value}
179
-
180
- user_identity_params =
181
- user_identity_params
182
- |> Map.put("provider", provider)
183
- |> Map.merge(other_params)
184
-
185
- {:ok, user_identity_params, user_params}
186
- end
187
-
188
- def init(opts), do: opts
189
-
190
- def middleware_renew(%{context: ctx, value: value} = res, _) when is_map(value) do
191
- %{
192
- res |
193
- context: %{
194
- ctx |
195
- assigns: %{tokens_to_cookies: true},
196
- session: Map.get(value, :session)
197
- },
198
- value: Map.delete(value, :session)
199
- }
200
- end
201
- def middleware_renew(res, _), do: res
202
-
203
- def middleware_sign_in(%{context: ctx, value: value} = res, _) when is_map(value) do
204
- %{
205
- res |
206
- context: %{
207
- ctx |
208
- assigns: %{tokens_to_cookies: true},
209
- session: Map.get(value, :session)
210
- },
211
- value: Map.delete(value, :session)
212
- }
213
- end
214
- def middleware_sign_in(res, _), do: res
215
-
216
- def middleware_sign_out(%{context: ctx, value: value} = res, _) when is_map(value) do
217
- %{
218
- res |
219
- context: %{
220
- ctx |
221
- assigns: %{sign_out: true}
222
- }
223
- }
224
- end
225
- def middleware_sign_out(res, _), do: res
226
-
227
- defp normalize_username(%{"preferred_username" => username} = params) do
228
- params
229
- |> Map.delete("preferred_username")
230
- |> Map.put("username", username)
231
- end
232
- defp normalize_username(params), do: params
233
-
234
- defp parse_callback_response({:ok, %{user: user} = response}, provider) do
235
- other_params =
236
- response
237
- |> Map.delete(:user)
238
- |> Map.put(:userinfo, user)
239
-
240
- user
241
- |> normalize_username()
242
- |> split_user_identity_params()
243
- |> handle_user_identity_params(other_params, provider)
244
- end
245
- defp parse_callback_response({:error, error}, _provider), do: {:error, error}
246
-
247
- def process_callback(session, conn), do: process_callback(session, conn, [])
248
- def process_callback(%{data: data, sign_in_provider: provider}, conn, opts) do
249
- strategies = Keyword.get(opts, :strategies) || auth_config()[:strategies]
250
- strategy_config = Keyword.fetch!(strategies, String.to_existing_atom(provider))
251
- redirect_uri =
252
- URI.parse(Plug.Conn.request_url(conn))
253
- |> Map.replace!(:fragment, nil)
254
- |> Map.replace!(:query, nil)
255
- |> case do
256
- %{host: "localhost"} = url -> url
257
- url -> %{url | port: 443, scheme: "https"}
258
- end
259
-
260
- Keyword.fetch!(strategy_config, :strategy).callback(
261
- Keyword.put(
262
- strategy_config,
263
- :session_params,
264
- data
265
- )
266
- |> Keyword.put(
267
- :http_adapter,
268
- Assent.HTTPAdapter.Mint
269
- )
270
- |> Keyword.put(
271
- :redirect_uri,
272
- redirect_uri
273
- ),
274
- conn.params
275
- )
276
- end
277
- def process_callback(err, _conn, _opts) do
278
- err
279
- end
280
-
281
- def resolve_renew(opts) do
282
- session_service = Keyword.get(opts, :session_service)
283
- if !session_service do
284
- raise "Potionx.Auth.Resolvers requires a session_service"
285
- end
286
-
287
- fn
288
- _parent, _, %{context: %Service{session: %{id: id}}} ->
289
- session_service.patch(
290
- %Service{
291
- changes: %{
292
- ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
293
- uuid_access: Ecto.UUID.generate(),
294
- uuid_renewal: Ecto.UUID.generate(),
295
- ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
296
- },
297
- filters: %{
298
- id: id
299
- }
300
- }
301
- )
302
- |> case do
303
- {:ok, %{session_patch: session}} ->
304
- {:ok, %{session: session}}
305
- err -> err
306
- end
307
- _parent, _, _ ->
308
- {:ok, %{error: "missing_session"}}
309
- end
310
- end
311
-
312
- def resolve_sign_in(opts \\ []) do
313
- session_service = Keyword.get(opts, :session_service)
314
- if !session_service do
315
- raise "Potionx.Auth.Resolvers requires a session_service"
316
- end
317
-
318
- fn _parent, %{provider: provider} = args, %{context: %Service{request_url: url} = ctx} ->
319
- strategies = Keyword.get(opts, :strategies) || auth_config()[:strategies]
320
-
321
- redirect_uri =
322
- URI.parse(url)
323
- |> Map.replace!(:path, "/api/v1/auth/#{provider}/callback")
324
- |> Map.replace!(:fragment, nil)
325
- |> Map.replace!(:query, nil)
326
- |> case do
327
- %{host: "localhost"} = url -> url
328
- url -> %{url | port: 443, scheme: "https"}
329
- end
330
-
331
- strategies
332
- |> Keyword.fetch(String.to_existing_atom(provider))
333
- |> case do
334
- {:ok, config} ->
335
- strategy = Keyword.fetch!(config, :strategy)
336
- config
337
- |> Keyword.delete(:strategy)
338
- |> Keyword.put(:redirect_uri, redirect_uri)
339
- |> Keyword.put(
340
- :http_adapter,
341
- Assent.HTTPAdapter.Mint
342
- )
343
- |> strategy.authorize_url()
344
- |> case do
345
- {:ok, %{session_params: params, url: url}} ->
346
- session_service.create(
347
- %Service{
348
- changes: %{
349
- data: Map.merge(args, params),
350
- ip: ctx.ip,
351
- sign_in_provider: provider,
352
- ttl_access_seconds: Potionx.Auth.token_config().sign_in_token.ttl_seconds,
353
- uuid_access: Ecto.UUID.generate()
354
- }
355
- },
356
- nil
357
- )
358
- |> case do
359
- {:ok, %{session: session}} ->
360
- {:ok, %{session: session, url: url}}
361
- err -> err
362
- end
363
- err -> err
364
- end
365
- _ ->
366
- {:ok, %{error: "Missing Provider"}}
367
- end
368
- end
369
- end
370
- def resolve_sign_out(opts \\ []) do
371
- session_service = Keyword.get(opts, :session_service)
372
- if !session_service do
373
- raise "Potionx.Auth.Assent resolve function requires a session_service"
374
- end
375
-
376
- fn
377
- _parent, _, %{context: %{session: nil}} ->
378
- {:ok, %{error: "not_signed_in"}}
379
- _parent, _, %{context: %{session: session}} ->
380
- session_service.delete(%Service{filters: %{id: session.id}})
381
- |> case do
382
- {:ok, _} -> {:ok, %{}}
383
- err -> err
384
- end
385
- end
386
- end
387
-
388
- defp split_user_identity_params(%{"sub" => uid} = params) do
389
- user_params = Map.delete(params, "sub")
390
-
391
- {%{"uid" => uid}, user_params}
392
- end
393
-
394
- def verify_providers_match(%{path_params: %{"provider" => provider2}}, %{sign_in_provider: provider} = session) do
395
- if provider === provider2 do
396
- session
397
- else
398
- {:error, "provider_mismatch"}
399
- end
400
- end
401
- end
1
+ defmodule Potionx.Auth.Resolvers do
2
+ alias Potionx.Context.Service
3
+
4
+ def auth_config() do
5
+ Application.get_env(:potionx, :auth)
6
+ end
7
+
8
+ @spec before_send(Plug.Conn.t(), Absinthe.Blueprint.t()) :: any
9
+ def before_send(
10
+ conn,
11
+ %Absinthe.Blueprint{
12
+ execution: %{
13
+ context: %{
14
+ assigns: %{
15
+ tokens_to_cookies: true
16
+ },
17
+ session: session
18
+ }
19
+ }
20
+ }
21
+ ) when not is_nil(session) do
22
+ Potionx.Auth.handle_user_session_cookies(session, conn)
23
+ end
24
+ def before_send(
25
+ conn,
26
+ %Absinthe.Blueprint{
27
+ execution: %{
28
+ context: %{
29
+ assigns: %{
30
+ sign_out: true
31
+ },
32
+ session: session
33
+ }
34
+ }
35
+ }
36
+ ) when not is_nil(session) do
37
+ conn
38
+ |> Potionx.Auth.delete_cookie(%{
39
+ name: Potionx.Auth.token_config().access_token.name,
40
+ ttl_seconds: session.ttl_access_seconds
41
+ })
42
+ |> Potionx.Auth.delete_cookie(%{
43
+ name: Potionx.Auth.token_config().renewal_token.name,
44
+ ttl_seconds: session.ttl_renewal_seconds
45
+ })
46
+ |> Potionx.Auth.delete_cookie(%{
47
+ name: Potionx.Auth.token_config().frontend.name,
48
+ ttl_seconds: session.ttl_access_seconds
49
+ })
50
+ end
51
+ def before_send(conn, _) do
52
+ conn
53
+ end
54
+
55
+ def call(conn, opts) do
56
+ callback(conn, opts)
57
+ end
58
+
59
+ def callback(%{assigns: %{context: %Service{session: %{id: _} = session} = ctx}} = conn, opts) do
60
+ after_login_path = Keyword.get(opts, :after_login_path) || "/"
61
+ redirect_path = Keyword.get(opts, :redirect_path) || "/login"
62
+ scheme = Keyword.get(opts, :scheme) || "https"
63
+ session_service = Keyword.fetch!(opts, :session_service)
64
+ redirect_url = get_redirect_url(conn, Map.get(session.data, "redirect_url") || after_login_path, scheme)
65
+
66
+ conn
67
+ |> verify_providers_match(session)
68
+ |> process_callback(conn, opts)
69
+ |> parse_callback_response(session.sign_in_provider)
70
+ |> create_user_session(session, session_service, %{ctx | changes: Map.put(ctx.changes, :redirect_url, redirect_url)})
71
+ |> Potionx.Auth.handle_user_session_cookies(conn)
72
+ |> case do
73
+ %Plug.Conn{} = conn ->
74
+ conn
75
+ |> Plug.Conn.put_resp_content_type("text/html")
76
+ |> Plug.Conn.send_resp(
77
+ 200,
78
+ """
79
+ <html>
80
+ <head><meta http-equiv="refresh" content="0;URL='#{redirect_url}'"/></head>
81
+ <body></body>
82
+ </html>
83
+ """
84
+ )
85
+
86
+ {:error, _, msg, _} ->
87
+ {:error, msg}
88
+ err -> err
89
+ end
90
+ |> case do
91
+ {:error, msg} ->
92
+ url = Enum.join([redirect_path, "?msg=", msg], "")
93
+ conn
94
+ |> Plug.Conn.put_resp_content_type("text/html")
95
+ |> Plug.Conn.assign(:potionx_auth_error, msg)
96
+ |> Plug.Conn.send_resp(
97
+ 401,
98
+ """
99
+ <html>
100
+ <head><meta http-equiv="refresh" content="0;URL='#{url}'"/></head>
101
+ <body></body>
102
+ </html>
103
+ """
104
+ )
105
+ res -> res
106
+ end
107
+ end
108
+ def callback(conn, opts) do
109
+ redirect_path = Keyword.get(opts, :redirect_path, "/login")
110
+ url = Enum.join([redirect_path, "?msg=missing_session"], "")
111
+ conn
112
+ |> Plug.Conn.assign(:potionx_auth_error, "missing_session")
113
+ |> Plug.Conn.send_resp(
114
+ 401,
115
+ """
116
+ <html>
117
+ <head><meta http-equiv="refresh" content="1;URL='#{url}'"/></head>
118
+ <body></body>
119
+ </html>
120
+ """
121
+ )
122
+ end
123
+
124
+ def create_user_session(
125
+ {:ok, user_identity_params, user_params},
126
+ previous_session,
127
+ session_service,
128
+ %Service{changes: %{redirect_url: redirect_url}, ip: ip}
129
+ ) do
130
+ session_service.create(
131
+ %Service{
132
+ changes: %{
133
+ identity: %{user_identity_params | "uid" => to_string(user_identity_params["uid"])},
134
+ session: %{
135
+ ip: ip,
136
+ sign_in_provider: previous_session.sign_in_provider,
137
+ ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
138
+ uuid_access: Ecto.UUID.generate(),
139
+ uuid_renewal: Ecto.UUID.generate(),
140
+ ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
141
+ },
142
+ user: %{
143
+ email: user_params["email"],
144
+ email_verified: user_params["email_verified"],
145
+ file_url: user_params["picture"],
146
+ name_first: user_params["given_name"],
147
+ name_last: user_params["family_name"],
148
+ locale: user_params["locale"],
149
+ name: user_params["name"],
150
+ redirect_url: redirect_url
151
+ }
152
+ }
153
+ },
154
+ previous_session
155
+ )
156
+ |> case do
157
+ {:ok, %{session: session}} -> session
158
+ err -> err
159
+ end
160
+ end
161
+ def create_user_session(err, _, _, _), do: err
162
+
163
+ @doc """
164
+ Prepare redirect_url, ensure redirect can only lead back to log in domain
165
+ """
166
+ def get_redirect_url(conn, redirect_url, scheme) do
167
+ redirect_uri = URI.parse(redirect_url)
168
+ URI.parse(Plug.Conn.request_url(conn))
169
+ |> Map.put(:query, redirect_uri.query)
170
+ |> Map.put(:path, redirect_uri.path)
171
+ |> Map.put(:port, scheme === "https" && 443 || conn.port)
172
+ |> Map.put(:scheme, scheme)
173
+ |> to_string
174
+ end
175
+
176
+ defp handle_user_identity_params({user_identity_params, user_params}, other_params, provider) do
177
+ user_identity_params = Map.put(user_identity_params, "provider", provider)
178
+ other_params = for {key, value} <- other_params, into: %{}, do: {Atom.to_string(key), value}
179
+
180
+ user_identity_params =
181
+ user_identity_params
182
+ |> Map.put("provider", provider)
183
+ |> Map.merge(other_params)
184
+
185
+ {:ok, user_identity_params, user_params}
186
+ end
187
+
188
+ def init(opts), do: opts
189
+
190
+ def middleware_renew(%{context: ctx, value: value} = res, _) when is_map(value) do
191
+ %{
192
+ res |
193
+ context: %{
194
+ ctx |
195
+ assigns: %{tokens_to_cookies: true},
196
+ session: Map.get(value, :session)
197
+ },
198
+ value: Map.delete(value, :session)
199
+ }
200
+ end
201
+ def middleware_renew(res, _), do: res
202
+
203
+ def middleware_sign_in(%{context: ctx, value: value} = res, _) when is_map(value) do
204
+ %{
205
+ res |
206
+ context: %{
207
+ ctx |
208
+ assigns: %{tokens_to_cookies: true},
209
+ session: Map.get(value, :session)
210
+ },
211
+ value: Map.delete(value, :session)
212
+ }
213
+ end
214
+ def middleware_sign_in(res, _), do: res
215
+
216
+ def middleware_sign_out(%{context: ctx, value: value} = res, _) when is_map(value) do
217
+ %{
218
+ res |
219
+ context: %{
220
+ ctx |
221
+ assigns: %{sign_out: true}
222
+ }
223
+ }
224
+ end
225
+ def middleware_sign_out(res, _), do: res
226
+
227
+ defp normalize_username(%{"preferred_username" => username} = params) do
228
+ params
229
+ |> Map.delete("preferred_username")
230
+ |> Map.put("username", username)
231
+ end
232
+ defp normalize_username(params), do: params
233
+
234
+ defp parse_callback_response({:ok, %{user: user} = response}, provider) do
235
+ other_params =
236
+ response
237
+ |> Map.delete(:user)
238
+ |> Map.put(:userinfo, user)
239
+
240
+ user
241
+ |> normalize_username()
242
+ |> split_user_identity_params()
243
+ |> handle_user_identity_params(other_params, provider)
244
+ end
245
+ defp parse_callback_response({:error, error}, _provider), do: {:error, error}
246
+
247
+ def process_callback(session, conn), do: process_callback(session, conn, [])
248
+ def process_callback(%{data: data, sign_in_provider: provider}, conn, opts) do
249
+ strategies = Keyword.get(opts, :strategies) || auth_config()[:strategies]
250
+ strategy_config = Keyword.fetch!(strategies, String.to_existing_atom(provider))
251
+ redirect_uri =
252
+ URI.parse(Plug.Conn.request_url(conn))
253
+ |> Map.replace!(:fragment, nil)
254
+ |> Map.replace!(:query, nil)
255
+ |> case do
256
+ %{host: "localhost"} = url -> url
257
+ url -> %{url | port: 443, scheme: "https"}
258
+ end
259
+
260
+ Keyword.fetch!(strategy_config, :strategy).callback(
261
+ Keyword.put(
262
+ strategy_config,
263
+ :session_params,
264
+ data
265
+ )
266
+ |> Keyword.put(
267
+ :http_adapter,
268
+ Assent.HTTPAdapter.Mint
269
+ )
270
+ |> Keyword.put(
271
+ :redirect_uri,
272
+ redirect_uri
273
+ ),
274
+ conn.params
275
+ )
276
+ end
277
+ def process_callback(err, _conn, _opts) do
278
+ err
279
+ end
280
+
281
+ def resolve_renew(opts) do
282
+ session_service = Keyword.get(opts, :session_service)
283
+ if !session_service do
284
+ raise "Potionx.Auth.Resolvers requires a session_service"
285
+ end
286
+
287
+ fn
288
+ _parent, _, %{context: %Service{session: %{id: id}}} ->
289
+ session_service.patch(
290
+ %Service{
291
+ changes: %{
292
+ ttl_access_seconds: Potionx.Auth.token_config().access_token.ttl_seconds,
293
+ uuid_access: Ecto.UUID.generate(),
294
+ uuid_renewal: Ecto.UUID.generate(),
295
+ ttl_renewal_seconds: Potionx.Auth.token_config().renewal_token.ttl_seconds
296
+ },
297
+ filters: %{
298
+ id: id
299
+ }
300
+ }
301
+ )
302
+ |> case do
303
+ {:ok, %{session_patch: session}} ->
304
+ {:ok, %{session: session}}
305
+ err -> err
306
+ end
307
+ _parent, _, _ ->
308
+ {:ok, %{error: "missing_session"}}
309
+ end
310
+ end
311
+
312
+ def resolve_sign_in(opts \\ []) do
313
+ session_service = Keyword.get(opts, :session_service)
314
+ if !session_service do
315
+ raise "Potionx.Auth.Resolvers requires a session_service"
316
+ end
317
+
318
+ fn _parent, %{provider: provider} = args, %{context: %Service{request_url: url} = ctx} ->
319
+ strategies = Keyword.get(opts, :strategies) || auth_config()[:strategies]
320
+
321
+ redirect_uri =
322
+ URI.parse(url)
323
+ |> Map.replace!(:path, "/api/v1/auth/#{provider}/callback")
324
+ |> Map.replace!(:fragment, nil)
325
+ |> Map.replace!(:query, nil)
326
+ |> case do
327
+ %{host: "localhost"} = url -> url
328
+ url -> %{url | port: 443, scheme: "https"}
329
+ end
330
+
331
+ strategies
332
+ |> Keyword.fetch(String.to_existing_atom(provider))
333
+ |> case do
334
+ {:ok, config} ->
335
+ strategy = Keyword.fetch!(config, :strategy)
336
+ config
337
+ |> Keyword.delete(:strategy)
338
+ |> Keyword.put(:redirect_uri, redirect_uri)
339
+ |> Keyword.put(
340
+ :http_adapter,
341
+ Assent.HTTPAdapter.Mint
342
+ )
343
+ |> strategy.authorize_url()
344
+ |> case do
345
+ {:ok, %{session_params: params, url: url}} ->
346
+ session_service.create(
347
+ %Service{
348
+ changes: %{
349
+ data: Map.merge(args, params),
350
+ ip: ctx.ip,
351
+ sign_in_provider: provider,
352
+ ttl_access_seconds: Potionx.Auth.token_config().sign_in_token.ttl_seconds,
353
+ uuid_access: Ecto.UUID.generate()
354
+ }
355
+ },
356
+ nil
357
+ )
358
+ |> case do
359
+ {:ok, %{session: session}} ->
360
+ {:ok, %{session: session, url: url}}
361
+ err -> err
362
+ end
363
+ err -> err
364
+ end
365
+ _ ->
366
+ {:ok, %{error: "Missing Provider"}}
367
+ end
368
+ end
369
+ end
370
+ def resolve_sign_out(opts \\ []) do
371
+ session_service = Keyword.get(opts, :session_service)
372
+ if !session_service do
373
+ raise "Potionx.Auth.Assent resolve function requires a session_service"
374
+ end
375
+
376
+ fn
377
+ _parent, _, %{context: %{session: nil}} ->
378
+ {:ok, %{error: "not_signed_in"}}
379
+ _parent, _, %{context: %{session: session}} ->
380
+ session_service.delete(%Service{filters: %{id: session.id}})
381
+ |> case do
382
+ {:ok, _} -> {:ok, %{}}
383
+ err -> err
384
+ end
385
+ end
386
+ end
387
+
388
+ defp split_user_identity_params(%{"sub" => uid} = params) do
389
+ user_params = Map.delete(params, "sub")
390
+
391
+ {%{"uid" => uid}, user_params}
392
+ end
393
+
394
+ def verify_providers_match(%{path_params: %{"provider" => provider2}}, %{sign_in_provider: provider} = session) do
395
+ if provider === provider2 do
396
+ session
397
+ else
398
+ {:error, "provider_mismatch"}
399
+ end
400
+ end
401
+ end
changed lib/potionx/auth/auth_session.ex
 
@@ -1,25 +1,25 @@
1
- defmodule Potionx.Auth.Session do
2
- import Ecto.Changeset
3
-
4
- def changeset(struct, params) do
5
- struct
6
- |> cast(
7
- params, [
8
- :data,
9
- :deleted_at,
10
- :ip,
11
- :sign_in_provider,
12
- :ttl_access_seconds,
13
- :ttl_renewal_seconds,
14
- :user_id,
15
- :uuid_access,
16
- :uuid_renewal
17
- ]
18
- )
19
- |> assoc_constraint(:user)
20
- |> validate_required([
21
- :ttl_access_seconds,
22
- :uuid_access
23
- ])
24
- end
25
- end
1
+ defmodule Potionx.Auth.Session do
2
+ import Ecto.Changeset
3
+
4
+ def changeset(struct, params) do
5
+ struct
6
+ |> cast(
7
+ params, [
8
+ :data,
9
+ :deleted_at,
10
+ :ip,
11
+ :sign_in_provider,
12
+ :ttl_access_seconds,
13
+ :ttl_renewal_seconds,
14
+ :user_id,
15
+ :uuid_access,
16
+ :uuid_renewal
17
+ ]
18
+ )
19
+ |> assoc_constraint(:user)
20
+ |> validate_required([
21
+ :ttl_access_seconds,
22
+ :uuid_access
23
+ ])
24
+ end
25
+ end
changed lib/potionx/auth/auth_session_service.ex
 
@@ -1,299 +1,299 @@
1
- defmodule Potionx.Auth.SessionService do
2
- alias Potionx.Context.Service
3
- alias Ecto.Multi
4
-
5
- @callback create(Potionx.Context.Service.t(), struct() | nil) :: {:ok, struct()} | {:error, map()}
6
- @callback delete(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, String.t()}
7
- @callback one(Potionx.Context.Service.t()) :: struct()
8
- @callback one_from_cache(Potionx.Context.Service.t()) :: struct() | map() | nil
9
- @callback patch(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
10
- @callback use_redis() :: boolean()
11
-
12
- defmacro __using__(opts) do
13
- if !Keyword.get(opts, :identity_service) do
14
- raise "Potionx.Auth.SessionService requires an identity service"
15
- end
16
- if !Keyword.get(opts, :repo) do
17
- raise "Potionx.Auth.SessionService requires a repo"
18
- end
19
- if !Keyword.get(opts, :session_schema) do
20
- raise "Potionx.Auth.SessionService requires a session schema"
21
- end
22
- if !Keyword.get(opts, :user_schema) do
23
- raise "Potionx.Auth.SessionService requires a user schema"
24
- end
25
- if !Keyword.get(opts, :user_service) do
26
- raise "Potionx.Auth.SessionService requires a user service"
27
- end
28
-
29
- quote do
30
- @allow_new_users unquote(opts[:allow_new_users])
31
- @behaviour Potionx.Auth.SessionService
32
- @identity_service unquote(opts[:identity_service])
33
- @repo unquote(opts[:repo])
34
- @session_schema unquote(opts[:session_schema])
35
- @user_schema unquote(opts[:user_schema])
36
- @user_service unquote(opts[:user_service])
37
- import Ecto.Query
38
-
39
- def create(%Service{changes: %{session: _} = changes, filters: filters} = ctx, previous_session) do
40
- Multi.new
41
- |> Multi.run(:session_delete, fn _, _ ->
42
- if previous_session do
43
- delete_from_repo(previous_session)
44
- else
45
- {:ok, nil}
46
- end
47
- end)
48
- |> Multi.run(:redis_old_session_deleted, fn
49
- _, %{session_delete: %{id: _} = session} ->
50
- delete_from_redis(session)
51
- _, _ -> {:ok, nil}
52
- end)
53
- |> Multi.run(:user, fn _, _ ->
54
- case changes do
55
- %{user: %{email: email}} ->
56
- @user_service.sign_in(%Service{changes: changes.user, filters: %{email: email}})
57
- |> case do
58
- nil -> {:error, "user_not_found"}
59
- {:ok, %{user: user}} -> {:ok, user}
60
- {:ok, _} = res -> res
61
- %{id: _} = user -> {:ok, user}
62
- err -> err
63
- end
64
- _ ->
65
- {:ok, nil}
66
- end
67
- end)
68
- |> Multi.run(:identity, fn
69
- _, %{user: %{id: user_id}} ->
70
- process_identity(changes.identity, user_id)
71
- _, _ -> {:ok, nil}
72
- end)
73
- |> Multi.run(:session, fn _, %{user: user} ->
74
- struct(@session_schema)
75
- |> @session_schema.changeset(
76
- Map.put(
77
- changes.session,
78
- :user_id,
79
- Map.get(user || %{}, :id)
80
- )
81
- )
82
- |> @repo.insert
83
- end)
84
- |> Multi.run(:redis, fn _, %{session: session, user: user} ->
85
- session = %{session | user: user}
86
- if (use_redis()) do
87
- [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
88
- |> Enum.reduce([], fn {key, ttl_key}, acc ->
89
- if Map.get(session, key) do
90
- acc ++ [
91
- Potionx.Redis.put(
92
- Map.get(session, key),
93
- Jason.encode!(session),
94
- Map.get(session, ttl_key)
95
- )
96
- ]
97
- else
98
- acc
99
- end
100
- end)
101
- |> Potionx.Utils.Ecto.reduce_results
102
- else
103
- {:ok, nil}
104
- end
105
- end)
106
- |> @repo.transaction
107
- end
108
- def create(%Service{changes: changes} = srv, previous_session) do
109
- create(
110
- %{
111
- srv | changes: %{session: changes}
112
- },
113
- previous_session
114
- )
115
- end
116
-
117
- def delete(%Service{filters: %{id: id}}) do
118
- Multi.new
119
- |> Multi.run(:session, fn _, _ ->
120
- @repo.get(@session_schema, id)
121
- |> case do
122
- nil -> {:error, "missing_session"}
123
- session -> {:ok, session}
124
- end
125
- end)
126
- |> Multi.run(
127
- :session_delete,
128
- fn _repo, %{session: session} ->
129
- delete_from_repo(session)
130
- end
131
- )
132
- |> Multi.run(:redis, fn _, %{session: session} ->
133
- delete_from_redis(session)
134
- end)
135
- |> @repo.transaction
136
- end
137
-
138
- def delete_from_repo(%{id: _} = session) do
139
- session
140
- |> @session_schema.changeset(%{
141
- deleted_at: NaiveDateTime.truncate(NaiveDateTime.utc_now, :second)
142
- })
143
- |> @repo.update
144
- end
145
-
146
- def delete_from_redis(%{uuid_access: _} = session) do
147
- if (use_redis()) do
148
- [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
149
- |> Enum.reduce([], fn {key, ttl_key}, acc ->
150
- if Map.get(session, key) do
151
- acc ++ [
152
- Potionx.Redis.delete(
153
- Map.get(session, key)
154
- )
155
- ]
156
- else
157
- acc
158
- end
159
- end)
160
- |> Potionx.Utils.Ecto.reduce_results
161
- else
162
- {:ok, nil}
163
- end
164
- end
165
-
166
- def one(%Service{} = ctx) do
167
- query(ctx)
168
- |> preload([:user])
169
- |> @repo.one
170
- end
171
-
172
- def one_from_cache(%Service{} = ctx) do
173
- token = case ctx.filters do
174
- %{uuid_access: token} -> token
175
- %{uuid_renewal: token} -> token
176
- end
177
- if (use_redis()) do
178
- Potionx.Redis.get(token)
179
- |> case do
180
- {:ok, nil} -> nil
181
- {:ok, res} ->
182
- struct!(
183
- @session_schema,
184
- Jason.decode!(res)
185
- |> transform_keys_to_atoms
186
- )
187
- _ -> nil
188
- end
189
- else
190
- one(ctx)
191
- end
192
- end
193
-
194
- def patch(%Service{filters: filters} = ctx) do
195
- Multi.new
196
- |> Multi.run(:session_old, fn _, _ ->
197
- query(ctx)
198
- |> preload([s], [:user])
199
- |> @repo.one
200
- |> case do
201
- nil -> {:error, "missing_session"}
202
- session -> {:ok, session}
203
- end
204
- end)
205
- |> Multi.run(
206
- :session_patch,
207
- fn _repo, %{session_old: session} ->
208
- session
209
- |> @session_schema.changeset(ctx.changes)
210
- |> @repo.update
211
- end
212
- )
213
- |> Multi.run(:redis_old, fn _, %{session_old: session} ->
214
- if (use_redis()) do
215
- [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
216
- |> Enum.map(fn {key, ttl_key} ->
217
- Potionx.Redis.delete(
218
- Map.get(session, key)
219
- )
220
- end)
221
- |> Potionx.Utils.Ecto.reduce_results
222
- else
223
- {:ok, nil}
224
- end
225
- end)
226
- |> Multi.run(:redis, fn _, %{session_patch: session} ->
227
- if (use_redis()) do
228
- [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
229
- |> Enum.map(fn {key, ttl_key} ->
230
- Potionx.Redis.put(
231
- Map.get(session, key),
232
- Jason.encode!(session),
233
- Map.get(session, ttl_key)
234
- )
235
- end)
236
- |> Potionx.Utils.Ecto.reduce_results
237
- else
238
- {:ok, nil}
239
- end
240
- end)
241
- |> @repo.transaction
242
- end
243
-
244
- def process_identity(changes, user_id) do
245
- @identity_service.query(%Service{filters: %{user_id: user_id}})
246
- |> @repo.all
247
- |> (fn identities ->
248
- existing_identity =
249
- Enum.find(identities, fn i -> i.provider === changes["provider"] end)
250
- cond do
251
- Enum.count(identities) === 0 ->
252
- @identity_service.create(%Service{
253
- changes: changes |> Map.put("user_id", user_id)
254
- })
255
- not is_nil(existing_identity) ->
256
- {:ok, existing_identity}
257
- true ->
258
- {:error, "invalid_provider"}
259
- end
260
- end).()
261
- end
262
-
263
- def query(%Service{} = ctx) do
264
- @session_schema
265
- |> where(
266
- ^(
267
- ctx.filters
268
- |> Map.to_list
269
- )
270
- )
271
- |> order_by([desc: :id])
272
- |> where([s], is_nil(s.deleted_at))
273
- end
274
- def query(q, _args), do: q
275
-
276
-
277
- def transform_keys_to_atoms(session) do
278
- session
279
- |> Map.new(fn {k, v} ->
280
- {String.to_existing_atom(k), v}
281
- end)
282
- |> case do
283
- %{user: %{"id" => _} = user} = session ->
284
- %{
285
- session |
286
- user: @user_schema.from_json(user)
287
- }
288
- session -> session
289
- end
290
- end
291
-
292
- def use_redis do
293
- Application.get_env(:redix, :url) !== nil
294
- end
295
-
296
- defoverridable(Potionx.Auth.SessionService)
297
- end
298
- end
299
- end
1
+ defmodule Potionx.Auth.SessionService do
2
+ alias Potionx.Context.Service
3
+ alias Ecto.Multi
4
+
5
+ @callback create(Potionx.Context.Service.t(), struct() | nil) :: {:ok, struct()} | {:error, map()}
6
+ @callback delete(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, String.t()}
7
+ @callback one(Potionx.Context.Service.t()) :: struct()
8
+ @callback one_from_cache(Potionx.Context.Service.t()) :: struct() | map() | nil
9
+ @callback patch(Potionx.Context.Service.t()) :: {:ok, struct()} | {:error, map()}
10
+ @callback use_redis() :: boolean()
11
+
12
+ defmacro __using__(opts) do
13
+ if !Keyword.get(opts, :identity_service) do
14
+ raise "Potionx.Auth.SessionService requires an identity service"
15
+ end
16
+ if !Keyword.get(opts, :repo) do
17
+ raise "Potionx.Auth.SessionService requires a repo"
18
+ end
19
+ if !Keyword.get(opts, :session_schema) do
20
+ raise "Potionx.Auth.SessionService requires a session schema"
21
+ end
22
+ if !Keyword.get(opts, :user_schema) do
23
+ raise "Potionx.Auth.SessionService requires a user schema"
24
+ end
25
+ if !Keyword.get(opts, :user_service) do
26
+ raise "Potionx.Auth.SessionService requires a user service"
27
+ end
28
+
29
+ quote do
30
+ @allow_new_users unquote(opts[:allow_new_users])
31
+ @behaviour Potionx.Auth.SessionService
32
+ @identity_service unquote(opts[:identity_service])
33
+ @repo unquote(opts[:repo])
34
+ @session_schema unquote(opts[:session_schema])
35
+ @user_schema unquote(opts[:user_schema])
36
+ @user_service unquote(opts[:user_service])
37
+ import Ecto.Query
38
+
39
+ def create(%Service{changes: %{session: _} = changes, filters: filters} = ctx, previous_session) do
40
+ Multi.new
41
+ |> Multi.run(:session_delete, fn _, _ ->
42
+ if previous_session do
43
+ delete_from_repo(previous_session)
44
+ else
45
+ {:ok, nil}
46
+ end
47
+ end)
48
+ |> Multi.run(:redis_old_session_deleted, fn
49
+ _, %{session_delete: %{id: _} = session} ->
50
+ delete_from_redis(session)
51
+ _, _ -> {:ok, nil}
52
+ end)
53
+ |> Multi.run(:user, fn _, _ ->
54
+ case changes do
55
+ %{user: %{email: email}} ->
56
+ @user_service.sign_in(%Service{changes: changes.user, filters: %{email: email}})
57
+ |> case do
58
+ nil -> {:error, "user_not_found"}
59
+ {:ok, %{user: user}} -> {:ok, user}
60
+ {:ok, _} = res -> res
61
+ %{id: _} = user -> {:ok, user}
62
+ err -> err
63
+ end
64
+ _ ->
65
+ {:ok, nil}
66
+ end
67
+ end)
68
+ |> Multi.run(:identity, fn
69
+ _, %{user: %{id: user_id}} ->
70
+ process_identity(changes.identity, user_id)
71
+ _, _ -> {:ok, nil}
72
+ end)
73
+ |> Multi.run(:session, fn _, %{user: user} ->
74
+ struct(@session_schema)
75
+ |> @session_schema.changeset(
76
+ Map.put(
77
+ changes.session,
78
+ :user_id,
79
+ Map.get(user || %{}, :id)
80
+ )
81
+ )
82
+ |> @repo.insert
83
+ end)
84
+ |> Multi.run(:redis, fn _, %{session: session, user: user} ->
85
+ session = %{session | user: user}
86
+ if (use_redis()) do
87
+ [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
88
+ |> Enum.reduce([], fn {key, ttl_key}, acc ->
89
+ if Map.get(session, key) do
90
+ acc ++ [
91
+ Potionx.Redis.put(
92
+ Map.get(session, key),
93
+ Jason.encode!(session),
94
+ Map.get(session, ttl_key)
95
+ )
96
+ ]
97
+ else
98
+ acc
99
+ end
100
+ end)
101
+ |> Potionx.Utils.Ecto.reduce_results
102
+ else
103
+ {:ok, nil}
104
+ end
105
+ end)
106
+ |> @repo.transaction
107
+ end
108
+ def create(%Service{changes: changes} = srv, previous_session) do
109
+ create(
110
+ %{
111
+ srv | changes: %{session: changes}
112
+ },
113
+ previous_session
114
+ )
115
+ end
116
+
117
+ def delete(%Service{filters: %{id: id}}) do
118
+ Multi.new
119
+ |> Multi.run(:session, fn _, _ ->
120
+ @repo.get(@session_schema, id)
121
+ |> case do
122
+ nil -> {:error, "missing_session"}
123
+ session -> {:ok, session}
124
+ end
125
+ end)
126
+ |> Multi.run(
127
+ :session_delete,
128
+ fn _repo, %{session: session} ->
129
+ delete_from_repo(session)
130
+ end
131
+ )
132
+ |> Multi.run(:redis, fn _, %{session: session} ->
133
+ delete_from_redis(session)
134
+ end)
135
+ |> @repo.transaction
136
+ end
137
+
138
+ def delete_from_repo(%{id: _} = session) do
139
+ session
140
+ |> @session_schema.changeset(%{
141
+ deleted_at: NaiveDateTime.truncate(NaiveDateTime.utc_now, :second)
142
+ })
143
+ |> @repo.update
144
+ end
145
+
146
+ def delete_from_redis(%{uuid_access: _} = session) do
147
+ if (use_redis()) do
148
+ [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
149
+ |> Enum.reduce([], fn {key, ttl_key}, acc ->
150
+ if Map.get(session, key) do
151
+ acc ++ [
152
+ Potionx.Redis.delete(
153
+ Map.get(session, key)
154
+ )
155
+ ]
156
+ else
157
+ acc
158
+ end
159
+ end)
160
+ |> Potionx.Utils.Ecto.reduce_results
161
+ else
162
+ {:ok, nil}
163
+ end
164
+ end
165
+
166
+ def one(%Service{} = ctx) do
167
+ query(ctx)
168
+ |> preload([:user])
169
+ |> @repo.one
170
+ end
171
+
172
+ def one_from_cache(%Service{} = ctx) do
173
+ token = case ctx.filters do
174
+ %{uuid_access: token} -> token
175
+ %{uuid_renewal: token} -> token
176
+ end
177
+ if (use_redis()) do
178
+ Potionx.Redis.get(token)
179
+ |> case do
180
+ {:ok, nil} -> nil
181
+ {:ok, res} ->
182
+ struct!(
183
+ @session_schema,
184
+ Jason.decode!(res)
185
+ |> transform_keys_to_atoms
186
+ )
187
+ _ -> nil
188
+ end
189
+ else
190
+ one(ctx)
191
+ end
192
+ end
193
+
194
+ def patch(%Service{filters: filters} = ctx) do
195
+ Multi.new
196
+ |> Multi.run(:session_old, fn _, _ ->
197
+ query(ctx)
198
+ |> preload([s], [:user])
199
+ |> @repo.one
200
+ |> case do
201
+ nil -> {:error, "missing_session"}
202
+ session -> {:ok, session}
203
+ end
204
+ end)
205
+ |> Multi.run(
206
+ :session_patch,
207
+ fn _repo, %{session_old: session} ->
208
+ session
209
+ |> @session_schema.changeset(ctx.changes)
210
+ |> @repo.update
211
+ end
212
+ )
213
+ |> Multi.run(:redis_old, fn _, %{session_old: session} ->
214
+ if (use_redis()) do
215
+ [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
216
+ |> Enum.map(fn {key, ttl_key} ->
217
+ Potionx.Redis.delete(
218
+ Map.get(session, key)
219
+ )
220
+ end)
221
+ |> Potionx.Utils.Ecto.reduce_results
222
+ else
223
+ {:ok, nil}
224
+ end
225
+ end)
226
+ |> Multi.run(:redis, fn _, %{session_patch: session} ->
227
+ if (use_redis()) do
228
+ [{:uuid_access, :ttl_access_seconds}, {:uuid_renewal, :ttl_renewal_seconds}]
229
+ |> Enum.map(fn {key, ttl_key} ->
230
+ Potionx.Redis.put(
231
+ Map.get(session, key),
232
+ Jason.encode!(session),
233
+ Map.get(session, ttl_key)
234
+ )
235
+ end)
236
+ |> Potionx.Utils.Ecto.reduce_results
237
+ else
238
+ {:ok, nil}
239
+ end
240
+ end)
241
+ |> @repo.transaction
242
+ end
243
+
244
+ def process_identity(changes, user_id) do
245
+ @identity_service.query(%Service{filters: %{user_id: user_id}})
246
+ |> @repo.all
247
+ |> (fn identities ->
248
+ existing_identity =
249
+ Enum.find(identities, fn i -> i.provider === changes["provider"] end)
250
+ cond do
251
+ Enum.count(identities) === 0 ->
252
+ @identity_service.create(%Service{
253
+ changes: changes |> Map.put("user_id", user_id)
254
+ })
255
+ not is_nil(existing_identity) ->
256
+ {:ok, existing_identity}
257
+ true ->
258
+ {:error, "invalid_provider"}
259
+ end
260
+ end).()
261
+ end
262
+
263
+ def query(%Service{} = ctx) do
264
+ @session_schema
265
+ |> where(
266
+ ^(
267
+ ctx.filters
268
+ |> Map.to_list
269
+ )
270
+ )
271
+ |> order_by([desc: :id])
272
+ |> where([s], is_nil(s.deleted_at))
273
+ end
274
+ def query(q, _args), do: q
275
+
276
+
277
+ def transform_keys_to_atoms(session) do
278
+ session
279
+ |> Map.new(fn {k, v} ->
280
+ {String.to_existing_atom(k), v}
281
+ end)
282
+ |> case do
283
+ %{user: %{"id" => _} = user} = session ->
284
+ %{
285
+ session |
286
+ user: @user_schema.from_json(user)
287
+ }
288
+ session -> session
289
+ end
290
+ end
291
+
292
+ def use_redis do
293
+ Application.get_env(:redix, :url) !== nil
294
+ end
295
+
296
+ defoverridable(Potionx.Auth.SessionService)
297
+ end
298
+ end
299
+ end
changed lib/potionx/auth/auth_test_provider.ex
 
@@ -1,39 +1,39 @@
1
- defmodule Potionx.Auth.Provider.Test do
2
- @behaviour Assent.Strategy
3
-
4
- @impl true
5
- def authorize_url(config) do
6
- case config[:error] do
7
- nil -> {:ok, %{url: url(), session_params: %{a: 1}}}
8
- error -> {:error, error}
9
- end
10
- end
11
-
12
- @impl true
13
- def callback(_config, %{"a" => "1"}) do
14
- {
15
- :ok, %{
16
- user: %{
17
- "sub" => 1,
18
- "name" => "name",
19
- "email" => email(),
20
- "family_name" => "family_name",
21
- "given_name" => "given_name",
22
- "picture" => "https://lh3.googleusercontent.com/a-/AOh14GhYIvA2AFsxAfXqUm9ZBiEbzwHX0M7qQ0JMzQzV8w=s100-c"
23
- },
24
- token: %{"access_token" => "access_token"}
25
- }
26
- }
27
- end
28
- def callback(_config, _params) do
29
- {:error, "Invalid params"}
30
- end
31
-
32
- def email, do: "[email protected]"
33
-
34
- def redirect_url, do: "/"
35
-
36
- def url do
37
- "https://provider.example.com/oauth/authorize"
38
- end
39
- end
1
+ defmodule Potionx.Auth.Provider.Test do
2
+ @behaviour Assent.Strategy
3
+
4
+ @impl true
5
+ def authorize_url(config) do
6
+ case config[:error] do
7
+ nil -> {:ok, %{url: url(), session_params: %{a: 1}}}
8
+ error -> {:error, error}
9
+ end
10
+ end
11
+
12
+ @impl true
13
+ def callback(_config, %{"a" => "1"}) do
14
+ {
15
+ :ok, %{
16
+ user: %{
17
+ "sub" => 1,
18
+ "name" => "name",
19
+ "email" => email(),
20
+ "family_name" => "family_name",
21
+ "given_name" => "given_name",
22
+ "picture" => "https://lh3.googleusercontent.com/a-/AOh14GhYIvA2AFsxAfXqUm9ZBiEbzwHX0M7qQ0JMzQzV8w=s100-c"
23
+ },
24
+ token: %{"access_token" => "access_token"}
25
+ }
26
+ }
27
+ end
28
+ def callback(_config, _params) do
29
+ {:error, "Invalid params"}
30
+ end
31
+
32
+ def email, do: "[email protected]"
33
+
34
+ def redirect_url, do: "/"
35
+
36
+ def url do
37
+ "https://provider.example.com/oauth/authorize"
38
+ end
39
+ end
changed lib/potionx/auth/auth_user.ex
 
@@ -1,3 +1,3 @@
1
- defmodule Potionx.Auth.User do
2
- @callback from_json(map()) :: struct()
3
- end
1
+ defmodule Potionx.Auth.User do
2
+ @callback from_json(map()) :: struct()
3
+ end
changed lib/potionx/controllers/controller.ex
 
@@ -1,22 +1,22 @@
1
- defmodule Potionx.Controller do
2
- @spec json(Plug.Conn.t, term) :: Plug.Conn.t
3
- def json(conn, data) do
4
- response = Jason.encode_to_iodata!(data)
5
- send_resp(conn, conn.status || 200, "application/json", response)
6
- end
7
-
8
- defp send_resp(conn, default_status, default_content_type, body) do
9
- conn
10
- |> ensure_resp_content_type(default_content_type)
11
- |> Plug.Conn.send_resp(conn.status || default_status, body)
12
- end
13
-
14
- defp ensure_resp_content_type(%Plug.Conn{resp_headers: resp_headers} = conn, content_type) do
15
- if List.keyfind(resp_headers, "content-type", 0) do
16
- conn
17
- else
18
- content_type = content_type <> "; charset=utf-8"
19
- %Plug.Conn{conn | resp_headers: [{"content-type", content_type}|resp_headers]}
20
- end
21
- end
22
- end
1
+ defmodule Potionx.Controller do
2
+ @spec json(Plug.Conn.t, term) :: Plug.Conn.t
3
+ def json(conn, data) do
4
+ response = Jason.encode_to_iodata!(data)
5
+ send_resp(conn, conn.status || 200, "application/json", response)
6
+ end
7
+
8
+ defp send_resp(conn, default_status, default_content_type, body) do
9
+ conn
10
+ |> ensure_resp_content_type(default_content_type)
11
+ |> Plug.Conn.send_resp(conn.status || default_status, body)
12
+ end
13
+
14
+ defp ensure_resp_content_type(%Plug.Conn{resp_headers: resp_headers} = conn, content_type) do
15
+ if List.keyfind(resp_headers, "content-type", 0) do
16
+ conn
17
+ else
18
+ content_type = content_type <> "; charset=utf-8"
19
+ %Plug.Conn{conn | resp_headers: [{"content-type", content_type}|resp_headers]}
20
+ end
21
+ end
22
+ end
changed lib/potionx/doc_utils.ex
 
@@ -1,18 +1,18 @@
1
- defmodule Potionx.DocUtils do
2
- def indent_to_string(0) do
3
- ""
4
- end
5
- def indent_to_string(indent) do
6
- Enum.reduce(1..indent, "", fn _, acc -> acc <> " " end)
7
- end
8
-
9
- def insert_list(list, index, list_to_insert) do
10
- Enum.concat(
11
- [
12
- Enum.slice(list, 0..(index-1)),
13
- list_to_insert,
14
- Enum.slice(list, index..-1)
15
- ]
16
- )
17
- end
18
- end
1
+ defmodule Potionx.DocUtils do
2
+ def indent_to_string(0) do
3
+ ""
4
+ end
5
+ def indent_to_string(indent) do
6
+ Enum.reduce(1..indent, "", fn _, acc -> acc <> " " end)
7
+ end
8
+
9
+ def insert_list(list, index, list_to_insert) do
10
+ Enum.concat(
11
+ [
12
+ Enum.slice(list, 0..(index-1)),
13
+ list_to_insert,
14
+ Enum.slice(list, index..-1)
15
+ ]
16
+ )
17
+ end
18
+ end
changed lib/potionx/endpoint.ex
 
@@ -1,18 +1,18 @@
1
- defmodule Potionx.Endpoint do
2
- defmacro __using__(opts) do
3
- quote do
4
- def disconnect_socket(fingerprint) do
5
- %Phoenix.Socket{
6
- assigns: %{
7
- session_fingerprint: fingerprint
8
- }
9
- }
10
- |> unquote(opts[:socket]).id
11
- |> broadcast("disconnect", %{})
12
- end
13
-
14
- defoverridable([disconnect_socket: 1])
15
- end
16
- end
17
-
18
- end
1
+ defmodule Potionx.Endpoint do
2
+ defmacro __using__(opts) do
3
+ quote do
4
+ def disconnect_socket(fingerprint) do
5
+ %Phoenix.Socket{
6
+ assigns: %{
7
+ session_fingerprint: fingerprint
8
+ }
9
+ }
10
+ |> unquote(opts[:socket]).id
11
+ |> broadcast("disconnect", %{})
12
+ end
13
+
14
+ defoverridable([disconnect_socket: 1])
15
+ end
16
+ end
17
+
18
+ end
changed lib/potionx/graphql/swell/products/product_queries.ex
 
@@ -1,104 +1,104 @@
1
- defmodule Potionx.GraphQl.Swell.ProductTypes do
2
- use Absinthe.Schema.Notation
3
- use Absinthe.Relay.Schema.Notation, :modern
4
-
5
- enum :discount_type_swell do
6
- value :fixed
7
- value :percent
8
- end
9
-
10
- enum :delivery_swell do
11
- value :shipment
12
- value :subscription
13
- value :giftcard
14
- end
15
-
16
- enum :stock_status_swell do
17
- value :available
18
- value :backorder
19
- value :preorder
20
- end
21
-
22
- node object :file_swell do
23
- field :content_type, :string
24
- field :date_uploaded, :string
25
- field :height, :integer
26
- field :length, :integer
27
- field :md5, :string
28
- field :url, :string
29
- field :width, :integer
30
- end
31
-
32
- node object :image_swell do
33
- field :file, :file_swell
34
- end
35
-
36
- object :price_swell do
37
- field :account_group, :string
38
- field :price, :decimal
39
- field :quantity_max, :integer
40
- field :quantity_min, :integer
41
- end
42
-
43
- object :product_cross_sell_swell do
44
- field :id, :string
45
- field :discount_amount, :integer
46
- field :discount_percent, :integer
47
- field :discount_type, :discount_type_swell do
48
- resolve fn el, _, _ ->
49
- {:ok, String.to_existing_atom(el.discount_type)}
50
- end
51
- end
52
- field :product, :string
53
- field :product_id, :string
54
- end
55
-
56
- object :product_option_swell do
57
- field :id, :string
58
- field :input_hint, :string
59
- field :input_type, :string
60
- field :name, :string
61
- field :parent_id, :id
62
- field :parent_value_ids, list_of(:id)
63
- field :required, :boolean
64
- field :subscription, :boolean
65
- field :variant, :boolean
66
- field :values, list_of(:product_option_value_swell)
67
- end
68
-
69
- object :product_option_value_swell do
70
- field :id, :string
71
- field :name, :string
72
- field :price, :decimal
73
- end
74
-
75
- node object :product_swell do
76
- field :active, :boolean
77
- field :attributes, :json
78
- field :cost, :integer
79
- field :cross_sells, list_of(:product_cross_sell_swell)
80
- field :currency, :string
81
- field :date_created, :string
82
- field :date_updated, :string
83
- field :delivery, :delivery_swell do
84
- resolve fn el, _, _ ->
85
- {:ok, String.to_existing_atom(el.delivery)}
86
- end
87
- end
88
- field :description, :string
89
- field :images, list_of(:image_swell)
90
- field :meta_description, :string
91
- field :meta_title, :string
92
- field :name, :string
93
- field :options, list_of(:product_option_swell)
94
- field :price, :decimal
95
- field :prices, list_of(:price_swell)
96
- field :review_rating, :decimal
97
- field :slug, :string
98
- field :stock_level, :integer
99
- field :stock_status, :stock_status_swell
100
- end
101
-
102
- # "tags": [],
103
- # "type": "standard"
104
- end
1
+ defmodule Potionx.GraphQl.Swell.ProductTypes do
2
+ use Absinthe.Schema.Notation
3
+ use Absinthe.Relay.Schema.Notation, :modern
4
+
5
+ enum :discount_type_swell do
6
+ value :fixed
7
+ value :percent
8
+ end
9
+
10
+ enum :delivery_swell do
11
+ value :shipment
12
+ value :subscription
13
+ value :giftcard
14
+ end
15
+
16
+ enum :stock_status_swell do
17
+ value :available
18
+ value :backorder
19
+ value :preorder
20
+ end
21
+
22
+ node object :file_swell do
23
+ field :content_type, :string
24
+ field :date_uploaded, :string
25
+ field :height, :integer
26
+ field :length, :integer
27
+ field :md5, :string
28
+ field :url, :string
29
+ field :width, :integer
30
+ end
31
+
32
+ node object :image_swell do
33
+ field :file, :file_swell
34
+ end
35
+
36
+ object :price_swell do
37
+ field :account_group, :string
38
+ field :price, :decimal
39
+ field :quantity_max, :integer
40
+ field :quantity_min, :integer
41
+ end
42
+
43
+ object :product_cross_sell_swell do
44
+ field :id, :string
45
+ field :discount_amount, :integer
46
+ field :discount_percent, :integer
47
+ field :discount_type, :discount_type_swell do
48
+ resolve fn el, _, _ ->
49
+ {:ok, String.to_existing_atom(el.discount_type)}
50
+ end
51
+ end
52
+ field :product, :string
53
+ field :product_id, :string
54
+ end
55
+
56
+ object :product_option_swell do
57
+ field :id, :string
58
+ field :input_hint, :string
59
+ field :input_type, :string
60
+ field :name, :string
61
+ field :parent_id, :id
62
+ field :parent_value_ids, list_of(:id)
63
+ field :required, :boolean
64
+ field :subscription, :boolean
65
+ field :variant, :boolean
66
+ field :values, list_of(:product_option_value_swell)
67
+ end
68
+
69
+ object :product_option_value_swell do
70
+ field :id, :string
71
+ field :name, :string
72
+ field :price, :decimal
73
+ end
74
+
75
+ node object :product_swell do
76
+ field :active, :boolean
77
+ field :attributes, :json
78
+ field :cost, :integer
79
+ field :cross_sells, list_of(:product_cross_sell_swell)
80
+ field :currency, :string
81
+ field :date_created, :string
82
+ field :date_updated, :string
83
+ field :delivery, :delivery_swell do
84
+ resolve fn el, _, _ ->
85
+ {:ok, String.to_existing_atom(el.delivery)}
86
+ end
87
+ end
88
+ field :description, :string
89
+ field :images, list_of(:image_swell)
90
+ field :meta_description, :string
91
+ field :meta_title, :string
92
+ field :name, :string
93
+ field :options, list_of(:product_option_swell)
94
+ field :price, :decimal
95
+ field :prices, list_of(:price_swell)
96
+ field :review_rating, :decimal
97
+ field :slug, :string
98
+ field :stock_level, :integer
99
+ field :stock_status, :stock_status_swell
100
+ end
101
+
102
+ # "tags": [],
103
+ # "type": "standard"
104
+ end
changed lib/potionx/middleware/changeset_errors_middleware.ex
 
@@ -1,32 +1,32 @@
1
- defmodule Potionx.Middleware.ChangesetErrors do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(res, _) do
5
- with %{errors: [%Ecto.Changeset{} = changeset]} <- res do
6
- %{res |
7
- errors: [],
8
- value: %{
9
- errors_fields: transform_errors(changeset)
10
- }
11
- }
12
- end
13
- end
14
-
15
- def transform_errors(changeset) do
16
- changeset
17
- |> Ecto.Changeset.traverse_errors(&format_error/1)
18
- |> Enum.map(fn
19
- {field, value} ->
20
- %{
21
- field: field,
22
- message: value
23
- }
24
- end)
25
- end
26
-
27
- defp format_error({msg, opts}) do
28
- Enum.reduce(opts, msg, fn {key, value}, acc ->
29
- String.replace(acc, "%{#{key}}", to_string(value))
30
- end)
31
- end
32
- end
1
+ defmodule Potionx.Middleware.ChangesetErrors do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(res, _) do
5
+ with %{errors: [%Ecto.Changeset{} = changeset]} <- res do
6
+ %{res |
7
+ errors: [],
8
+ value: %{
9
+ errors_fields: transform_errors(changeset)
10
+ }
11
+ }
12
+ end
13
+ end
14
+
15
+ def transform_errors(changeset) do
16
+ changeset
17
+ |> Ecto.Changeset.traverse_errors(&format_error/1)
18
+ |> Enum.map(fn
19
+ {field, value} ->
20
+ %{
21
+ field: field,
22
+ message: value
23
+ }
24
+ end)
25
+ end
26
+
27
+ defp format_error({msg, opts}) do
28
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
29
+ String.replace(acc, "%{#{key}}", to_string(value))
30
+ end)
31
+ end
32
+ end
changed lib/potionx/middleware/me_middleware.ex
 
@@ -1,13 +1,13 @@
1
- defmodule Potionx.Middleware.Me do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{context: %Potionx.Context.Service{user: %{id: id}} = ctx} = res, _) do
5
- %{
6
- res | context: %{ctx | filters: %{id: id}}
7
- }
8
- end
9
- def call(res, _) do
10
- res
11
- |> Absinthe.Resolution.put_result({:error, "unauthorized"})
12
- end
13
- end
1
+ defmodule Potionx.Middleware.Me do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{context: %Potionx.Context.Service{user: %{id: id}} = ctx} = res, _) do
5
+ %{
6
+ res | context: %{ctx | filters: %{id: id}}
7
+ }
8
+ end
9
+ def call(res, _) do
10
+ res
11
+ |> Absinthe.Resolution.put_result({:error, "unauthorized"})
12
+ end
13
+ end
changed lib/potionx/middleware/mutation_middleware.ex
 
@@ -1,12 +1,12 @@
1
- defmodule Potionx.Middleware.Mutation do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(res, _) do
5
- cond do
6
- is_map(res.value) && Map.has_key?(res.value, :__struct__) ->
7
- %{res | value: %{node: res.value}}
8
- true ->
9
- %{res | value: res.value || %{}}
10
- end
11
- end
12
- end
1
+ defmodule Potionx.Middleware.Mutation do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(res, _) do
5
+ cond do
6
+ is_map(res.value) && Map.has_key?(res.value, :__struct__) ->
7
+ %{res | value: %{node: res.value}}
8
+ true ->
9
+ %{res | value: res.value || %{}}
10
+ end
11
+ end
12
+ end
changed lib/potionx/middleware/roles_authorization_middleware.ex
 
@@ -1,19 +1,19 @@
1
- defmodule Potionx.Middleware.RolesAuthorization do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{context: %Potionx.Context.Service{} = ctx} = res, opts) do
5
- (opts[:roles] || [])
6
- |> Enum.any?(fn role ->
7
- Enum.member?(ctx.roles || [], role)
8
- end)
9
- |> if do
10
- res
11
- else
12
- res
13
- |> Absinthe.Resolution.put_result({:error, "unauthorized"})
14
- end
15
- end
16
- def call(res, _) do
17
- res
18
- end
19
- end
1
+ defmodule Potionx.Middleware.RolesAuthorization do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{context: %Potionx.Context.Service{} = ctx} = res, opts) do
5
+ (opts[:roles] || [])
6
+ |> Enum.any?(fn role ->
7
+ Enum.member?(ctx.roles || [], role)
8
+ end)
9
+ |> if do
10
+ res
11
+ else
12
+ res
13
+ |> Absinthe.Resolution.put_result({:error, "unauthorized"})
14
+ end
15
+ end
16
+ def call(res, _) do
17
+ res
18
+ end
19
+ end
changed lib/potionx/middleware/scope_organization_middleware.ex
 
@@ -1,9 +1,9 @@
1
- defmodule Potionx.Middleware.ScopeOrganization do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{context: %Potionx.Context.Service{} = ctx} = res, _) do
5
- Potionx.Repo.put_org_id((ctx.organization || %{}) |> Map.get(:id))
6
- res
7
- end
8
- def call(resolution, _), do: resolution
9
- end
1
+ defmodule Potionx.Middleware.ScopeOrganization do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{context: %Potionx.Context.Service{} = ctx} = res, _) do
5
+ Potionx.Repo.put_org_id((ctx.organization || %{}) |> Map.get(:id))
6
+ res
7
+ end
8
+ def call(resolution, _), do: resolution
9
+ end
changed lib/potionx/middleware/scope_user_middleware.ex
 
@@ -1,9 +1,9 @@
1
- defmodule Potionx.Middleware.ScopeUser do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{context: %Potionx.Context.Service{} = ctx} = res, _) do
5
- Potionx.Repo.put_user_id((ctx.user || %{}) |> Map.get(:id))
6
- res
7
- end
8
- def call(resolution, _), do: resolution
9
- end
1
+ defmodule Potionx.Middleware.ScopeUser do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{context: %Potionx.Context.Service{} = ctx} = res, _) do
5
+ Potionx.Repo.put_user_id((ctx.user || %{}) |> Map.get(:id))
6
+ res
7
+ end
8
+ def call(resolution, _), do: resolution
9
+ end
changed lib/potionx/middleware/service_context_middleware.ex
 
@@ -1,25 +1,25 @@
1
- defmodule Potionx.Middleware.ServiceContext do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{arguments: args, context: %Potionx.Context.Service{} = ctx} = res, _) do
5
- %{
6
- res |
7
- context: %{
8
- ctx |
9
- arguments: args,
10
- changes: Map.get(args, :changes, ctx.changes),
11
- filters: Map.get(args, :filters, ctx.filters),
12
- order: Map.get(args, :order),
13
- order_by: Map.get(args, :order_by),
14
- search: Map.get(args, :search),
15
- pagination: %Potionx.Repo.Pagination{
16
- after: Map.get(args, :after),
17
- before: Map.get(args, :before),
18
- first: Map.get(args, :first),
19
- last: Map.get(args, :last)
20
- }
21
- }
22
- }
23
- end
24
- def call(resolution, _), do: resolution
25
- end
1
+ defmodule Potionx.Middleware.ServiceContext do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{arguments: args, context: %Potionx.Context.Service{} = ctx} = res, _) do
5
+ %{
6
+ res |
7
+ context: %{
8
+ ctx |
9
+ arguments: args,
10
+ changes: Map.get(args, :changes, ctx.changes),
11
+ filters: Map.get(args, :filters, ctx.filters),
12
+ order: Map.get(args, :order),
13
+ order_by: Map.get(args, :order_by),
14
+ search: Map.get(args, :search),
15
+ pagination: %Potionx.Repo.Pagination{
16
+ after: Map.get(args, :after),
17
+ before: Map.get(args, :before),
18
+ first: Map.get(args, :first),
19
+ last: Map.get(args, :last)
20
+ }
21
+ }
22
+ }
23
+ end
24
+ def call(resolution, _), do: resolution
25
+ end
changed lib/potionx/middleware/user_required_middleware.ex
 
@@ -1,23 +1,23 @@
1
- defmodule Potionx.Middleware.UserRequired do
2
- @behaviour Absinthe.Middleware
3
-
4
- def call(%{context: %Potionx.Context.Service{session: %{user: %{id: _}}}} = res, _opts) do
5
- res
6
- end
7
- def call(res, opts) do
8
- opts = Keyword.merge([exceptions: []], opts)
9
- Enum.member?(
10
- Keyword.get(opts, :exceptions),
11
- Absinthe.Resolution.path(res)
12
- |> Enum.at(0)
13
- |> Absinthe.Adapter.LanguageConventions.to_internal_name(nil)
14
- |> String.to_existing_atom
15
- )
16
- |> if do
17
- res
18
- else
19
- res
20
- |> Absinthe.Resolution.put_result({:error, "unauthorized"})
21
- end
22
- end
23
- end
1
+ defmodule Potionx.Middleware.UserRequired do
2
+ @behaviour Absinthe.Middleware
3
+
4
+ def call(%{context: %Potionx.Context.Service{session: %{user: %{id: _}}}} = res, _opts) do
5
+ res
6
+ end
7
+ def call(res, opts) do
8
+ opts = Keyword.merge([exceptions: []], opts)
9
+ Enum.member?(
10
+ Keyword.get(opts, :exceptions),
11
+ Absinthe.Resolution.path(res)
12
+ |> Enum.at(0)
13
+ |> Absinthe.Adapter.LanguageConventions.to_internal_name(nil)
14
+ |> String.to_existing_atom
15
+ )
16
+ |> if do
17
+ res
18
+ else
19
+ res
20
+ |> Absinthe.Resolution.put_result({:error, "unauthorized"})
21
+ end
22
+ end
23
+ end
changed lib/potionx/plugs/absinthe_plug.ex
 
@@ -1,12 +1,12 @@
1
- defmodule Potionx.Plug.Absinthe do
2
- @behaviour Plug
3
-
4
- def init(opts), do: opts
5
-
6
- def call(%{assigns: %{context: ctx}} = conn, _) do
7
- Absinthe.Plug.put_options(
8
- conn,
9
- context: ctx
10
- )
11
- end
12
- end
1
+ defmodule Potionx.Plug.Absinthe do
2
+ @behaviour Plug
3
+
4
+ def init(opts), do: opts
5
+
6
+ def call(%{assigns: %{context: ctx}} = conn, _) do
7
+ Absinthe.Plug.put_options(
8
+ conn,
9
+ context: ctx
10
+ )
11
+ end
12
+ end
changed lib/potionx/plugs/assets_manifest_plug.ex
 
@@ -1,15 +1,15 @@
1
- defmodule Potionx.Plug.AssetsManifest do
2
- alias Plug.Conn
3
-
4
- def call(conn, _) do
5
- conn
6
- |> Conn.assign(
7
- :scripts,
8
- []
9
- )
10
- |> Conn.assign(
11
- :stylesheets,
12
- []
13
- )
14
- end
15
- end
1
+ defmodule Potionx.Plug.AssetsManifest do
2
+ alias Plug.Conn
3
+
4
+ def call(conn, _) do
5
+ conn
6
+ |> Conn.assign(
7
+ :scripts,
8
+ []
9
+ )
10
+ |> Conn.assign(
11
+ :stylesheets,
12
+ []
13
+ )
14
+ end
15
+ end
changed lib/potionx/plugs/health_plug.ex
 
@@ -1,35 +1,35 @@
1
- defmodule Potionx.Plug.Health do
2
- @behaviour Plug
3
-
4
- @path_startup "/_k8s/startup"
5
- @path_liveness "/_k8s/liveness"
6
- @path_readiness "/_k8s/readiness"
7
-
8
- @impl true
9
- def init(opts), do: opts
10
-
11
- @impl true
12
- def call(%Plug.Conn{} = conn, opts) do
13
- health_module = Keyword.fetch!(opts, :health_module)
14
- case conn.request_path do
15
- @path_startup -> health_response(conn, health_module.has_started?())
16
- @path_liveness -> health_response(conn, health_module.is_alive?())
17
- @path_readiness -> health_response(conn, health_module.is_ready?())
18
- _other -> conn
19
- end
20
- end
21
-
22
- # Respond according to health checks
23
- defp health_response(conn, true) do
24
- conn
25
- |> Plug.Conn.send_resp(200, "OK")
26
- |> Plug.Conn.halt()
27
- end
28
-
29
- defp health_response(conn, false) do
30
- conn
31
- |> Plug.Conn.send_resp(503, "SERVICE UNAVAILABLE")
32
- |> Plug.Conn.halt()
33
- end
34
-
35
- end
1
+ defmodule Potionx.Plug.Health do
2
+ @behaviour Plug
3
+
4
+ @path_startup "/_k8s/startup"
5
+ @path_liveness "/_k8s/liveness"
6
+ @path_readiness "/_k8s/readiness"
7
+
8
+ @impl true
9
+ def init(opts), do: opts
10
+
11
+ @impl true
12
+ def call(%Plug.Conn{} = conn, opts) do
13
+ health_module = Keyword.fetch!(opts, :health_module)
14
+ case conn.request_path do
15
+ @path_startup -> health_response(conn, health_module.has_started?())
16
+ @path_liveness -> health_response(conn, health_module.is_alive?())
17
+ @path_readiness -> health_response(conn, health_module.is_ready?())
18
+ _other -> conn
19
+ end
20
+ end
21
+
22
+ # Respond according to health checks
23
+ defp health_response(conn, true) do
24
+ conn
25
+ |> Plug.Conn.send_resp(200, "OK")
26
+ |> Plug.Conn.halt()
27
+ end
28
+
29
+ defp health_response(conn, false) do
30
+ conn
31
+ |> Plug.Conn.send_resp(503, "SERVICE UNAVAILABLE")
32
+ |> Plug.Conn.halt()
33
+ end
34
+
35
+ end
changed lib/potionx/plugs/maybe_disable_introspection_plug.ex
 
@@ -1,45 +1,45 @@
1
- defmodule Potionx.Plug.MaybeDisableIntrospection do
2
- @behaviour Plug
3
- alias Plug.Conn
4
- alias Potionx.Context.Service
5
-
6
- @doc false
7
- def init(config) do
8
- Keyword.merge(
9
- [roles: [:admin]],
10
- config
11
- )
12
- |> Enum.into(%{})
13
- end
14
-
15
- def call(%Plug.Conn{params: %{"query" => q}} = conn, config) do
16
- if (String.contains?(q, "__schema")) do
17
- conn
18
- |> maybe_refuse(config)
19
- else
20
- conn
21
- end
22
- end
23
- def call(conn, _) do
24
- conn
25
- end
26
-
27
- def maybe_refuse(%{assigns: %{context: %Service{roles: roles}}} = conn, %{roles_allowed: roles_allowed}) when is_list(roles) do
28
- (roles_allowed || [])
29
- |> Enum.any?(fn role ->
30
- Enum.member?(roles, role)
31
- end)
32
- |> if do
33
- conn
34
- else
35
- conn
36
- |> Conn.put_status(:forbidden)
37
- |> Conn.halt
38
- end
39
- end
40
- def maybe_refuse(conn, _) do
41
- conn
42
- |> Conn.put_status(:forbidden)
43
- |> Conn.halt
44
- end
45
- end
1
+ defmodule Potionx.Plug.MaybeDisableIntrospection do
2
+ @behaviour Plug
3
+ alias Plug.Conn
4
+ alias Potionx.Context.Service
5
+
6
+ @doc false
7
+ def init(config) do
8
+ Keyword.merge(
9
+ [roles: [:admin]],
10
+ config
11
+ )
12
+ |> Enum.into(%{})
13
+ end
14
+
15
+ def call(%Plug.Conn{params: %{"query" => q}} = conn, config) do
16
+ if (String.contains?(q, "__schema")) do
17
+ conn
18
+ |> maybe_refuse(config)
19
+ else
20
+ conn
21
+ end
22
+ end
23
+ def call(conn, _) do
24
+ conn
25
+ end
26
+
27
+ def maybe_refuse(%{assigns: %{context: %Service{roles: roles}}} = conn, %{roles_allowed: roles_allowed}) when is_list(roles) do
28
+ (roles_allowed || [])
29
+ |> Enum.any?(fn role ->
30
+ Enum.member?(roles, role)
31
+ end)
32
+ |> if do
33
+ conn
34
+ else
35
+ conn
36
+ |> Conn.put_status(:forbidden)
37
+ |> Conn.halt
38
+ end
39
+ end
40
+ def maybe_refuse(conn, _) do
41
+ conn
42
+ |> Conn.put_status(:forbidden)
43
+ |> Conn.halt
44
+ end
45
+ end
changed lib/potionx/plugs/require_auth_plug.ex
 
@@ -1,47 +1,47 @@
1
- defmodule Potionx.Plug.RequireAuth do
2
- @behaviour Plug
3
- alias Potionx.Context.Service
4
-
5
- def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
- conn
7
- |> maybe_allow(opts)
8
- end
9
-
10
- def init(opts) do
11
- Keyword.merge(
12
- [
13
- login_path: "/login"
14
- ],
15
- opts
16
- )
17
- |> Enum.into(%{})
18
- end
19
-
20
- def maybe_allow(%{halted: true} = conn, _), do: conn
21
- def maybe_allow(%{assigns: %{context: %{session: %{user: %{id: id}}}}} = conn, opts) when not is_nil(id) do
22
- cond do
23
- conn.request_path === opts.login_path ->
24
- Phoenix.Controller.redirect(conn, to: "/")
25
- |> Plug.Conn.halt
26
- true ->
27
- conn
28
- end
29
- end
30
- def maybe_allow(%{assigns: %{context: %{session: %{id: id}}}} = conn, %{user_optional: true}) when not is_nil(id) do
31
- conn
32
- end
33
-
34
- def maybe_allow(%Plug.Conn{method: method, request_path: path} = conn, opts) do
35
- cond do
36
- path === opts.login_path ->
37
- conn
38
- method === "GET" ->
39
- Phoenix.Controller.redirect(conn, to: opts.login_path)
40
- |> Plug.Conn.halt
41
- true ->
42
- conn
43
- |> Plug.Conn.put_status(401)
44
- |> Plug.Conn.halt
45
- end
46
- end
47
- end
1
+ defmodule Potionx.Plug.RequireAuth do
2
+ @behaviour Plug
3
+ alias Potionx.Context.Service
4
+
5
+ def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
+ conn
7
+ |> maybe_allow(opts)
8
+ end
9
+
10
+ def init(opts) do
11
+ Keyword.merge(
12
+ [
13
+ login_path: "/login"
14
+ ],
15
+ opts
16
+ )
17
+ |> Enum.into(%{})
18
+ end
19
+
20
+ def maybe_allow(%{halted: true} = conn, _), do: conn
21
+ def maybe_allow(%{assigns: %{context: %{session: %{user: %{id: id}}}}} = conn, opts) when not is_nil(id) do
22
+ cond do
23
+ conn.request_path === opts.login_path ->
24
+ Phoenix.Controller.redirect(conn, to: "/")
25
+ |> Plug.Conn.halt
26
+ true ->
27
+ conn
28
+ end
29
+ end
30
+ def maybe_allow(%{assigns: %{context: %{session: %{id: id}}}} = conn, %{user_optional: true}) when not is_nil(id) do
31
+ conn
32
+ end
33
+
34
+ def maybe_allow(%Plug.Conn{method: method, request_path: path} = conn, opts) do
35
+ cond do
36
+ path === opts.login_path ->
37
+ conn
38
+ method === "GET" ->
39
+ Phoenix.Controller.redirect(conn, to: opts.login_path)
40
+ |> Plug.Conn.halt
41
+ true ->
42
+ conn
43
+ |> Plug.Conn.put_status(401)
44
+ |> Plug.Conn.halt
45
+ end
46
+ end
47
+ end
changed lib/potionx/plugs/require_unauth_plug.ex
 
@@ -1,26 +1,26 @@
1
- defmodule Potionx.Plug.RequireUnauth do
2
- @behaviour Plug
3
- alias Potionx.Context.Service
4
-
5
- def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
- conn
7
- |> maybe_redirect(opts)
8
- end
9
-
10
- def init(opts) do
11
- Keyword.merge(
12
- [],
13
- opts
14
- )
15
- |> Enum.into(%{})
16
- end
17
-
18
- def maybe_redirect(%{halted: true} = conn, _), do: conn
19
- def maybe_redirect(%{assigns: %{context: %{session: %{user: %{id: id}}}}} = conn, _opts) when not is_nil(id) do
20
- Phoenix.Controller.redirect(conn, to: "/")
21
- |> Plug.Conn.halt
22
- end
23
- def maybe_redirect(conn, _) do
24
- conn
25
- end
26
- end
1
+ defmodule Potionx.Plug.RequireUnauth do
2
+ @behaviour Plug
3
+ alias Potionx.Context.Service
4
+
5
+ def call(%{assigns: %{context: %Service{}}} = conn, opts) do
6
+ conn
7
+ |> maybe_redirect(opts)
8
+ end
9
+
10
+ def init(opts) do
11
+ Keyword.merge(
12
+ [],
13
+ opts
14
+ )
15
+ |> Enum.into(%{})
16
+ end
17
+
18
+ def maybe_redirect(%{halted: true} = conn, _), do: conn
19
+ def maybe_redirect(%{assigns: %{context: %{session: %{user: %{id: id}}}}} = conn, _opts) when not is_nil(id) do
20
+ Phoenix.Controller.redirect(conn, to: "/")
21
+ |> Plug.Conn.halt
22
+ end
23
+ def maybe_redirect(conn, _) do
24
+ conn
25
+ end
26
+ end
changed lib/potionx/plugs/service_context_plug.ex
 
@@ -1,43 +1,43 @@
1
- defmodule Potionx.Plug.ServiceContext do
2
- @behaviour Plug
3
-
4
- def init(opts), do: opts
5
-
6
- def call(conn, _) do
7
- conn
8
- |> Plug.Conn.assign(
9
- :context,
10
- build_context(conn)
11
- )
12
- end
13
-
14
- def build_context(conn) do
15
- ctx = %Potionx.Context.Service{
16
- changes: Map.get(conn.body_params, :changes, %{}),
17
- filters: Map.get(conn.body_params, :filters, %{}),
18
- # roles: Map.get((user || %{}), :roles, []),
19
- ip: get_ip(conn),
20
- organization: nil,
21
- request_url: Plug.Conn.request_url(conn)
22
- }
23
-
24
- ctx
25
- end
26
-
27
- def get_ip(conn) do
28
- forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for"))
29
- cloudflare_ip = Enum.at(Plug.Conn.get_req_header(conn, "cf-connecting-ip"), 0)
30
-
31
- cond do
32
- forwarded_for ->
33
- String.split(forwarded_for, ",")
34
- |> Enum.map(&String.trim/1)
35
- |> List.first()
36
- cloudflare_ip -> cloudflare_ip
37
- true ->
38
- conn.remote_ip
39
- |> :inet.ntoa
40
- |> to_string
41
- end
42
- end
43
- end
1
+ defmodule Potionx.Plug.ServiceContext do
2
+ @behaviour Plug
3
+
4
+ def init(opts), do: opts
5
+
6
+ def call(conn, _) do
7
+ conn
8
+ |> Plug.Conn.assign(
9
+ :context,
10
+ build_context(conn)
11
+ )
12
+ end
13
+
14
+ def build_context(conn) do
15
+ ctx = %Potionx.Context.Service{
16
+ changes: Map.get(conn.body_params, :changes, %{}),
17
+ filters: Map.get(conn.body_params, :filters, %{}),
18
+ # roles: Map.get((user || %{}), :roles, []),
19
+ ip: get_ip(conn),
20
+ organization: nil,
21
+ request_url: Plug.Conn.request_url(conn)
22
+ }
23
+
24
+ ctx
25
+ end
26
+
27
+ def get_ip(conn) do
28
+ forwarded_for = List.first(Plug.Conn.get_req_header(conn, "x-forwarded-for"))
29
+ cloudflare_ip = Enum.at(Plug.Conn.get_req_header(conn, "cf-connecting-ip"), 0)
30
+
31
+ cond do
32
+ forwarded_for ->
33
+ String.split(forwarded_for, ",")
34
+ |> Enum.map(&String.trim/1)
35
+ |> List.first()
36
+ cloudflare_ip -> cloudflare_ip
37
+ true ->
38
+ conn.remote_ip
39
+ |> :inet.ntoa
40
+ |> to_string
41
+ end
42
+ end
43
+ end
changed lib/potionx/plugs/urql_upload_plug.ex
 
@@ -1,35 +1,35 @@
1
- defmodule Potionx.Plug.UrqlUpload do
2
- def call(%{params: %{"map" => map, "operations" => operations} = params} = conn, _opts) do
3
- map = Jason.decode!(map)
4
- next_params = Jason.decode!(operations)
5
- {changes, uploads} = Enum.reduce(map, {%{}, %{}}, fn {k, [key]}, {changes, uploads} ->
6
- file = Map.get(params, k)
7
- key = String.split(key, ".") |> Enum.at(-1)
8
- {
9
- Map.put(
10
- changes,
11
- key,
12
- file.filename
13
- ),
14
- Map.put(
15
- uploads,
16
- file.filename,
17
- file
18
- )
19
- }
20
- end)
21
- %{
22
- conn |
23
- params: %{
24
- next_params |
25
- "variables" => %{
26
- next_params["variables"] |
27
- "changes" => Map.merge(next_params["variables"]["changes"], changes)
28
- }
29
- }
30
- |> Map.merge(uploads)
31
- }
32
- end
33
- def call(conn, _opts), do: conn
34
- def init(opts), do: opts
35
- end
1
+ defmodule Potionx.Plug.UrqlUpload do
2
+ def call(%{params: %{"map" => map, "operations" => operations} = params} = conn, _opts) do
3
+ map = Jason.decode!(map)
4
+ next_params = Jason.decode!(operations)
5
+ {changes, uploads} = Enum.reduce(map, {%{}, %{}}, fn {k, [key]}, {changes, uploads} ->
6
+ file = Map.get(params, k)
7
+ key = String.split(key, ".") |> Enum.at(-1)
8
+ {
9
+ Map.put(
10
+ changes,
11
+ key,
12
+ file.filename
13
+ ),
14
+ Map.put(
15
+ uploads,
16
+ file.filename,
17
+ file
18
+ )
19
+ }
20
+ end)
21
+ %{
22
+ conn |
23
+ params: %{
24
+ next_params |
25
+ "variables" => %{
26
+ next_params["variables"] |
27
+ "changes" => Map.merge(next_params["variables"]["changes"], changes)
28
+ }
29
+ }
30
+ |> Map.merge(uploads)
31
+ }
32
+ end
33
+ def call(conn, _opts), do: conn
34
+ def init(opts), do: opts
35
+ end
changed lib/potionx/redis.ex
 
@@ -1,24 +1,24 @@
1
- defmodule Potionx.Redis do
2
- @redix_instance_name :redix
3
-
4
- def delete(id) do
5
- Redix.command(
6
- @redix_instance_name,
7
- ["DEL", id]
8
- )
9
- end
10
-
11
- def get(id) do
12
- Redix.command(
13
- @redix_instance_name,
14
- ["GET", id]
15
- )
16
- end
17
-
18
- def put(key, value, ttl_s) do
19
- Redix.command(
20
- @redix_instance_name,
21
- ["SET", key, value, "PX", ttl_s * 1000]
22
- )
23
- end
24
- end
1
+ defmodule Potionx.Redis do
2
+ @redix_instance_name :redix
3
+
4
+ def delete(id) do
5
+ Redix.command(
6
+ @redix_instance_name,
7
+ ["DEL", id]
8
+ )
9
+ end
10
+
11
+ def get(id) do
12
+ Redix.command(
13
+ @redix_instance_name,
14
+ ["GET", id]
15
+ )
16
+ end
17
+
18
+ def put(key, value, ttl_s) do
19
+ Redix.command(
20
+ @redix_instance_name,
21
+ ["SET", key, value, "PX", ttl_s * 1000]
22
+ )
23
+ end
24
+ end
changed lib/potionx/repo.ex
 
@@ -1,78 +1,78 @@
1
- defmodule Potionx.Repo do
2
- @tenant_key_org {:potionx, :organization_id}
3
- @tenant_key_user {:potionx, :user_id}
4
- use TypedStruct
5
-
6
- defmodule Pagination do
7
- typedstruct do
8
- field :after, :string
9
- field :before, :string
10
- field :first, :integer
11
- field :last, :integer
12
- end
13
- end
14
-
15
- defmacro __using__(opts) do
16
- quote do
17
- require Ecto.Query
18
- @scoped_by_organization unquote(opts[:scoped_by_organization]) || []
19
- @scoped_by_user unquote(opts[:scoped_by_user]) || []
20
-
21
- def default_options(_operation) do
22
- [org_id: Potionx.Repo.get_org_id(), user_id: Potionx.Repo.get_user_id()]
23
- end
24
-
25
- def prepare_query(_operation, %{from: %{source: {_, model}}} = query, opts) do
26
- cond do
27
- opts[:schema_migration] ->
28
- {query, opts}
29
- Enum.member?(@scoped_by_organization, model) and is_nil(opts[:org_id]) ->
30
- raise "expected organization_id to be set"
31
- Enum.member?(@scoped_by_user, model) and is_nil(opts[:user_id]) ->
32
- raise "expected user_id to be set"
33
- true ->
34
- [
35
- {:user_id, @scoped_by_user},
36
- {:organization_id, @scoped_by_organization}
37
- ]
38
- |> Enum.reduce({query, opts}, fn {key, list}, {q, opts} ->
39
- cond do
40
- Enum.member?(list, model) ->
41
- {
42
- q |> Ecto.Query.where(^[{key, opts[key]}]),
43
- opts
44
- }
45
- not is_nil(opts[key]) ->
46
- {
47
- q |> Ecto.Query.where(^[{key, opts[key]}]),
48
- opts
49
- }
50
- true ->
51
- {q, opts}
52
- end
53
- end)
54
- true ->
55
- raise "expected org_id or skip_org_id to be set"
56
- end
57
- end
58
- def prepare_query(_operation, q, opts) do
59
- {q, opts}
60
- end
61
- defoverridable([default_options: 1, prepare_query: 3])
62
- end
63
- end
64
-
65
- def get_org_id() do
66
- Process.get(@tenant_key_org)
67
- end
68
- def get_user_id() do
69
- Process.get(@tenant_key_user)
70
- end
71
-
72
- def put_org_id(org_id) do
73
- Process.put(@tenant_key_org, org_id)
74
- end
75
- def put_user_id(user_id) do
76
- Process.put(@tenant_key_user, user_id)
77
- end
78
- end
1
+ defmodule Potionx.Repo do
2
+ @tenant_key_org {:potionx, :organization_id}
3
+ @tenant_key_user {:potionx, :user_id}
4
+ use TypedStruct
5
+
6
+ defmodule Pagination do
7
+ typedstruct do
8
+ field :after, :string
9
+ field :before, :string
10
+ field :first, :integer
11
+ field :last, :integer
12
+ end
13
+ end
14
+
15
+ defmacro __using__(opts) do
16
+ quote do
17
+ require Ecto.Query
18
+ @scoped_by_organization unquote(opts[:scoped_by_organization]) || []
19
+ @scoped_by_user unquote(opts[:scoped_by_user]) || []
20
+
21
+ def default_options(_operation) do
22
+ [org_id: Potionx.Repo.get_org_id(), user_id: Potionx.Repo.get_user_id()]
23
+ end
24
+
25
+ def prepare_query(_operation, %{from: %{source: {_, model}}} = query, opts) do
26
+ cond do
27
+ opts[:schema_migration] ->
28
+ {query, opts}
29
+ Enum.member?(@scoped_by_organization, model) and is_nil(opts[:org_id]) ->
30
+ raise "expected organization_id to be set"
31
+ Enum.member?(@scoped_by_user, model) and is_nil(opts[:user_id]) ->
32
+ raise "expected user_id to be set"
33
+ true ->
34
+ [
35
+ {:user_id, @scoped_by_user},
36
+ {:organization_id, @scoped_by_organization}
37
+ ]
38
+ |> Enum.reduce({query, opts}, fn {key, list}, {q, opts} ->
39
+ cond do
40
+ Enum.member?(list, model) ->
41
+ {
42
+ q |> Ecto.Query.where(^[{key, opts[key]}]),
43
+ opts
44
+ }
45
+ not is_nil(opts[key]) ->
46
+ {
47
+ q |> Ecto.Query.where(^[{key, opts[key]}]),
48
+ opts
49
+ }
50
+ true ->
51
+ {q, opts}
52
+ end
53
+ end)
54
+ true ->
55
+ raise "expected org_id or skip_org_id to be set"
56
+ end
57
+ end
58
+ def prepare_query(_operation, q, opts) do
59
+ {q, opts}
60
+ end
61
+ defoverridable([default_options: 1, prepare_query: 3])
62
+ end
63
+ end
64
+
65
+ def get_org_id() do
66
+ Process.get(@tenant_key_org)
67
+ end
68
+ def get_user_id() do
69
+ Process.get(@tenant_key_user)
70
+ end
71
+
72
+ def put_org_id(org_id) do
73
+ Process.put(@tenant_key_org, org_id)
74
+ end
75
+ def put_user_id(user_id) do
76
+ Process.put(@tenant_key_user, user_id)
77
+ end
78
+ end
changed lib/potionx/resolvers.ex
 
@@ -1,7 +1,7 @@
1
- defmodule Potionx.Resolvers do
2
- def resolve_computed(module, key) do
3
- fn entry, _, _ ->
4
- {:ok, apply(module, key, [entry])}
5
- end
6
- end
7
- end
1
+ defmodule Potionx.Resolvers do
2
+ def resolve_computed(module, key) do
3
+ fn entry, _, _ ->
4
+ {:ok, apply(module, key, [entry])}
5
+ end
6
+ end
7
+ end
changed lib/potionx/schema.ex
 
@@ -1,80 +1,80 @@
1
- defmodule Potionx.Schema do
2
- defmacro __using__(opts) do
3
- opts = Keyword.merge(
4
- [
5
- user_required_exceptions: []
6
- ],
7
- opts
8
- )
9
- quote do
10
- @user_required_exceptions unquote(opts[:user_required_exceptions])
11
- use Absinthe.Schema
12
- use Absinthe.Relay.Schema, :modern
13
- import_types Absinthe.Plug.Types
14
- import_types Absinthe.Type.Custom
15
- import_types Potionx.Types
16
- import_types Potionx.Types.JSON
17
-
18
- scalar :global_id do
19
- parse fn
20
- %{value: nil}, ctx ->
21
- {:ok, nil}
22
- %{value: v}, ctx when is_integer(v) ->
23
- {:ok, v}
24
- %{value: v}, ctx when is_binary(v) ->
25
- Integer.parse(v)
26
- |> case do
27
- :error ->
28
- Absinthe.Relay.Node.from_global_id(v, __MODULE__)
29
- |> case do
30
- {:ok, %{id: id}} -> {:ok, id}
31
- err -> {:ok, nil}
32
- end
33
- {res, _} ->
34
- {:ok, res}
35
- end
36
- _, _ ->
37
- {:ok, nil}
38
- end
39
-
40
- serialize fn input ->
41
- input
42
- end
43
- end
44
-
45
- def middleware(middleware, _field, %{identifier: :mutation}) do
46
- Enum.concat([
47
- [
48
- {Potionx.Middleware.UserRequired, [
49
- exceptions: @user_required_exceptions
50
- ++ [
51
- :session_renew, :sign_in_provider
52
- ]
53
- ]},
54
- Potionx.Middleware.ServiceContext
55
- ],
56
- middleware,
57
- [
58
- Potionx.Middleware.ChangesetErrors,
59
- Potionx.Middleware.Mutation
60
- ]
61
- ])
62
- end
63
- def middleware(middleware, _field, %{identifier: :query}) do
64
- [
65
- Potionx.Middleware.ServiceContext
66
- ] ++ middleware
67
- end
68
-
69
- def middleware(middleware, _field, _object) do
70
- middleware
71
- end
72
-
73
- def plugins do
74
- [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults()
75
- end
76
-
77
- defoverridable([middleware: 3])
78
- end
79
- end
80
- end
1
+ defmodule Potionx.Schema do
2
+ defmacro __using__(opts) do
3
+ opts = Keyword.merge(
4
+ [
5
+ user_required_exceptions: []
6
+ ],
7
+ opts
8
+ )
9
+ quote do
10
+ @user_required_exceptions unquote(opts[:user_required_exceptions])
11
+ use Absinthe.Schema
12
+ use Absinthe.Relay.Schema, :modern
13
+ import_types Absinthe.Plug.Types
14
+ import_types Absinthe.Type.Custom
15
+ import_types Potionx.Types
16
+ import_types Potionx.Types.JSON
17
+
18
+ scalar :global_id do
19
+ parse fn
20
+ %{value: nil}, ctx ->
21
+ {:ok, nil}
22
+ %{value: v}, ctx when is_integer(v) ->
23
+ {:ok, v}
24
+ %{value: v}, ctx when is_binary(v) ->
25
+ Integer.parse(v)
26
+ |> case do
27
+ :error ->
28
+ Absinthe.Relay.Node.from_global_id(v, __MODULE__)
29
+ |> case do
30
+ {:ok, %{id: id}} -> {:ok, id}
31
+ err -> {:ok, nil}
32
+ end
33
+ {res, _} ->
34
+ {:ok, res}
35
+ end
36
+ _, _ ->
37
+ {:ok, nil}
38
+ end
39
+
40
+ serialize fn input ->
41
+ input
42
+ end
43
+ end
44
+
45
+ def middleware(middleware, _field, %{identifier: :mutation}) do
46
+ Enum.concat([
47
+ [
48
+ {Potionx.Middleware.UserRequired, [
49
+ exceptions: @user_required_exceptions
50
+ ++ [
51
+ :session_renew, :sign_in_provider
52
+ ]
53
+ ]},
54
+ Potionx.Middleware.ServiceContext
55
+ ],
56
+ middleware,
57
+ [
58
+ Potionx.Middleware.ChangesetErrors,
59
+ Potionx.Middleware.Mutation
60
+ ]
61
+ ])
62
+ end
63
+ def middleware(middleware, _field, %{identifier: :query}) do
64
+ [
65
+ Potionx.Middleware.ServiceContext
66
+ ] ++ middleware
67
+ end
68
+
69
+ def middleware(middleware, _field, _object) do
70
+ middleware
71
+ end
72
+
73
+ def plugins do
74
+ [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults()
75
+ end
76
+
77
+ defoverridable([middleware: 3])
78
+ end
79
+ end
80
+ end
changed lib/potionx/service_context.ex
 
@@ -1,33 +1,35 @@
1
- defmodule Potionx.Context.Service do
2
- use TypedStruct
3
- @behaviour Access
4
-
5
- typedstruct do
6
- field :__absinthe_plug__, map()
7
- field :arguments, map()
8
- field :assigns, map()
9
- field :changes, map(), default: %{}
10
- field :files, [Plug.Upload.t()]
11
- field :filters, map(), default: %{}
12
- field :ip, String.t()
13
- field :order, atom()
14
- field :order_by, String.t()
15
- field :organization, struct()
16
- field :pagination, Potionx.Repo.Pagination.t(), default: %Potionx.Repo.Pagination{}
17
- field :request_url, String.t()
18
- field :roles, [String.t()], default: []
19
- field :search, String.t()
20
- field :session, struct()
21
- field :user, struct()
22
- end
23
-
24
- @spec fetch(map, any) :: {:ok, any}
25
- def fetch(ctx, key) do
26
- Map.fetch(ctx, key)
27
- end
28
-
29
- def get_and_update(data, key, func) do
30
- Map.get_and_update(data, key, func)
31
- end
32
- def pop(data, key), do: Map.pop(data, key)
33
- end
1
+ defmodule Potionx.Context.Service do
2
+ use TypedStruct
3
+ @behaviour Access
4
+
5
+ typedstruct do
6
+ field :__absinthe_plug__, map()
7
+ field :arguments, map()
8
+ field :assigns, map()
9
+ field :changes, map(), default: %{}
10
+ field :files, [Plug.Upload.t()]
11
+ field :filters, map(), default: %{}
12
+ field :ip, String.t()
13
+ field :locale, atom()
14
+ field :locale_default, atom()
15
+ field :order, atom()
16
+ field :order_by, String.t()
17
+ field :organization, struct()
18
+ field :pagination, Potionx.Repo.Pagination.t(), default: %Potionx.Repo.Pagination{}
19
+ field :request_url, String.t()
20
+ field :roles, [String.t()], default: []
21
+ field :search, String.t()
22
+ field :session, struct()
23
+ field :user, struct()
24
+ end
25
+
26
+ @spec fetch(map, any) :: {:ok, any}
27
+ def fetch(ctx, key) do
28
+ Map.fetch(ctx, key)
29
+ end
30
+
31
+ def get_and_update(data, key, func) do
32
+ Map.get_and_update(data, key, func)
33
+ end
34
+ def pop(data, key), do: Map.pop(data, key)
35
+ end
changed lib/potionx/socket.ex
 
@@ -1,30 +1,30 @@
1
- defmodule Potionx.Socket do
2
- defmacro __using__(_) do
3
- quote do
4
- @impl true
5
- def connect(%{"token" => token} = _params, socket, _) do
6
- {:ok, app_name} = :application.get_application(__MODULE__)
7
- %Plug.Conn{secret_key_base: socket.endpoint.config(:secret_key_base)}
8
- # |> Potionx.Plug.ApiAuth.get_credentials(token, [otp_app: app_name])
9
- |> case do
10
- nil -> :error
11
-
12
- {user, metadata} ->
13
- Absinthe.Phoenix.Socket.put_opts(
14
- socket,
15
- context: %Potionx.Context.Service{
16
- session_fingerprint: metadata[:fingerprint],
17
- user: user
18
- }
19
- )
20
- {:ok, socket}
21
- end
22
- end
23
-
24
- @impl true
25
- def id(%{assigns: %{session_fingerprint: session_fingerprint}}), do: "user:#{session_fingerprint}"
26
-
27
- defoverridable([id: 1, connect: 3])
28
- end
29
- end
30
- end
1
+ defmodule Potionx.Socket do
2
+ defmacro __using__(_) do
3
+ quote do
4
+ @impl true
5
+ def connect(%{"token" => token} = _params, socket, _) do
6
+ {:ok, app_name} = :application.get_application(__MODULE__)
7
+ %Plug.Conn{secret_key_base: socket.endpoint.config(:secret_key_base)}
8
+ # |> Potionx.Plug.ApiAuth.get_credentials(token, [otp_app: app_name])
9
+ |> case do
10
+ nil -> :error
11
+
12
+ {user, metadata} ->
13
+ Absinthe.Phoenix.Socket.put_opts(
14
+ socket,
15
+ context: %Potionx.Context.Service{
16
+ session_fingerprint: metadata[:fingerprint],
17
+ user: user
18
+ }
19
+ )
20
+ {:ok, socket}
21
+ end
22
+ end
23
+
24
+ @impl true
25
+ def id(%{assigns: %{session_fingerprint: session_fingerprint}}), do: "user:#{session_fingerprint}"
26
+
27
+ defoverridable([id: 1, connect: 3])
28
+ end
29
+ end
30
+ end
changed lib/potionx/types.ex
 
@@ -1,81 +1,81 @@
1
- defmodule Potionx.Types do
2
- use Absinthe.Schema.Notation
3
-
4
- enum :sort_order do
5
- value :asc
6
- value :desc
7
- end
8
- object :error do
9
- field :field, :string
10
- field :message, :string
11
- end
12
-
13
- object :sign_in_provider_result do
14
- field :error, :string
15
- field :url, :string
16
- end
17
-
18
- scalar :string_bool do
19
- parse fn
20
- %{value: v} ->
21
- case v do
22
- nil ->
23
- {:ok, false}
24
- true ->
25
- {:ok, true}
26
- false ->
27
- {:ok, false}
28
- 0 ->
29
- {:ok, false}
30
- 1 ->
31
- {:ok, true}
32
- "0" ->
33
- {:ok, false}
34
- "1" ->
35
- {:ok, true}
36
- "all" ->
37
- {:ok, "all"}
38
- end
39
- _ ->
40
- {:ok, nil}
41
- end
42
- end
43
- end
44
-
45
- defmodule Potionx.Types.JSON do
46
- @moduledoc """
47
- The Json scalar type allows arbitrary JSON values to be passed in and out.
48
- Requires `{ :jason, "~> 1.1" }` package: https://github.com/michalmuskala/jason
49
- """
50
- use Absinthe.Schema.Notation
51
-
52
- scalar :json, name: "Json" do
53
- description("""
54
- The `Json` scalar type represents arbitrary json string data, represented as UTF-8
55
- character sequences. The Json type is most often used to represent a free-form
56
- human-readable json string.
57
- """)
58
-
59
- serialize(&encode/1)
60
- parse(&decode/1)
61
- end
62
-
63
- @spec decode(Absinthe.Blueprint.Input.String.t()) :: {:ok, term()} | :error
64
- @spec decode(Absinthe.Blueprint.Input.Null.t()) :: {:ok, nil}
65
- defp decode(%Absinthe.Blueprint.Input.String{value: value}) do
66
- case Jason.decode(value) do
67
- {:ok, result} -> {:ok, result}
68
- _ -> :error
69
- end
70
- end
71
-
72
- defp decode(%Absinthe.Blueprint.Input.Null{}) do
73
- {:ok, nil}
74
- end
75
-
76
- defp decode(_) do
77
- :error
78
- end
79
-
80
- defp encode(value), do: value
81
- end
1
+ defmodule Potionx.Types do
2
+ use Absinthe.Schema.Notation
3
+
4
+ enum :sort_order do
5
+ value :asc
6
+ value :desc
7
+ end
8
+ object :error do
9
+ field :field, :string
10
+ field :message, :string
11
+ end
12
+
13
+ object :sign_in_provider_result do
14
+ field :error, :string
15
+ field :url, :string
16
+ end
17
+
18
+ scalar :string_bool do
19
+ parse fn
20
+ %{value: v} ->
21
+ case v do
22
+ nil ->
23
+ {:ok, false}
24
+ true ->
25
+ {:ok, true}
26
+ false ->
27
+ {:ok, false}
28
+ 0 ->
29
+ {:ok, false}
30
+ 1 ->
31
+ {:ok, true}
32
+ "0" ->
33
+ {:ok, false}
34
+ "1" ->
35
+ {:ok, true}
36
+ "all" ->
37
+ {:ok, "all"}
38
+ end
39
+ _ ->
40
+ {:ok, nil}
41
+ end
42
+ end
43
+ end
44
+
45
+ defmodule Potionx.Types.JSON do
46
+ @moduledoc """
47
+ The Json scalar type allows arbitrary JSON values to be passed in and out.
48
+ Requires `{ :jason, "~> 1.1" }` package: https://github.com/michalmuskala/jason
49
+ """
50
+ use Absinthe.Schema.Notation
51
+
52
+ scalar :json, name: "Json" do
53
+ description("""
54
+ The `Json` scalar type represents arbitrary json string data, represented as UTF-8
55
+ character sequences. The Json type is most often used to represent a free-form
56
+ human-readable json string.
57
+ """)
58
+
59
+ serialize(&encode/1)
60
+ parse(&decode/1)
61
+ end
62
+
63
+ @spec decode(Absinthe.Blueprint.Input.String.t()) :: {:ok, term()} | :error
64
+ @spec decode(Absinthe.Blueprint.Input.Null.t()) :: {:ok, nil}
65
+ defp decode(%Absinthe.Blueprint.Input.String{value: value}) do
66
+ case Jason.decode(value) do
67
+ {:ok, result} -> {:ok, result}
68
+ _ -> :error
69
+ end
70
+ end
71
+
72
+ defp decode(%Absinthe.Blueprint.Input.Null{}) do
73
+ {:ok, nil}
74
+ end
75
+
76
+ defp decode(_) do
77
+ :error
78
+ end
79
+
80
+ defp encode(value), do: value
81
+ end
changed lib/potionx/utils_ecto.ex
 
@@ -1,21 +1,21 @@
1
- defmodule Potionx.Utils.Ecto do
2
- @doc """
3
- Reduce results to an atom. Returns error if one of the results failed, otherwise
4
- returns ok.
5
- """
6
- def reduce_results(results) do
7
- Enum.reduce(results, {:ok, []}, fn res, acc ->
8
- case acc do
9
- {:ok, models} ->
10
- case res do
11
- {:ok, model} ->
12
- {:ok, models ++ [model]}
13
- err ->
14
- err
15
- end
16
- err ->
17
- err
18
- end
19
- end)
20
- end
1
+ defmodule Potionx.Utils.Ecto do
2
+ @doc """
3
+ Reduce results to an atom. Returns error if one of the results failed, otherwise
4
+ returns ok.
5
+ """
6
+ def reduce_results(results) do
7
+ Enum.reduce(results, {:ok, []}, fn res, acc ->
8
+ case acc do
9
+ {:ok, models} ->
10
+ case res do
11
+ {:ok, model} ->
12
+ {:ok, models ++ [model]}
13
+ err ->
14
+ err
15
+ end
16
+ err ->
17
+ err
18
+ end
19
+ end)
20
+ end
21
21
end
\ No newline at end of file
changed mix.exs
 
@@ -1,63 +1,63 @@
1
- defmodule Potionx.MixProject do
2
- use Mix.Project
3
-
4
- # Run "mix help compile.app" to learn about applications.
5
- def application do
6
- [
7
- extra_applications: [:logger]
8
- ]
9
- end
10
-
11
- # Run "mix help deps" to learn about dependencies.
12
- defp deps do
13
- [
14
- {:absinthe, ">= 1.6.0"},
15
- {:absinthe_plug, ">= 1.5.4"},
16
- {:absinthe_relay, ">= 1.5.1"},
17
- {:assent, ">= 0.1.23"},
18
- {:dataloader, ">= 1.0.9"},
19
- {:jason, ">= 1.3.0"},
20
- {:plug_cowboy, ">= 2.0.0"},
21
- {:redix, ">= 1.1.0"},
22
- {:typed_struct, "~> 0.2.1"},
23
- # {:dep_from_hexpm, "~> 0.3.0"},
24
- # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
25
- {:ecto_network, ">= 1.3.0", only: :test},
26
- {:ecto_sql, ">= 3.5.0"},
27
- {:ex_doc, "~> 0.22", only: :dev, runtime: false},
28
- {:phoenix, ">= 1.6.0"},
29
- {:postgrex, ">= 0.0.0", only: :test}
30
- ]
31
- end
32
-
33
- # Specifies which paths to compile per environment.
34
- defp elixirc_paths(:test), do: ["lib", "test/repo", "test/support"]
35
- defp elixirc_paths(_), do: ["lib"]
36
-
37
- defp package do
38
- [
39
- maintainers: ["Vince Roy", "Michael Demchuk"],
40
- licenses: ["MIT"],
41
- links: %{github: "https://github.com/PotionApps/potionx"},
42
- files:
43
- ~w(lib priv LICENSE.md mix.exs README.md .formatter.exs)
44
- ]
45
- end
46
-
47
- def project do
48
- [
49
- app: :potionx,
50
- version: "0.8.13",
51
- elixir: "~> 1.11",
52
- elixirc_paths: elixirc_paths(Mix.env()),
53
- package: package(),
54
- start_permanent: Mix.env() == :prod,
55
- deps: deps(),
56
- homepage_url: "https://www.potionapps.com",
57
- source_url: "https://github.com/PotionApps/potionx",
58
- description: """
59
- Potionx is a set of generators and functions that speeds up the process of setting up and deploying a full-stack application that uses Elixir with GraphQL for the server-side component and Vue for the frontend component.
60
- """
61
- ]
62
- end
63
- end
1
+ defmodule Potionx.MixProject do
2
+ use Mix.Project
3
+
4
+ # Run "mix help compile.app" to learn about applications.
5
+ def application do
6
+ [
7
+ extra_applications: [:logger]
8
+ ]
9
+ end
10
+
11
+ # Run "mix help deps" to learn about dependencies.
12
+ defp deps do
13
+ [
14
+ {:absinthe, ">= 1.6.0"},
15
+ {:absinthe_plug, ">= 1.5.4"},
16
+ {:absinthe_relay, ">= 1.5.1"},
17
+ {:assent, ">= 0.1.23"},
18
+ {:dataloader, ">= 1.0.9"},
19
+ {:jason, ">= 1.3.0"},
20
+ {:plug_cowboy, ">= 2.0.0"},
21
+ {:redix, ">= 1.1.0"},
22
+ {:typed_struct, "~> 0.2.1"},
23
+ # {:dep_from_hexpm, "~> 0.3.0"},
24
+ # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
25
+ {:ecto_network, ">= 1.3.0", only: :test},
26
+ {:ecto_sql, ">= 3.5.0"},
27
+ {:ex_doc, "~> 0.22", only: :dev, runtime: false},
28
+ {:phoenix, ">= 1.6.0"},
29
+ {:postgrex, ">= 0.0.0", only: :test}
30
+ ]
31
+ end
32
+
33
+ # Specifies which paths to compile per environment.
34
+ defp elixirc_paths(:test), do: ["lib", "test/repo", "test/support"]
35
+ defp elixirc_paths(_), do: ["lib"]
36
+
37
+ defp package do
38
+ [
39
+ maintainers: ["Vince Roy", "Michael Demchuk"],
40
+ licenses: ["MIT"],
41
+ links: %{github: "https://github.com/PotionApps/potionx"},
42
+ files:
43
+ ~w(lib priv LICENSE.md mix.exs README.md .formatter.exs)
44
+ ]
45
+ end
46
+
47
+ def project do
48
+ [
49
+ app: :potionx,
50
+ version: "0.8.14",
51
+ elixir: "~> 1.11",
52
+ elixirc_paths: elixirc_paths(Mix.env()),
53
+ package: package(),
54
+ start_permanent: Mix.env() == :prod,
55
+ deps: deps(),
56
+ homepage_url: "https://www.potionapps.com",
57
+ source_url: "https://github.com/PotionApps/potionx",
58
+ description: """
59
+ Potionx is a set of generators and functions that speeds up the process of setting up and deploying a full-stack application that uses Elixir with GraphQL for the server-side component and Vue for the frontend component.
60
+ """
61
+ ]
62
+ end
63
+ end
changed priv/templates/potion.gen.gql_for_model/app_schema.ex
 
@@ -1,28 +1,28 @@
1
- defmodule <%= module_name_graphql %>.Schema do
2
- use Potionx.Schema
3
-
4
- node interface do
5
- resolve_type fn
6
- _, _ ->
7
- nil
8
- end
9
- end
10
-
11
- def context(ctx) do
12
- Map.put(ctx, :loader, dataloader())
13
- end
14
-
15
- def dataloader do
16
- Dataloader.new
17
- end
18
-
19
- query do
20
-
21
- end
22
- mutation do
23
-
24
- end
25
- subscription do
26
-
27
- end
28
- end
1
+ defmodule <%= module_name_graphql %>.Schema do
2
+ use Potionx.Schema
3
+
4
+ node interface do
5
+ resolve_type fn
6
+ _, _ ->
7
+ nil
8
+ end
9
+ end
10
+
11
+ def context(ctx) do
12
+ Map.put(ctx, :loader, dataloader())
13
+ end
14
+
15
+ def dataloader do
16
+ Dataloader.new
17
+ end
18
+
19
+ query do
20
+
21
+ end
22
+ mutation do
23
+
24
+ end
25
+ subscription do
26
+
27
+ end
28
+ end
changed priv/templates/potion.gen.gql_for_model/collection.gql
 
@@ -1,35 +1,35 @@
1
- query <%= model_name_graphql_case %>Collection(
2
- $after: String,
3
- $before: String,
4
- $first: Int,
5
- $last: Int,
6
- $filters: <%= model_name %>Filters,
7
- $search: String
8
- ) {
9
- <%= model_name_graphql_case %>Collection(
10
- after: $after,
11
- before: $before,
12
- first: $first,
13
- filters: $filters,
14
- last: $last,
15
- search: $search
16
- ) {
17
- pageInfo {
18
- endCursor
19
- hasNextPage
20
- hasPreviousPage
21
- startCursor
22
- }
23
- count
24
- countBefore
25
- edges {
26
- node {
27
- __typename
28
- internalId
29
- <%= for field <- graphql_fields do %><%= field %>
30
- <% end %>
31
- }
32
- cursor
33
- }
34
- }
1
+ query <%= model_name_graphql_case %>Collection(
2
+ $after: String,
3
+ $before: String,
4
+ $first: Int,
5
+ $last: Int,
6
+ $filters: <%= model_name %>Filters,
7
+ $search: String
8
+ ) {
9
+ <%= model_name_graphql_case %>Collection(
10
+ after: $after,
11
+ before: $before,
12
+ first: $first,
13
+ filters: $filters,
14
+ last: $last,
15
+ search: $search
16
+ ) {
17
+ pageInfo {
18
+ endCursor
19
+ hasNextPage
20
+ hasPreviousPage
21
+ startCursor
22
+ }
23
+ count
24
+ countBefore
25
+ edges {
26
+ node {
27
+ __typename
28
+ internalId
29
+ <%= for field <- graphql_fields do %><%= field %>
30
+ <% end %>
31
+ }
32
+ cursor
33
+ }
34
+ }
35
35
}
\ No newline at end of file
changed priv/templates/potion.gen.gql_for_model/delete.gql
 
@@ -1,18 +1,18 @@
1
- mutation <%= model_name_graphql_case %>Delete(
2
- $filters: <%= model_name %>FiltersSingle
3
- ) {
4
- <%= model_name_graphql_case %>Delete(
5
- filters: $filters
6
- ) {
7
- errors
8
- errorsFields {
9
- field
10
- message
11
- }
12
- node {
13
- __typename
14
- <%= for field <- graphql_fields do %><%= field %>
15
- <% end %>
16
- }
17
- }
1
+ mutation <%= model_name_graphql_case %>Delete(
2
+ $filters: <%= model_name %>FiltersSingle
3
+ ) {
4
+ <%= model_name_graphql_case %>Delete(
5
+ filters: $filters
6
+ ) {
7
+ errors
8
+ errorsFields {
9
+ field
10
+ message
11
+ }
12
+ node {
13
+ __typename
14
+ <%= for field <- graphql_fields do %><%= field %>
15
+ <% end %>
16
+ }
17
+ }
18
18
}
\ No newline at end of file
changed priv/templates/potion.gen.gql_for_model/model_mock.ex
 
@@ -1,9 +1,9 @@
1
- defmodule <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock do
2
- def run do
3
- <%= mock %>
4
- end
5
-
6
- def run_patch do
7
- <%= mock_patch %>
8
- end
9
- end
1
+ defmodule <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock do
2
+ def run do
3
+ <%= mock %>
4
+ end
5
+
6
+ def run_patch do
7
+ <%= mock_patch %>
8
+ end
9
+ end
changed priv/templates/potion.gen.gql_for_model/mutation.gql
 
@@ -1,21 +1,21 @@
1
- mutation <%= model_name_graphql_case %>Mutation(
2
- $changes: <%= model_name %>Input,
3
- $filters: <%= model_name %>FiltersSingle
4
- ) {
5
- <%= model_name_graphql_case %>Mutation(
6
- changes: $changes,
7
- filters: $filters
8
- ) {
9
- errors
10
- errorsFields {
11
- field
12
- message
13
- }
14
- node {
15
- __typename
16
- internalId
17
- <%= for field <- graphql_fields do %><%= field %>
18
- <% end %>
19
- }
20
- }
1
+ mutation <%= model_name_graphql_case %>Mutation(
2
+ $changes: <%= model_name %>Input,
3
+ $filters: <%= model_name %>FiltersSingle
4
+ ) {
5
+ <%= model_name_graphql_case %>Mutation(
6
+ changes: $changes,
7
+ filters: $filters
8
+ ) {
9
+ errors
10
+ errorsFields {
11
+ field
12
+ message
13
+ }
14
+ node {
15
+ __typename
16
+ internalId
17
+ <%= for field <- graphql_fields do %><%= field %>
18
+ <% end %>
19
+ }
20
+ }
21
21
}
\ No newline at end of file
changed priv/templates/potion.gen.gql_for_model/mutations.ex
 
@@ -1,19 +1,19 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Mutations do
2
- use Absinthe.Schema.Notation
3
- alias <%= module_name_graphql %>.Resolver
4
-
5
- object :<%= model_name_snakecase %>_mutations do
6
- field :<%= model_name_snakecase %>_delete, type: :<%= model_name_snakecase %>_mutation_result do
7
- arg :filters, :<%= model_name_snakecase %>_filters_single
8
- middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
9
- resolve &Resolver.<%= model_name %>.delete/2
10
- end
11
-
12
- field :<%= model_name_snakecase %>_mutation, type: :<%= model_name_snakecase %>_mutation_result do
13
- arg :changes, :<%= model_name_snakecase %>_input
14
- arg :filters, :<%= model_name_snakecase %>_filters_single
15
- middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
16
- resolve &Resolver.<%= model_name %>.mutation/2
17
- end
18
- end
19
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Mutations do
2
+ use Absinthe.Schema.Notation
3
+ alias <%= module_name_graphql %>.Resolver
4
+
5
+ object :<%= model_name_snakecase %>_mutations do
6
+ field :<%= model_name_snakecase %>_delete, type: :<%= model_name_snakecase %>_mutation_result do
7
+ arg :filters, :<%= model_name_snakecase %>_filters_single
8
+ middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
9
+ resolve &Resolver.<%= model_name %>.delete/2
10
+ end
11
+
12
+ field :<%= model_name_snakecase %>_mutation, type: :<%= model_name_snakecase %>_mutation_result do
13
+ arg :changes, :<%= model_name_snakecase %>_input
14
+ arg :filters, :<%= model_name_snakecase %>_filters_single
15
+ middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
16
+ resolve &Resolver.<%= model_name %>.mutation/2
17
+ end
18
+ end
19
+ end
changed priv/templates/potion.gen.gql_for_model/mutations_test.exs
 
@@ -1,152 +1,152 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %>MutationTest do
2
- use <%= module_name_data %>.DataCase
3
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>
4
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock
5
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
6
-
7
- def prepare_ctx(ctx) do
8
- %{
9
- ctx |
10
- roles: [:admin],
11
- session: %{
12
- id: 1,
13
- user: %{
14
- id: 1,
15
- roles: [:admin]
16
- }
17
- }
18
- }
19
- end
20
-
21
- describe "<%= model_name_snakecase %> delete" do
22
- setup do
23
- ctx = %Potionx.Context.Service{
24
- changes: <%= model_name %>Mock.run(),
25
- } |> prepare_ctx
26
- {:ok, entry} = <%= model_name %>Service.mutation(ctx)
27
- {:ok, ctx: ctx, entry: entry}
28
- end
29
- test "deletes <%= model_name_snakecase %>", %{ctx: ctx, entry: entry} do
30
- Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Delete.gql")
31
- |> Absinthe.run(
32
- <%= module_name_graphql %>.Schema,
33
- context: ctx,
34
- variables: %{"filters" => %{"id" => entry.id}}
35
- )
36
- |> (fn {:ok, res} ->
37
- assert res.data["<%= model_name_graphql_case %>Delete"]["node"]["id"] ===
38
- Absinthe.Relay.Node.to_global_id(
39
- :<%= model_name_snakecase %>,
40
- entry.id,
41
- <%= module_name_graphql %>.Schema
42
- )
43
- end).()
44
- end
45
- end
46
-
47
- describe "<%= model_name_snakecase %> new mutation" do
48
- setup do
49
- ctx =
50
- %Potionx.Context.Service{
51
- changes: <%= model_name %>Mock.run() |> Map.delete(:id)
52
- } |> prepare_ctx
53
- {:ok, ctx: ctx}
54
- end
55
-
56
- test "creates <%= model_name_snakecase %>", %{ctx: ctx} do
57
- changes = Enum.map(ctx.changes, fn
58
- {k, v} when v === %{} -> {k, Jason.encode!(%{})}
59
- {k, v} -> {k, v}
60
- end)
61
- |> Enum.into(%{})
62
-
63
- Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
64
- |> Absinthe.run(
65
- <%= module_name_graphql %>.Schema,
66
- [
67
- context: ctx,
68
- variables: %{
69
- "changes" => Jason.decode!(Jason.encode!(changes))
70
- }
71
- ]
72
- )
73
- |> (fn {:ok, res} ->
74
- assert res.data["<%= model_name_graphql_case %>Mutation"]["node"]["id"]
75
- end).()
76
- end
77
- end
78
-
79
- describe "<%= model_name_snakecase %> invalid mutation/2" do
80
- setup do
81
- ctx =
82
- %Potionx.Context.Service{
83
- changes: %{},
84
- } |> prepare_ctx
85
- {:ok, ctx: ctx}
86
- end
87
-
88
- test "invalid <%= model_name_snakecase %> mutation", %{ctx: ctx} do
89
- Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
90
- |> Absinthe.run(
91
- <%= module_name_graphql %>.Schema,
92
- context: ctx,
93
- variables: %{
94
- "changes" => Jason.decode!(Jason.encode!(ctx.changes))
95
- }
96
- )
97
- |> (fn {:ok, res} ->
98
- assert res.data["<%= model_name_graphql_case %>Mutation"]["errorsFields"] |> Enum.at(0) |> Map.get("field")
99
- assert res.data["<%= model_name_graphql_case %>Mutation"]["errorsFields"] |> Enum.at(0) |> Map.get("message")
100
- end).()
101
- end
102
- end
103
-
104
- describe "<%= model_name_snakecase %> patch mutation/2" do
105
- setup do
106
- ctx =
107
- %Potionx.Context.Service{
108
- changes: <%= model_name %>Mock.run(),
109
- } |> prepare_ctx
110
- required_field =
111
- <%= model_name %>.changeset(%<%= model_name %>{}, %{})
112
- |> Map.get(:errors)
113
- |> Keyword.keys
114
- |> Enum.at(0)
115
- {:ok, entry} = <%= model_name %>Service.mutation(ctx)
116
- {:ok, ctx: ctx, entry: entry, required_field: required_field}
117
- end
118
-
119
- test "patches <%= model_name_snakecase %>", %{ctx: ctx, entry: entry, required_field: required_field} do
120
- changes =
121
- required_field && Map.put(%{}, to_string(required_field), <%= model_name %>Mock.run_patch()[required_field]) || %{}
122
-
123
- changes = Enum.map(changes, fn
124
- {k, v} when v === %{} -> {k, Jason.encode!(%{})}
125
- {k, v} -> {k, v}
126
- end)
127
- |> Enum.into(%{})
128
-
129
- Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
130
- |> Absinthe.run(
131
- <%= module_name_graphql %>.Schema,
132
- context: ctx,
133
- variables: %{
134
- "changes" => changes,
135
- "filters" => %{
136
- "id" => Absinthe.Relay.Node.to_global_id(
137
- :<%= model_name_snakecase %>,
138
- entry.id,
139
- <%= module_name_graphql %>.Schema
140
- )
141
- }
142
- }
143
- )
144
- |> (fn {:ok, res} ->
145
- assert res.data["<%= model_name_graphql_case %>Mutation"]["node"]["id"]
146
- if required_field do
147
- assert res.data["<%= model_name_graphql_case %>Mutation"]["node"][to_string(required_field)] === <%= model_name %>Mock.run_patch()[required_field]
148
- end
149
- end).()
150
- end
151
- end
152
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %>MutationTest do
2
+ use <%= module_name_data %>.DataCase
3
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>
4
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock
5
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
6
+
7
+ def prepare_ctx(ctx) do
8
+ %{
9
+ ctx |
10
+ roles: [:admin],
11
+ session: %{
12
+ id: 1,
13
+ user: %{
14
+ id: 1,
15
+ roles: [:admin]
16
+ }
17
+ }
18
+ }
19
+ end
20
+
21
+ describe "<%= model_name_snakecase %> delete" do
22
+ setup do
23
+ ctx = %Potionx.Context.Service{
24
+ changes: <%= model_name %>Mock.run(),
25
+ } |> prepare_ctx
26
+ {:ok, entry} = <%= model_name %>Service.mutation(ctx)
27
+ {:ok, ctx: ctx, entry: entry}
28
+ end
29
+ test "deletes <%= model_name_snakecase %>", %{ctx: ctx, entry: entry} do
30
+ Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Delete.gql")
31
+ |> Absinthe.run(
32
+ <%= module_name_graphql %>.Schema,
33
+ context: ctx,
34
+ variables: %{"filters" => %{"id" => entry.id}}
35
+ )
36
+ |> (fn {:ok, res} ->
37
+ assert res.data["<%= model_name_graphql_case %>Delete"]["node"]["id"] ===
38
+ Absinthe.Relay.Node.to_global_id(
39
+ :<%= model_name_snakecase %>,
40
+ entry.id,
41
+ <%= module_name_graphql %>.Schema
42
+ )
43
+ end).()
44
+ end
45
+ end
46
+
47
+ describe "<%= model_name_snakecase %> new mutation" do
48
+ setup do
49
+ ctx =
50
+ %Potionx.Context.Service{
51
+ changes: <%= model_name %>Mock.run() |> Map.delete(:id)
52
+ } |> prepare_ctx
53
+ {:ok, ctx: ctx}
54
+ end
55
+
56
+ test "creates <%= model_name_snakecase %>", %{ctx: ctx} do
57
+ changes = Enum.map(ctx.changes, fn
58
+ {k, v} when v === %{} -> {k, Jason.encode!(%{})}
59
+ {k, v} -> {k, v}
60
+ end)
61
+ |> Enum.into(%{})
62
+
63
+ Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
64
+ |> Absinthe.run(
65
+ <%= module_name_graphql %>.Schema,
66
+ [
67
+ context: ctx,
68
+ variables: %{
69
+ "changes" => Jason.decode!(Jason.encode!(changes))
70
+ }
71
+ ]
72
+ )
73
+ |> (fn {:ok, res} ->
74
+ assert res.data["<%= model_name_graphql_case %>Mutation"]["node"]["id"]
75
+ end).()
76
+ end
77
+ end
78
+
79
+ describe "<%= model_name_snakecase %> invalid mutation/2" do
80
+ setup do
81
+ ctx =
82
+ %Potionx.Context.Service{
83
+ changes: %{},
84
+ } |> prepare_ctx
85
+ {:ok, ctx: ctx}
86
+ end
87
+
88
+ test "invalid <%= model_name_snakecase %> mutation", %{ctx: ctx} do
89
+ Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
90
+ |> Absinthe.run(
91
+ <%= module_name_graphql %>.Schema,
92
+ context: ctx,
93
+ variables: %{
94
+ "changes" => Jason.decode!(Jason.encode!(ctx.changes))
95
+ }
96
+ )
97
+ |> (fn {:ok, res} ->
98
+ assert res.data["<%= model_name_graphql_case %>Mutation"]["errorsFields"] |> Enum.at(0) |> Map.get("field")
99
+ assert res.data["<%= model_name_graphql_case %>Mutation"]["errorsFields"] |> Enum.at(0) |> Map.get("message")
100
+ end).()
101
+ end
102
+ end
103
+
104
+ describe "<%= model_name_snakecase %> patch mutation/2" do
105
+ setup do
106
+ ctx =
107
+ %Potionx.Context.Service{
108
+ changes: <%= model_name %>Mock.run(),
109
+ } |> prepare_ctx
110
+ required_field =
111
+ <%= model_name %>.changeset(%<%= model_name %>{}, %{})
112
+ |> Map.get(:errors)
113
+ |> Keyword.keys
114
+ |> Enum.at(0)
115
+ {:ok, entry} = <%= model_name %>Service.mutation(ctx)
116
+ {:ok, ctx: ctx, entry: entry, required_field: required_field}
117
+ end
118
+
119
+ test "patches <%= model_name_snakecase %>", %{ctx: ctx, entry: entry, required_field: required_field} do
120
+ changes =
121
+ required_field && Map.put(%{}, to_string(required_field), <%= model_name %>Mock.run_patch()[required_field]) || %{}
122
+
123
+ changes = Enum.map(changes, fn
124
+ {k, v} when v === %{} -> {k, Jason.encode!(%{})}
125
+ {k, v} -> {k, v}
126
+ end)
127
+ |> Enum.into(%{})
128
+
129
+ Elixir.File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Mutation.gql")
130
+ |> Absinthe.run(
131
+ <%= module_name_graphql %>.Schema,
132
+ context: ctx,
133
+ variables: %{
134
+ "changes" => changes,
135
+ "filters" => %{
136
+ "id" => Absinthe.Relay.Node.to_global_id(
137
+ :<%= model_name_snakecase %>,
138
+ entry.id,
139
+ <%= module_name_graphql %>.Schema
140
+ )
141
+ }
142
+ }
143
+ )
144
+ |> (fn {:ok, res} ->
145
+ assert res.data["<%= model_name_graphql_case %>Mutation"]["node"]["id"]
146
+ if required_field do
147
+ assert res.data["<%= model_name_graphql_case %>Mutation"]["node"][to_string(required_field)] === <%= model_name %>Mock.run_patch()[required_field]
148
+ end
149
+ end).()
150
+ end
151
+ end
152
+ end
changed priv/templates/potion.gen.gql_for_model/queries.ex
 
@@ -1,21 +1,21 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Queries do
2
- use Absinthe.Schema.Notation
3
- use Absinthe.Relay.Schema.Notation, :modern
4
-
5
- object :<%= model_name_snakecase %>_queries do
6
- connection field :<%= model_name_snakecase %>_collection, node_type: :<%= model_name_snakecase %> do
7
- # :after, :before, :first, :last added by connection
8
- arg :filters, :<%= model_name_snakecase %>_filters
9
- arg :order, type: :sort_order, default_value: :asc
10
- arg :search, :string
11
- middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
12
- resolve &<%= module_name_graphql %>.Resolver.<%= model_name %>.collection/2
13
- end
14
-
15
- field :<%= model_name_snakecase %>_single, type: :<%= model_name_snakecase %> do
16
- arg :filters, :<%= model_name_snakecase %>_filters_single
17
- middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
18
- resolve &<%= module_name_graphql %>.Resolver.<%= model_name %>.one/2
19
- end
20
- end
21
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Queries do
2
+ use Absinthe.Schema.Notation
3
+ use Absinthe.Relay.Schema.Notation, :modern
4
+
5
+ object :<%= model_name_snakecase %>_queries do
6
+ connection field :<%= model_name_snakecase %>_collection, node_type: :<%= model_name_snakecase %> do
7
+ # :after, :before, :first, :last added by connection
8
+ arg :filters, :<%= model_name_snakecase %>_filters
9
+ arg :order, type: :sort_order, default_value: :asc
10
+ arg :search, :string
11
+ middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
12
+ resolve &<%= module_name_graphql %>.Resolver.<%= model_name %>.collection/2
13
+ end
14
+
15
+ field :<%= model_name_snakecase %>_single, type: :<%= model_name_snakecase %> do
16
+ arg :filters, :<%= model_name_snakecase %>_filters_single
17
+ middleware Potionx.Middleware.RolesAuthorization, [roles: [:admin]]
18
+ resolve &<%= module_name_graphql %>.Resolver.<%= model_name %>.one/2
19
+ end
20
+ end
21
+ end
changed priv/templates/potion.gen.gql_for_model/queries_test.exs
 
@@ -1,61 +1,61 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %>QueryTest do
2
- use <%= module_name_data %>.DataCase
3
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock
4
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
5
-
6
- describe "<%= model_name_snakecase %> collection and single" do
7
- setup do
8
- ctx = %Potionx.Context.Service{
9
- changes: <%= model_name %>Mock.run(),
10
- roles: [:admin],
11
- session: %{
12
- id: 1,
13
- user: %{
14
- id: 1,
15
- roles: [:admin]
16
- }
17
- }
18
- }
19
- {:ok, entry} = <%= model_name %>Service.mutation(ctx)
20
- {:ok, ctx: ctx, entry: entry}
21
- end
22
- test "returns collection of <%= model_name_snakecase %>", %{ctx: ctx} do
23
- File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Collection.gql")
24
- |> Absinthe.run(
25
- <%= module_name_graphql %>.Schema,
26
- context: ctx,
27
- variables: %{
28
- "first" => 15
29
- }
30
- )
31
- |> (fn {:ok, res} ->
32
- assert res.data["<%= model_name_graphql_case %>Collection"]["count"] === 1
33
- assert res.data["<%= model_name_graphql_case %>Collection"]["edges"] |> Enum.count === 1
34
- end).()
35
- end
36
- test "returns single <%= model_name_snakecase %>", %{ctx: ctx, entry: entry} do
37
- File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Single.gql")
38
- |> Absinthe.run(
39
- <%= module_name_graphql %>.Schema,
40
- context: ctx,
41
- variables: %{
42
- "filters" => %{
43
- "id" => Absinthe.Relay.Node.to_global_id(
44
- :<%= model_name_snakecase %>,
45
- entry.id,
46
- <%= module_name_graphql %>.Schema
47
- )
48
- }
49
- }
50
- )
51
- |> (fn {:ok, res} ->
52
- assert res.data["<%= model_name_graphql_case %>Single"]["id"] ===
53
- Absinthe.Relay.Node.to_global_id(
54
- :<%= model_name_snakecase %>,
55
- entry.id,
56
- <%= module_name_graphql %>.Schema
57
- )
58
- end).()
59
- end
60
- end
61
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %>QueryTest do
2
+ use <%= module_name_data %>.DataCase
3
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Mock
4
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
5
+
6
+ describe "<%= model_name_snakecase %> collection and single" do
7
+ setup do
8
+ ctx = %Potionx.Context.Service{
9
+ changes: <%= model_name %>Mock.run(),
10
+ roles: [:admin],
11
+ session: %{
12
+ id: 1,
13
+ user: %{
14
+ id: 1,
15
+ roles: [:admin]
16
+ }
17
+ }
18
+ }
19
+ {:ok, entry} = <%= model_name %>Service.mutation(ctx)
20
+ {:ok, ctx: ctx, entry: entry}
21
+ end
22
+ test "returns collection of <%= model_name_snakecase %>", %{ctx: ctx} do
23
+ File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Collection.gql")
24
+ |> Absinthe.run(
25
+ <%= module_name_graphql %>.Schema,
26
+ context: ctx,
27
+ variables: %{
28
+ "first" => 15
29
+ }
30
+ )
31
+ |> (fn {:ok, res} ->
32
+ assert res.data["<%= model_name_graphql_case %>Collection"]["count"] === 1
33
+ assert res.data["<%= model_name_graphql_case %>Collection"]["edges"] |> Enum.count === 1
34
+ end).()
35
+ end
36
+ test "returns single <%= model_name_snakecase %>", %{ctx: ctx, entry: entry} do
37
+ File.read!("shared/src/models/<%= context_name %>/<%= model_name %>/<%= model_name_graphql_case %>Single.gql")
38
+ |> Absinthe.run(
39
+ <%= module_name_graphql %>.Schema,
40
+ context: ctx,
41
+ variables: %{
42
+ "filters" => %{
43
+ "id" => Absinthe.Relay.Node.to_global_id(
44
+ :<%= model_name_snakecase %>,
45
+ entry.id,
46
+ <%= module_name_graphql %>.Schema
47
+ )
48
+ }
49
+ }
50
+ )
51
+ |> (fn {:ok, res} ->
52
+ assert res.data["<%= model_name_graphql_case %>Single"]["id"] ===
53
+ Absinthe.Relay.Node.to_global_id(
54
+ :<%= model_name_snakecase %>,
55
+ entry.id,
56
+ <%= module_name_graphql %>.Schema
57
+ )
58
+ end).()
59
+ end
60
+ end
61
+ end
changed priv/templates/potion.gen.gql_for_model/resolver.ex
 
@@ -1,91 +1,91 @@
1
- defmodule <%= module_name_graphql %>.Resolver.<%= model_name %> do
2
- alias <%= potion_name %>.Context.Service
3
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
4
- use Absinthe.Relay.Schema.Notation, :modern
5
-
6
- def collection(args, %{context: %Service{} = ctx}) do
7
- q = <%= model_name %>Service.query(ctx)
8
- count = <%= model_name %>Service.count(ctx)
9
- count_before = get_count_before(ctx, count)
10
-
11
- q
12
- |> Absinthe.Relay.Connection.from_query(
13
- &<%= module_name_data %>.Repo.all/1,
14
- ensure_first_page_is_full(args),
15
- [count: count]
16
- )
17
- |> case do
18
- {:ok, result} ->
19
- {
20
- :ok,
21
- Map.merge(
22
- result, %{
23
- count: count,
24
- count_before: count_before
25
- }
26
- )
27
- }
28
- err -> err
29
- end
30
- end
31
-
32
- def data do
33
- Dataloader.Ecto.new(<%= module_name_data %>.Repo, query: &<%= model_name %>Service.query/2)
34
- end
35
-
36
- def delete(_, %{context: %Service{} = ctx}) do
37
- <%= model_name %>Service.delete(ctx)
38
- |> case do
39
- {:ok, %{<%= model_name_atom %>: res}} -> {:ok, res}
40
- {:error, _, err, _} -> {:error, err}
41
- res -> res
42
- end
43
- end
44
-
45
- def ensure_first_page_is_full(args) do
46
- if Map.get(args, :before) do
47
- Absinthe.Relay.Connection.cursor_to_offset(args.before)
48
- |> elem(1)
49
- |> Kernel.<(args.last)
50
- |> if do
51
- %{
52
- first: args.last
53
- }
54
- else
55
- args
56
- end
57
- else
58
- args
59
- end
60
- end
61
-
62
- def get_count_before(ctx, count) do
63
- cond do
64
- not is_nil(ctx.pagination.after) ->
65
- Absinthe.Relay.Connection.cursor_to_offset(ctx.pagination.after)
66
- |> elem(1)
67
- not is_nil(ctx.pagination.before) ->
68
- Absinthe.Relay.Connection.cursor_to_offset(ctx.pagination.before)
69
- |> elem(1)
70
- |> Kernel.-(ctx.pagination.last)
71
- |> max(0)
72
- not is_nil(ctx.pagination.last) ->
73
- count - ctx.pagination.last
74
- true ->
75
- 0
76
- end
77
- end
78
-
79
- def mutation(_, %{context: %Service{} = ctx}) do
80
- <%= model_name %>Service.mutation(ctx)
81
- |> case do
82
- {:ok, %{<%= model_name_atom %>: res}} -> {:ok, res}
83
- {:error, _, err, _} -> {:error, err}
84
- res -> res
85
- end
86
- end
87
-
88
- def one(_, %{context: %Service{} = ctx}) do
89
- {:ok, <%= model_name %>Service.one(ctx)}
90
- end
91
- end
1
+ defmodule <%= module_name_graphql %>.Resolver.<%= model_name %> do
2
+ alias <%= potion_name %>.Context.Service
3
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service
4
+ use Absinthe.Relay.Schema.Notation, :modern
5
+
6
+ def collection(args, %{context: %Service{} = ctx}) do
7
+ q = <%= model_name %>Service.query(ctx)
8
+ count = <%= model_name %>Service.count(ctx)
9
+ count_before = get_count_before(ctx, count)
10
+
11
+ q
12
+ |> Absinthe.Relay.Connection.from_query(
13
+ &<%= module_name_data %>.Repo.all/1,
14
+ ensure_first_page_is_full(args),
15
+ [count: count]
16
+ )
17
+ |> case do
18
+ {:ok, result} ->
19
+ {
20
+ :ok,
21
+ Map.merge(
22
+ result, %{
23
+ count: count,
24
+ count_before: count_before
25
+ }
26
+ )
27
+ }
28
+ err -> err
29
+ end
30
+ end
31
+
32
+ def data do
33
+ Dataloader.Ecto.new(<%= module_name_data %>.Repo, query: &<%= model_name %>Service.query/2)
34
+ end
35
+
36
+ def delete(_, %{context: %Service{} = ctx}) do
37
+ <%= model_name %>Service.delete(ctx)
38
+ |> case do
39
+ {:ok, %{<%= model_name_atom %>: res}} -> {:ok, res}
40
+ {:error, _, err, _} -> {:error, err}
41
+ res -> res
42
+ end
43
+ end
44
+
45
+ def ensure_first_page_is_full(args) do
46
+ if Map.get(args, :before) do
47
+ Absinthe.Relay.Connection.cursor_to_offset(args.before)
48
+ |> elem(1)
49
+ |> Kernel.<(args.last)
50
+ |> if do
51
+ %{
52
+ first: args.last
53
+ }
54
+ else
55
+ args
56
+ end
57
+ else
58
+ args
59
+ end
60
+ end
61
+
62
+ def get_count_before(ctx, count) do
63
+ cond do
64
+ not is_nil(ctx.pagination.after) ->
65
+ Absinthe.Relay.Connection.cursor_to_offset(ctx.pagination.after)
66
+ |> elem(1)
67
+ not is_nil(ctx.pagination.before) ->
68
+ Absinthe.Relay.Connection.cursor_to_offset(ctx.pagination.before)
69
+ |> elem(1)
70
+ |> Kernel.-(ctx.pagination.last)
71
+ |> max(0)
72
+ not is_nil(ctx.pagination.last) ->
73
+ count - ctx.pagination.last
74
+ true ->
75
+ 0
76
+ end
77
+ end
78
+
79
+ def mutation(_, %{context: %Service{} = ctx}) do
80
+ <%= model_name %>Service.mutation(ctx)
81
+ |> case do
82
+ {:ok, %{<%= model_name_atom %>: res}} -> {:ok, res}
83
+ {:error, _, err, _} -> {:error, err}
84
+ res -> res
85
+ end
86
+ end
87
+
88
+ def one(_, %{context: %Service{} = ctx}) do
89
+ {:ok, <%= model_name %>Service.one(ctx)}
90
+ end
91
+ end
changed priv/templates/potion.gen.gql_for_model/schema.ex
 
@@ -1,2 +1,2 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %> do
2
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %> do
2
+ end
changed priv/templates/potion.gen.gql_for_model/service.ex
 
@@ -1,80 +1,80 @@
1
- defmodule <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service do
2
- alias <%= potion_name %>.Context.Service
3
- alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>
4
- alias <%= module_name_data %>.Repo
5
- import Ecto.Query
6
-
7
- def count(%Service{} = ctx) do
8
- from(item in query(ctx))
9
- |> select([i], count(i.id))
10
- |> exclude(:order_by)
11
- |> Repo.one!
12
- end
13
-
14
- def delete(%Service{} = ctx) do
15
- query(ctx)
16
- |> Repo.one
17
- |> case do
18
- nil -> {:error, "not_found"}
19
- entry ->
20
- entry
21
- |> Repo.delete
22
- end
23
- end
24
-
25
- def mutation(%Service{filters: %{id: id}} = ctx) when not is_nil(id) do
26
- query(ctx)
27
- |> Repo.one
28
- |> case do
29
- nil -> {:error, "not_found"}
30
- entry ->
31
- <%= model_name %>.changeset(entry, ctx.changes)
32
- |> Repo.update
33
- end
34
- end
35
- def mutation(%Service{} = ctx) do
36
- %<%= model_name %>{}
37
- |> <%= model_name %>.changeset(ctx.changes)
38
- |> Repo.insert
39
- end
40
-
41
- def one(%Service{} = ctx) do
42
- query(ctx)
43
- |> Repo.one
44
- end
45
-
46
- def query(%Service{} = ctx) do
47
- <%= model_name %>
48
- |> search(ctx)
49
- |> where(
50
- ^(
51
- ctx.filters
52
- |> Map.to_list
53
- )
54
- )
55
- |> order_by([desc: :id])
56
- end
57
- def query(q, _args), do: q
58
-
59
- @doc """
60
- A search function that searches all string columns by default.
61
- """
62
- def search(query, %Service{search: nil}), do: query
63
- def search(query, %Service{search: ""}), do: query
64
- def search(query, %Service{search: s}) do
65
- clauses =
66
- <%= model_name %>.__schema__(:fields)
67
- |> Enum.reduce(nil, fn field_name, query ->
68
- if (<%= model_name %>.__schema__(:type, field_name) === :string) do
69
- if (query === nil) do
70
- dynamic([p], ilike(field(p, ^field_name), ^"%#{s}%"))
71
- else
72
- dynamic([p], ilike(field(p, ^field_name), ^"%#{s}%") or ^query)
73
- end
74
- else
75
- query
76
- end
77
- end)
78
- from(query, where: ^clauses)
79
- end
80
- end
1
+ defmodule <%= module_name_data %>.<%= context_name %>.<%= model_name %>Service do
2
+ alias <%= potion_name %>.Context.Service
3
+ alias <%= module_name_data %>.<%= context_name %>.<%= model_name %>
4
+ alias <%= module_name_data %>.Repo
5
+ import Ecto.Query
6
+
7
+ def count(%Service{} = ctx) do
8
+ from(item in query(ctx))
9
+ |> select([i], count(i.id))
10
+ |> exclude(:order_by)
11
+ |> Repo.one!
12
+ end
13
+
14
+ def delete(%Service{} = ctx) do
15
+ query(ctx)
16
+ |> Repo.one
17
+ |> case do
18
+ nil -> {:error, "not_found"}
19
+ entry ->
20
+ entry
21
+ |> Repo.delete
22
+ end
23
+ end
24
+
25
+ def mutation(%Service{filters: %{id: id}} = ctx) when not is_nil(id) do
26
+ query(ctx)
27
+ |> Repo.one
28
+ |> case do
29
+ nil -> {:error, "not_found"}
30
+ entry ->
31
+ <%= model_name %>.changeset(entry, ctx.changes)
32
+ |> Repo.update
33
+ end
34
+ end
35
+ def mutation(%Service{} = ctx) do
36
+ %<%= model_name %>{}
37
+ |> <%= model_name %>.changeset(ctx.changes)
38
+ |> Repo.insert
39
+ end
40
+
41
+ def one(%Service{} = ctx) do
42
+ query(ctx)
43
+ |> Repo.one
44
+ end
45
+
46
+ def query(%Service{} = ctx) do
47
+ <%= model_name %>
48
+ |> search(ctx)
49
+ |> where(
50
+ ^(
51
+ ctx.filters
52
+ |> Map.to_list
53
+ )
54
+ )
55
+ |> order_by([desc: :id])
56
+ end
57
+ def query(q, _args), do: q
58
+
59
+ @doc """
60
+ A search function that searches all string columns by default.
61
+ """
62
+ def search(query, %Service{search: nil}), do: query
63
+ def search(query, %Service{search: ""}), do: query
64
+ def search(query, %Service{search: s}) do
65
+ clauses =
66
+ <%= model_name %>.__schema__(:fields)
67
+ |> Enum.reduce(nil, fn field_name, query ->
68
+ if (<%= model_name %>.__schema__(:type, field_name) === :string) do
69
+ if (query === nil) do
70
+ dynamic([p], ilike(field(p, ^field_name), ^"%#{s}%"))
71
+ else
72
+ dynamic([p], ilike(field(p, ^field_name), ^"%#{s}%") or ^query)
73
+ end
74
+ else
75
+ query
76
+ end
77
+ end)
78
+ from(query, where: ^clauses)
79
+ end
80
+ end
changed priv/templates/potion.gen.gql_for_model/single.gql
 
@@ -1,12 +1,12 @@
1
- query <%= model_name_graphql_case %>Single(
2
- $filters: <%= model_name %>FiltersSingle
3
- ) {
4
- <%= model_name_graphql_case %>Single(
5
- filters: $filters
6
- ) {
7
- __typename
8
- internalId
9
- <%= for field <- graphql_fields do %><%= field %>
10
- <% end %>
11
- }
1
+ query <%= model_name_graphql_case %>Single(
2
+ $filters: <%= model_name %>FiltersSingle
3
+ ) {
4
+ <%= model_name_graphql_case %>Single(
5
+ filters: $filters
6
+ ) {
7
+ __typename
8
+ internalId
9
+ <%= for field <- graphql_fields do %><%= field %>
10
+ <% end %>
11
+ }
12
12
}
\ No newline at end of file
changed priv/templates/potion.gen.gql_for_model/types.ex
 
@@ -1,6 +1,6 @@
1
- defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Types do
2
- use Absinthe.Schema.Notation
3
- use Absinthe.Relay.Schema.Notation, :modern
4
- import Absinthe.Resolution.Helpers
5
-
6
- end
1
+ defmodule <%= module_name_graphql %>.Schema.<%= model_name %>Types do
2
+ use Absinthe.Schema.Notation
3
+ use Absinthe.Relay.Schema.Notation, :modern
4
+ import Absinthe.Resolution.Helpers
5
+
6
+ end