visit
# import '../fragments/UserData.graphql'
query GetUser {
user(id: 1) {
# ...UserData
email
}
}
mutation ($email: String!) @rbac(requireMatchAll: [superadmin]) {
deleteManyMessages(where: { users: { is: { email: { equals: $email } } } }) {
count
}
}
type Query {
post(id: Int!): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
url: "/posts/{{ .arguments.id }}"
)
@mapping(mode: NONE)
country(code: String!): Country
@GraphQLDataSource(
host: "countries.trevorblades.com"
url: "/"
field: "country"
params: [
{
name: "code"
sourceKind: FIELD_ARGUMENTS
sourceName: "code"
variableType: "String!"
}
]
)
person(id: String!): Person
@WasmDataSource(
wasmFile: "./person.wasm"
input: "{\"id\":\"{{ .arguments.id }}\"}"
)
@mapping(mode: NONE)
httpBinPipeline: String
@PipelineDataSource(
configFilePath: "./httpbin_pipeline.json"
inputJSON: """
{
"url": "//httpbin.org/get",
"method": "GET"
}
"""
)
@mapping(mode: NONE)
}
type Product implements ProductItf & SkuItf
@join__implements(graph: INVENTORY, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "SkuItf")
@join__implements(graph: REVIEWS, interface: "ProductItf")
@join__type(graph: INVENTORY, key: "id")
@join__type(graph: PRODUCTS, key: "id")
@join__type(graph: PRODUCTS, key: "sku package")
@join__type(graph: PRODUCTS, key: "sku variation { id }")
@join__type(graph: REVIEWS, key: "id")
{
id: ID! @tag(name: "hi-from-products")
dimensions: ProductDimension @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS)
delivery(zip: String): DeliveryEstimates @join__field(graph: INVENTORY, requires: "dimensions { size weight }")
sku: String @join__field(graph: PRODUCTS)
name: String @join__field(graph: PRODUCTS)
package: String @join__field(graph: PRODUCTS)
variation: ProductVariation @join__field(graph: PRODUCTS)
createdBy: User @join__field(graph: PRODUCTS)
hidden: String @join__field(graph: PRODUCTS)
reviewsScore: Float! @join__field(graph: REVIEWS, override: "products")
oldField: String @join__field(graph: PRODUCTS)
reviewsCount: Int! @join__field(graph: REVIEWS)
reviews: [Review!]! @join__field(graph: REVIEWS)
}
I wonder how you debug this schema when one of the @join__field
or @join__type
directives are wrong. But even if we ignore the debugging part, the heavy use of directives makes it impossible to understand the schema at a glance.
type Product @key(fields: "id") {
id: ID!
name: String
price: Int
weight: Int
inStock: Boolean
shippingEstimate: Int @external
warehouse: Warehouse @requires(fields: "inStock")
}
The @key
, @external
, and @requires
directives might be a bit weird, but at least it was readable.
type Query {
hello: String!
@StaticDataSource(
data: "World!"
)
@mapping(mode: NONE)
staticBoolean: Boolean!
@StaticDataSource(
data: "true"
)
@mapping(mode: NONE)
nonNullInt: Int!
@StaticDataSource(
data: "1"
)
@mapping(mode: NONE)
nullableInt: Int
@StaticDataSource(
data: null
)
@mapping(mode: NONE)
foo: Foo!
@StaticDataSource(
data: "{\"bar\": \"baz\"}"
)
@mapping(mode: NONE)
}
As you can see in the example above, it would be quite handy if we could "compose" the @HttpJsonDataSource
directive with the @mapping
directive, because in most cases, we need them together.
In a language like Typescript, we could wrap the HttpJsonDataSource
function with the mapping
function:
const HttpJsonDataSourceWithMapping = (config: HttpJsonDataSourceConfig) => {
return mapping(HttpJsonDataSource(config));
};
type Query {
post(id: Int): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
url: "/posts/{{ .arguments.id }}"
)
}
In the URL, we use some weird templating syntax to inject the id
argument into the URL. How do we know how to correctly write this template when it's just a string? What if the id is null?
type Query {
post(id: Int): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
path: "/posts/{{ default 0 .arguments.id }}"
)
}
type Query {
country(code: String!): Country
@GraphQLDataSource(
host: "countries.trevorblades.com"
url: "/"
field: "country"
params: [
{
name: "code"
sourceKind: FIELD_ARGUMENTS
sourceName: "code"
variableType: "String!"
}
]
)
}
In this example, we have to "apply" the code
argument to the GraphQL data source. We have to reference it by name (name: "code"
), but this is also just a string without any type safety. Because we need to be able to apply some "mapping" logic, we also have to specify the sourceName
and variableType
, which are also just strings.
type Product implements ProductItf & SkuItf
@join__implements(graph: INVENTORY, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "SkuItf")
@join__implements(graph: REVIEWS, interface: "ProductItf")
@join__type(graph: INVENTORY, key: "id")
@join__type(graph: PRODUCTS, key: "id")
@join__type(graph: PRODUCTS, key: "sku package")
@join__type(graph: PRODUCTS, key: "sku variation { id }")
@join__type(graph: REVIEWS, key: "id")
{
id: ID! @tag(name: "hi-from-products")
dimensions: ProductDimension @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS)
delivery(zip: String): DeliveryEstimates @join__field(graph: INVENTORY, requires: "dimensions { size weight }")
sku: String @join__field(graph: PRODUCTS)
name: String @join__field(graph: PRODUCTS)
package: String @join__field(graph: PRODUCTS)
variation: ProductVariation @join__field(graph: PRODUCTS)
createdBy: User @join__field(graph: PRODUCTS)
hidden: String @join__field(graph: PRODUCTS)
reviewsScore: Float! @join__field(graph: REVIEWS, override: "products")
oldField: String @join__field(graph: PRODUCTS)
reviewsCount: Int! @join__field(graph: REVIEWS)
reviews: [Review!]! @join__field(graph: REVIEWS)
}
Take a look at lines 8,9 and 14. We have to specify key
arguments to define joins between subgraphs. If you look closely, you'll see that the value in the string argument is a SelectionSet.
extend type Product @key(fields: "id") {
id: ID! @external
inStock: Boolean!
}
This is a simple one. We're defining a key for the Product type. Again, the value is a SelectionSet, although it's just a single field, but still not autocompletion or type-safety. Ideally, the IDE could tell us that allowed inputs for the fields
argument, which leads to the root cause of the problem.
If we were using a language with proper support for generics, like TypeScript (surprise), the @key
directive could inherit meta information from the Product
type it's attached to. This way, we could make the fields
argument above type-safe.
type Review @model {
id: ID!
rating: Int! @default(value: 5)
published: Boolean @default(value: false)
status: Status @default(value: PENDING_REVIEW)
}
enum Status {
PENDING_REVIEW
APPROVED
}
We can see that the @default
directive is used multiple times to set default values for different fields. The problem is that this is actually an invalid GraphQL. The argument value
cannot be of type Int
, Boolean
and Status
at the same time.
Multiple problems lead to this issue. First, directive locations are very limited. You can define that a directive is allowed on the location FIELD_DEFINITION
, but you cannot specify that it should only be allowed on Int
fields.
But even if we could do that, it would still be ambiguous because we'd have to define multiple @default
directives for different types. So, ideally, we could leverage some sort of Polymorphism to define a single @default
directive that works for all types. Unfortunately, GraphQL doesn't support this use case and never might.
type Review @model {
id: ID!
rating: Int! @defaultInt(value: 5)
published: Boolean @defaultBoolean(value: false)
status: Status @defaultStatus(value: PENDING_REVIEW)
}
This might now be a valid GraphQL Schema, but there's another problem stemming from this approach. We cannot enforce that the user puts @defaultInt
on an Int
field. There's no constraint available in the GraphQL Schema language to enforce this. TypeScript can easily do this.
type Query {
scriptExample(message: String!): JSON
@rest(
endpoint: "//httpbin.org/anything"
method: POST
ecmascript: """
function bodyPOST(s) {
let body = JSON.parse(s);
body.ExtraMessage = get("message");
return JSON.stringify(body);
}
function transformREST(s) {
let out = JSON.parse(s);
out.CustomMessage = get("message");
return JSON.stringify(out);
}
"""
)
}
type Query {
anonymous: [Customer]
@rest (endpoint: "//api.com/customers",
transforms: [
{pathpattern: ["[]","name"], editor: "drop"}])
known: [Customer]
@rest (endpoint: "//api.com/customers")
}
This reminds me a lot of XSLT. If you're not familiar with XSLT, it's a language to transform XML documents. The problem with this example is that we're trying to use GraphQL as a transformation language. If you want to filter something, you can do so very easily with map
or filter
in TypeScript/Javascript. GraphQL was never designed to be used as a language to transform data.
I'm not sure how great of a developer experience this is. I'd probably want type-safety and auto-completion for the transforms
argument. Then, how do I debug this "code"? How do I write tests for this?
This whole approach feels like re-inventing an ESB with GraphQL.
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="//www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html>
<body>
<h2>My CD Collection</h2>
<table border="1">
<tr bgcolor="#9acd32">
<th>Title</th>
<th>Artist</th>
</tr>
<xsl:for-each select="catalog/cd">
<tr>
<td><xsl:value-of select="title"/></td>
<td><xsl:value-of select="artist"/></td>
</tr>
</xsl:for-each>
</table>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
type Mutation {
addCustomerById(id: ID!, name: String!, email: String!): Customer
@dbquery(
type: "mysql"
table: "customer"
dml: INSERT
configuration: "mysql_config"
schema: "schema_name"
)
}
access:
policies:
- type: Query
rules:
- condition: PREDICATE
name: name of rule
fields: [ fieldname ... ]
const helloWorld = "Hello World";
const lower = helloWorld.toLowerCase(); // "hello world"
type Query { helloWorld: String}
Let's imagine our GraphQL server allows the use of a @lowerCase
directive. We can now use it like this:
type Query { helloWorld: String @lowerCase}
What's the difference between the two examples? The helloWorld
object in TypeScript is recognized as a string, so we can call toLowerCase
on it. It's very obvious that we can call this method because it's attached to the string type.
In GraphQL, there are no "methods" we can call on a field. We can attach Directives to fields, but this is not obvious. Additionally, the @lowerCase
directive only makes sense on a string field, but GraphQL doesn't allow us to limit the usage of a directive to a specific type. What seems simple when there's just a single directive can become quite complex when you have 10, 20, or even more directives.
To conclude this section, the implementation of a directive usually carries a number of rules that cannot be expressed in the GraphQL schema. E.g. the GraphQL specification doesn't allow us to limit the @lowerCase
directive to string fields. This means, linters won't work, autocompletion won't work properly, and validation will also not be able to detect these errors. Instead, the detection of the misuse of a directive will be deferred to runtime. With TypeScript, we're catching these errors at compile time. With tsc --watch --noEmit
, we can even catch these errors while we're writing the code.