Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More typesafety for GraphQL code #6167

Merged
merged 15 commits into from
Mar 21, 2022
Merged

Conversation

mattkrick
Copy link
Member

@mattkrick mattkrick commented Mar 4, 2022

Fix #4754

  • Run the following on the private schema & see the response times for various services
query {
  ping {
    rethinkdb
    postgres
  	redis
  }
}

Today, we don't get a lot of type safety when we create objects with graphql

  • We must remember to add types to the source
  • args are broken until we upgrade to v16
  • We have to remember to include the context
  • The return value of a resolver (e.g. {taskId}) isn't typed at all

So, i took a shot at what all the cool kids do these days.

  • The schema is created using an SDL. You write GraphQL & it just creates the objects for you. All that's left are the resolvers
  • codegen turns that SDL into types that you can apply to your resolvers
  • the source value of a resolver (the first param, e.g. {taskId}) is called a "Model", which can be thought of like a database object vs. an object returned from graphql. that separation is nice. I'm calling them Sources in this PR so the name matches what GraphQL calls them.

Reading material:

Would love to get some feedback!

@mattkrick mattkrick marked this pull request as draft March 4, 2022 02:50
codegen.json Outdated
"config": {
"mapperTypeSuffix": "Model",
"mappers": {
"PingPayload": "./ping#PingPayload"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

each model must be defined here, which is a bit annoying, but makes sense since it needs to be included in the built types

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is a bit annoying, but currently we mostly use any for it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would LOVE to remove this sharp edge.
the problem I face is that sometimes a Source comes from a database entity. Sometimes, it's just the return value from a mutation or query. I still haven't come up with a good enough rule to automate it. Once we do, we can write a script that automates this.


const schema = mergeSchemas({schemas: [legacySchema], typeDefs, resolvers: resolverMap})

const updateSchema = async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's probably a better way to create the schema SDL...

const legacySchema = new GraphQLSchema({query, mutation, types: rootTypes})

const typeDefs = loadFilesSync(
path.join(__PROJECT_ROOT__, 'packages/server/graphql/intranetSchema/typeDefs/*.graphql')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i really like this part. no more including files 1 by 1. just include the whole directory

return res ? duration : -1
}

const resolverMap: PingSuccessResolvers = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these aren't the best examples because they're so simple, but if you try to return something that doesn't resolve to a number it'll throw an error, which is great!

@@ -0,0 +1,7 @@
import {QueryResolvers} from '../resolvers/types'

export interface PingPayload {}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not the greatest example, but say we wanted to just return {taskId}, we could define that here

@@ -0,0 +1,10 @@
union PingPayload = ErrorPayload | PingSuccess
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is writing a file like this easier than the old way?

scripts/dev.js Show resolved Hide resolved
return collector
}

const resolverMap: Resolvers = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blimmer this is my attempt to emulate what you were doing with that codegen script

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my current thinking is to have 3 folders inside /graphql:

  • public
    • public/queries
    • public/mutations
    • public/subscriptions
    • public/types
  • private
    • private/queries
    • private/mutations
    • private/types
  • github
    • queries
    • mutations
  • gitlab
    • queries
    • mutations

the above won't touch the current hierarchy so we can gradually move over to this new format.

Copy link
Contributor

@blimmer blimmer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm submitting these initial ideas for now, going to shift back over to #6183 to complete that work, then come back over here to make additional progress.

codegen.json Show resolved Hide resolved
scripts/dev.js Show resolved Hide resolved
@@ -12,6 +12,17 @@
"schema": "packages/server/graphql/nestedSchema/GitLab/gitlabSchema.graphql",
"documents": "packages/server/graphql/nestedSchema/GitLab/queries/*.graphql",
"plugins": ["typescript", "typescript-operations", "add"]
},
"packages/server/graphql/intranetSchema/sdl/resolverTypes.ts": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: how do we get rid of this SDL folder?

@mattkrick mattkrick marked this pull request as ready for review March 17, 2022 17:56
@@ -0,0 +1,5186 @@
type Query {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 are we planning to split the schema up? Having everything in one giant file makes editing and reviewing harder.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Every new piece of business logic will have its own .graphql typeDef. We'll use a tool that merges all those typeDefs together into a generated schema that we won't have to look at or touch.

I finished the refactor & left all the legacy stuff in a big _legacy.graphql for the sake of time.
https://github.com/ParabolInc/parabol/pull/6228/files#diff-77adac95bcf72265d1a92f5b3932ccf2b8a6055e41e1baf2337fd7c5ee8fc830

@@ -0,0 +1,48 @@
import getRethink from '../../../../database/rethinkDriver'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I find the folder structure a bit confusing. Why do we put 'sdl' in there? Schema definition language is what will end up in 'typeDefs' folder

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed!
What do you think of this hierarchy?

inside /graphql:

  • public
    • public/queries
    • public/mutations
    • public/subscriptions
    • public/types
  • private
    • private/queries
    • private/mutations
    • private/types
  • github
    • queries
    • mutations
  • gitlab
    • queries
    • mutations

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -0,0 +1,386 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// Type definitions for webpack (module API) 1.16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 Wouldn't you put type definitions in d.ts files? How does this differ from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/webpack-env/index.d.ts ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i couldn't figure out how to import this without using the /// references pattern, which is deprecated. It also had some incorrect types, so i figure I'd just copy it over & remove the dep

scripts/dev.js Outdated
@@ -45,7 +45,10 @@ const dev = async (maybeInit) => {
await Promise.all([removeArtifacts()])
}
// Enable this if you're creating new github schemas
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 Comment is not up to date anymore

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

scripts/dev.js Outdated
// await generate(codegenSchema)
try {
await generate(codegenSchema)
} catch {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 Do we really just want to swallow all errors? If so, there should be a comment why

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

codegen.json Outdated
"config": {
"mapperTypeSuffix": "Model",
"mappers": {
"PingPayload": "./ping#PingPayload"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is a bit annoying, but currently we mostly use any for it.

Copy link
Contributor

@Dschoordsch Dschoordsch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. The source mapping in codegen.json is a bit annoying and having definitions spread across more files, but overall it's probably the way to go.

I wish there was something like typebox for GraphQL instead, but the GraphQL types don't contain enough information and with codegen we're on a more mainstream path.


const logins: QueryResolvers['logins'] = async (_source, args) => args

export default logins
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 You should delete graphql/intranetSchema/queries/logins.ts

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"plugins": ["typescript", "typescript-resolvers", "add"],
"config": {
"contextType": "../../graphql#InternalContext",
"mappers": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 We might want to set "defaultMapper": void or similar to avoid the case where people don't define it.
We could even define export type No_model_added_in_codegen_json = {} and set it as default mapper, just so developers are pointed to it more directly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like the idea of forcing an explicit mapper!
i'll play around with the subsequent PR to test void and never and see which one works best.

return res ? duration : -1
}

export type PingableServicesSource = Record<string, never>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 I think if there is no input, it should be undefined or void instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we return undefined then the graphql traversal won't continue, it only continues on objects.

To test: remove return {} from ping.ts.

@mattkrick
Copy link
Member Author

looks like master fails the build now, but not because of this.
gonna merge to master anyways & we can triage that later...

all issues that weren't fixed here (folder hierarchy, explicit Source types) will be fixed or trialed in the following PR: #6228

@mattkrick mattkrick merged commit fbc23f5 into master Mar 21, 2022
@mattkrick mattkrick deleted the feat/4754/pingpongwallawallabingbong branch March 21, 2022 17:16
@adaniels-parabol adaniels-parabol mentioned this pull request Mar 25, 2022
16 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Create GraphQL ping
3 participants