improvement: add schema codegen features & guide

This commit is contained in:
Zach Daniel 2024-08-16 14:35:21 -04:00
parent 1b9722a1fb
commit 3d1babb9f1
13 changed files with 6473 additions and 7 deletions

View 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

View file

@ -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

View file

@ -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
View 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

View file

@ -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

View file

@ -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 =

View file

@ -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
View 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

File diff suppressed because it is too large Load diff

3299
priv/schema.graphql Normal file

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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