mirror of
https://github.com/ash-project/ash_graphql.git
synced 2024-09-19 12:53:40 +12:00
improvement: add schema codegen features & guide
This commit is contained in:
parent
1b9722a1fb
commit
3d1babb9f1
13 changed files with 6473 additions and 7 deletions
27
documentation/topics/sdl-file.md
Normal file
27
documentation/topics/sdl-file.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Using the SDL File
|
||||
|
||||
By passing the `generate_sdl_file` to `use AshGraphql.Schema`, AshGraphql will generate
|
||||
a schema file when you run `mix ash.codegen`.
|
||||
|
||||
> ### Ensure your schema is up to date, gitignored, or not generated {: .info}
|
||||
>
|
||||
> We suggest first adding `mix ash.codegen --check` to your CI/CD pipeline to
|
||||
> ensure the schema is always up-to-date. Alternatively you can add the file
|
||||
> to your `.gitignore`, or you can remove the `generate_sdl_file` option to skip
|
||||
> generating the file.
|
||||
|
||||
Some things that you can use this SDL file for:
|
||||
|
||||
## Documentation
|
||||
|
||||
The schema file itself represents your entire GraphQL API definition, and examining it can be very useful.
|
||||
|
||||
## Code Generation
|
||||
|
||||
You can use tools like [GraphQL codegen](https://the-guild.dev/graphql/codegen) to generate a client
|
||||
for your GraphQL API.
|
||||
|
||||
## Validating Changes
|
||||
|
||||
Use the SDL file to check for breaking changes in your schema, especially if you are exposing a public API.
|
||||
A plug and play github action for this can be found here: https://the-guild.dev/graphql/inspector/docs/products/action
|
|
@ -29,7 +29,11 @@ end
|
|||
|
||||
#### Setting up your schema
|
||||
|
||||
If you don't have an absinthe schema, you can create one just for ash. Replace `helpdesk` in the examples with your own application name.
|
||||
If you don't have an absinthe schema, you can create one just for ash.
|
||||
Replace `helpdesk` in the examples with your own application name.
|
||||
|
||||
See [the SDL file guide](/documentation/topics/sdl-file.md) for more information on using the SDL file,
|
||||
or remove the `generate_sdl_file` option to skip generating it on calls to `mix ash.codegen`.
|
||||
|
||||
in `lib/helpdesk/schema.ex`
|
||||
|
||||
|
@ -38,7 +42,9 @@ defmodule Helpdesk.GraphqlSchema do
|
|||
use Absinthe.Schema
|
||||
|
||||
# Add your domains here
|
||||
use AshGraphql, domains: [Your.Domains]
|
||||
use AshGraphql,
|
||||
domains: [Your.Domains],
|
||||
generate_sdl_file: "priv/schema.graphql"
|
||||
|
||||
query do
|
||||
# Custom absinthe queries can be placed here
|
||||
|
@ -56,7 +62,6 @@ defmodule Helpdesk.GraphqlSchema do
|
|||
end
|
||||
```
|
||||
|
||||
|
||||
#### Connect your schema
|
||||
|
||||
##### Using Phoenix
|
||||
|
|
|
@ -44,6 +44,7 @@ defmodule AshGraphql do
|
|||
quote bind_quoted: [
|
||||
domains: opts[:domains],
|
||||
domain: opts[:domain],
|
||||
generate_sdl_file: opts[:generate_sdl_file],
|
||||
action_middleware: opts[:action_middleware] || [],
|
||||
define_relay_types?: Keyword.get(opts, :define_relay_types?, true),
|
||||
relay_ids?: Keyword.get(opts, :relay_ids?, false),
|
||||
|
@ -98,6 +99,16 @@ defmodule AshGraphql do
|
|||
|> Enum.reverse()
|
||||
|> List.update_at(0, fn {domain, resources, _} -> {domain, resources, true} end)
|
||||
|
||||
@generate_sdl_file generate_sdl_file
|
||||
|
||||
@doc false
|
||||
def generate_sdl_file do
|
||||
@generate_sdl_file
|
||||
end
|
||||
|
||||
@doc false
|
||||
def ash_graphql_schema?, do: true
|
||||
|
||||
@ash_resources Enum.flat_map(domains, &elem(&1, 1))
|
||||
ash_resources = @ash_resources
|
||||
@all_domains Enum.map(domains, &elem(&1, 0))
|
||||
|
|
92
lib/codegen.ex
Normal file
92
lib/codegen.ex
Normal file
|
@ -0,0 +1,92 @@
|
|||
defmodule AshGraphql.Codegen do
|
||||
@moduledoc false
|
||||
|
||||
def generate_sdl_file(schema, opts) do
|
||||
target = schema.generate_sdl_file()
|
||||
|
||||
case Mix.Tasks.Absinthe.Schema.Sdl.generate_schema(%Mix.Tasks.Absinthe.Schema.Sdl.Options{
|
||||
schema: schema,
|
||||
filename: target
|
||||
}) do
|
||||
{:ok, contents} ->
|
||||
if opts[:check?] do
|
||||
target_contents = File.read!(target)
|
||||
|
||||
if String.trim(target_contents) != String.trim(contents) do
|
||||
raise "Generated SDL file for #{} does not match existing file. Please run `mix ash.codegen` to generate the new file."
|
||||
end
|
||||
else
|
||||
File.write!(target, contents)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def schemas do
|
||||
apps =
|
||||
if Code.ensure_loaded?(Mix.Project) do
|
||||
if apps_paths = Mix.Project.apps_paths() do
|
||||
apps_paths |> Map.keys() |> Enum.sort()
|
||||
else
|
||||
[Mix.Project.config()[:app]]
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
apps()
|
||||
|> Stream.concat(apps)
|
||||
|> Stream.uniq()
|
||||
|> Task.async_stream(
|
||||
fn app ->
|
||||
app
|
||||
|> :application.get_key(:modules)
|
||||
|> case do
|
||||
:undefined ->
|
||||
[]
|
||||
|
||||
{_, mods} ->
|
||||
mods
|
||||
|> List.wrap()
|
||||
|> Enum.filter(&ash_graphql_schema?/1)
|
||||
end
|
||||
end,
|
||||
timeout: :infinity
|
||||
)
|
||||
|> Stream.map(&elem(&1, 1))
|
||||
|> Stream.flat_map(& &1)
|
||||
|> Stream.uniq()
|
||||
|> Enum.to_list()
|
||||
end
|
||||
|
||||
defp ash_graphql_schema?(module) do
|
||||
Code.ensure_compiled!(module)
|
||||
function_exported?(module, :ash_graphql_schema?, 0) && module.ash_graphql_schema?()
|
||||
end
|
||||
|
||||
Code.ensure_loaded!(Mix.Project)
|
||||
|
||||
if function_exported?(Mix.Project, :deps_tree, 0) do
|
||||
# for our app, and all dependency apps, we want to find extensions
|
||||
# the benefit of not just getting all loaded applications is that this
|
||||
# is actually a surprisingly expensive thing to do for every single built
|
||||
# in application for elixir/erlang. Instead we get anything w/ a dependency on ash or spark
|
||||
# this could miss things, but its unlikely. And if it misses things, it actually should be
|
||||
# fixed in the dependency that is relying on a transitive dependency :)
|
||||
defp apps do
|
||||
Mix.Project.deps_tree()
|
||||
|> Stream.filter(fn {_, nested_deps} ->
|
||||
Enum.any?(nested_deps, &(&1 == :spark || &1 == :ash))
|
||||
end)
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
end
|
||||
else
|
||||
defp apps do
|
||||
Logger.warning(
|
||||
"Mix.Project.deps_tree/0 not available, falling back to loaded_applications/0. Upgrade to Elixir 1.15+ to make this *much* faster."
|
||||
)
|
||||
|
||||
:application.loaded_applications()
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -118,7 +118,10 @@ defmodule AshGraphql.Igniter do
|
|||
schema_name,
|
||||
"""
|
||||
use Absinthe.Schema
|
||||
use AshGraphql, domains: #{inspect(domains)}
|
||||
|
||||
use AshGraphql,
|
||||
domains: #{inspect(domains)},
|
||||
generate_sdl_file: "priv/schema.graphql"
|
||||
|
||||
import_types Absinthe.Plug.Types
|
||||
|
||||
|
|
|
@ -452,6 +452,16 @@ defmodule AshGraphql.Resource do
|
|||
%{module: __MODULE__, location: %{file: env.file, line: env.line}}
|
||||
end
|
||||
|
||||
def codegen(argv) do
|
||||
schemas = AshGraphql.Codegen.schemas()
|
||||
|
||||
check? = "--check" in argv
|
||||
|
||||
for schema <- schemas, schema.generate_sdl_file() do
|
||||
AshGraphql.Codegen.generate_sdl_file(schema, check?: check?)
|
||||
end
|
||||
end
|
||||
|
||||
# sobelow_skip ["DOS.StringToAtom"]
|
||||
def install(igniter, module, Ash.Resource, _path, _argv) do
|
||||
type =
|
||||
|
|
1
mix.exs
1
mix.exs
|
@ -59,6 +59,7 @@ defmodule AshGraphql.MixProject do
|
|||
"documentation/tutorials/getting-started-with-graphql.md",
|
||||
"documentation/topics/authorize-with-graphql.md",
|
||||
"documentation/topics/handle-errors.md",
|
||||
"documentation/topics/sdl-file.md",
|
||||
"documentation/topics/use-enums-with-graphql.md",
|
||||
"documentation/topics/use-json-with-graphql.md",
|
||||
"documentation/topics/use-subscriptions-with-graphql.md",
|
||||
|
|
333
priv/relay_ids.graphql
Normal file
333
priv/relay_ids.graphql
Normal file
|
@ -0,0 +1,333 @@
|
|||
schema {
|
||||
mutation: RootMutationType
|
||||
query: RootQueryType
|
||||
}
|
||||
|
||||
"The result of the :assign_posts mutation"
|
||||
type AssignPostsResult {
|
||||
"The successful result of the mutation"
|
||||
result: User
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input AssignPostsInput {
|
||||
name: String
|
||||
postIds: [ID!]
|
||||
}
|
||||
|
||||
"The result of the :create_user mutation"
|
||||
type CreateUserResult {
|
||||
"The successful result of the mutation"
|
||||
result: User
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
name: String
|
||||
}
|
||||
|
||||
input UserFilterName {
|
||||
isNil: Boolean
|
||||
eq: String
|
||||
notEq: String
|
||||
in: [String]
|
||||
lessThan: String
|
||||
greaterThan: String
|
||||
lessThanOrEqual: String
|
||||
greaterThanOrEqual: String
|
||||
}
|
||||
|
||||
input UserFilterId {
|
||||
isNil: Boolean
|
||||
eq: ID
|
||||
notEq: ID
|
||||
in: [ID!]
|
||||
lessThan: ID
|
||||
greaterThan: ID
|
||||
lessThanOrEqual: ID
|
||||
greaterThanOrEqual: ID
|
||||
}
|
||||
|
||||
input UserFilterInput {
|
||||
and: [UserFilterInput!]
|
||||
or: [UserFilterInput!]
|
||||
not: [UserFilterInput!]
|
||||
id: UserFilterId
|
||||
name: UserFilterName
|
||||
posts: PostFilterInput
|
||||
}
|
||||
|
||||
type User implements Node {
|
||||
id: ID!
|
||||
name: String
|
||||
posts(
|
||||
"How to sort the records in the response"
|
||||
sort: [PostSortInput]
|
||||
|
||||
"A filter to limit the results"
|
||||
filter: PostFilterInput
|
||||
|
||||
"The number of records to return."
|
||||
limit: Int
|
||||
|
||||
"The number of records to skip."
|
||||
offset: Int
|
||||
): [Post!]!
|
||||
}
|
||||
|
||||
"The result of the :create_resource mutation"
|
||||
type CreateResourceResult {
|
||||
"The successful result of the mutation"
|
||||
result: ResourceWithNoPrimaryKeyGet
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input CreateResourceInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type ResourceWithNoPrimaryKeyGet implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
posts(
|
||||
"How to sort the records in the response"
|
||||
sort: [PostSortInput]
|
||||
|
||||
"A filter to limit the results"
|
||||
filter: PostFilterInput
|
||||
|
||||
"The number of records to return."
|
||||
limit: Int
|
||||
|
||||
"The number of records to skip."
|
||||
offset: Int
|
||||
): [Post!]!
|
||||
}
|
||||
|
||||
"The result of the :delete_post mutation"
|
||||
type DeletePostResult {
|
||||
"The record that was successfully deleted"
|
||||
result: Post
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
"The result of the :assign_author mutation"
|
||||
type AssignAuthorResult {
|
||||
"The successful result of the mutation"
|
||||
result: Post
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input AssignAuthorInput {
|
||||
text: String
|
||||
authorId: ID
|
||||
}
|
||||
|
||||
"The result of the :update_post mutation"
|
||||
type UpdatePostResult {
|
||||
"The successful result of the mutation"
|
||||
result: Post
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input UpdatePostInput {
|
||||
text: String
|
||||
authorId: ID
|
||||
}
|
||||
|
||||
"The result of the :simple_create_post mutation"
|
||||
type SimpleCreatePostResult {
|
||||
"The successful result of the mutation"
|
||||
result: Post
|
||||
|
||||
"Any errors generated, if the mutation failed"
|
||||
errors: [MutationError!]!
|
||||
}
|
||||
|
||||
input SimpleCreatePostInput {
|
||||
text: String
|
||||
authorId: ID
|
||||
}
|
||||
|
||||
enum PostSortField {
|
||||
ID
|
||||
TEXT
|
||||
AUTHOR_ID
|
||||
}
|
||||
|
||||
"A keyset page of :post"
|
||||
type KeysetPageOfPost {
|
||||
"Total count on all pages"
|
||||
count: Int
|
||||
|
||||
"The records contained in the page"
|
||||
results: [Post!]
|
||||
|
||||
"The first keyset in the results"
|
||||
startKeyset: String
|
||||
|
||||
"The last keyset in the results"
|
||||
endKeyset: String
|
||||
}
|
||||
|
||||
input PostFilterAuthorId {
|
||||
isNil: Boolean
|
||||
eq: ID
|
||||
notEq: ID
|
||||
in: [ID]
|
||||
lessThan: ID
|
||||
greaterThan: ID
|
||||
lessThanOrEqual: ID
|
||||
greaterThanOrEqual: ID
|
||||
}
|
||||
|
||||
input PostFilterText {
|
||||
isNil: Boolean
|
||||
eq: String
|
||||
notEq: String
|
||||
in: [String]
|
||||
lessThan: String
|
||||
greaterThan: String
|
||||
lessThanOrEqual: String
|
||||
greaterThanOrEqual: String
|
||||
}
|
||||
|
||||
input PostFilterId {
|
||||
isNil: Boolean
|
||||
eq: ID
|
||||
notEq: ID
|
||||
in: [ID!]
|
||||
lessThan: ID
|
||||
greaterThan: ID
|
||||
lessThanOrEqual: ID
|
||||
greaterThanOrEqual: ID
|
||||
}
|
||||
|
||||
input PostFilterInput {
|
||||
and: [PostFilterInput!]
|
||||
or: [PostFilterInput!]
|
||||
not: [PostFilterInput!]
|
||||
id: PostFilterId
|
||||
text: PostFilterText
|
||||
authorId: PostFilterAuthorId
|
||||
author: UserFilterInput
|
||||
}
|
||||
|
||||
input PostSortInput {
|
||||
order: SortOrder
|
||||
field: PostSortField!
|
||||
}
|
||||
|
||||
type Post implements Node {
|
||||
id: ID!
|
||||
text: String
|
||||
authorId: ID
|
||||
author: User
|
||||
}
|
||||
|
||||
"A relay node"
|
||||
interface Node {
|
||||
"A unique identifier"
|
||||
id: ID!
|
||||
}
|
||||
|
||||
enum SortOrder {
|
||||
DESC
|
||||
DESC_NULLS_FIRST
|
||||
DESC_NULLS_LAST
|
||||
ASC
|
||||
ASC_NULLS_FIRST
|
||||
ASC_NULLS_LAST
|
||||
}
|
||||
|
||||
"An error generated by a failed mutation"
|
||||
type MutationError {
|
||||
"The human readable error message"
|
||||
message: String
|
||||
|
||||
"A shorter error message, with vars not replaced"
|
||||
shortMessage: String
|
||||
|
||||
"Replacements for the short message"
|
||||
vars: Json
|
||||
|
||||
"An error code for the given error"
|
||||
code: String
|
||||
|
||||
"The field or fields that produced the error"
|
||||
fields: [String!]
|
||||
}
|
||||
|
||||
type RootQueryType {
|
||||
"Retrieves a Node from its global id"
|
||||
node(
|
||||
"The Node unique identifier"
|
||||
id: ID!
|
||||
): Node!
|
||||
|
||||
getPost(
|
||||
"The id of the record"
|
||||
id: ID!
|
||||
): Post
|
||||
|
||||
postLibrary(
|
||||
"How to sort the records in the response"
|
||||
sort: [PostSortInput]
|
||||
|
||||
"A filter to limit the results"
|
||||
filter: PostFilterInput
|
||||
|
||||
"The number of records to return from the beginning. Maximum 250"
|
||||
first: Int
|
||||
|
||||
"Show records before the specified keyset."
|
||||
before: String
|
||||
|
||||
"Show records after the specified keyset."
|
||||
after: String
|
||||
|
||||
"The number of records to return to the end. Maximum 250"
|
||||
last: Int
|
||||
): KeysetPageOfPost
|
||||
|
||||
getResourceByName(
|
||||
"The id of the record"
|
||||
id: ID!
|
||||
|
||||
name: String
|
||||
): ResourceWithNoPrimaryKeyGet
|
||||
|
||||
getUser(
|
||||
"The id of the record"
|
||||
id: ID!
|
||||
): User
|
||||
}
|
||||
|
||||
type RootMutationType {
|
||||
simpleCreatePost(input: SimpleCreatePostInput): SimpleCreatePostResult!
|
||||
updatePost(id: ID!, input: UpdatePostInput): UpdatePostResult!
|
||||
assignAuthor(id: ID!, input: AssignAuthorInput): AssignAuthorResult!
|
||||
deletePost(id: ID!): DeletePostResult!
|
||||
createResource(input: CreateResourceInput!): CreateResourceResult!
|
||||
createUser(input: CreateUserInput): CreateUserResult!
|
||||
assignPosts(id: ID!, input: AssignPostsInput): AssignPostsResult!
|
||||
}
|
||||
|
||||
"""
|
||||
The `Json` scalar type represents arbitrary json string data, represented as UTF-8
|
||||
character sequences. The Json type is most often used to represent a free-form
|
||||
human-readable json string.
|
||||
"""
|
||||
scalar Json
|
2685
priv/root_level_errors.graphql
Normal file
2685
priv/root_level_errors.graphql
Normal file
File diff suppressed because it is too large
Load diff
3299
priv/schema.graphql
Normal file
3299
priv/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,7 @@ defmodule AshGraphql.Test.RelayIds.Schema do
|
|||
|
||||
@domains [AshGraphql.Test.RelayIds.Domain]
|
||||
|
||||
use AshGraphql, domains: @domains, relay_ids?: true
|
||||
use AshGraphql, domains: @domains, relay_ids?: true, generate_sdl_file: "priv/relay_ids.graphql"
|
||||
|
||||
query do
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ defmodule AshGraphql.Test.RootLevelErrorsSchema do
|
|||
|
||||
@domains [AshGraphql.Test.RootLevelErrorsDomain]
|
||||
|
||||
use AshGraphql, domains: @domains
|
||||
use AshGraphql, domains: @domains, generate_sdl_file: "priv/root_level_errors.graphql"
|
||||
|
||||
query do
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@ defmodule AshGraphql.Test.Schema do
|
|||
|
||||
@domains [AshGraphql.Test.Domain, AshGraphql.Test.OtherDomain]
|
||||
|
||||
use AshGraphql, domains: @domains
|
||||
use AshGraphql, domains: @domains, generate_sdl_file: "priv/schema.graphql"
|
||||
|
||||
query do
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue