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
|