We try to leverage GraphQL as a query language for our API. Maybe we're able to reduce complexity a bit with that (a lot of repetition in CRUD endpoints, a lot of data "massaging", missing authorizations / validations).
We're using ApolloGraphQL as the runtime implementation, TypeGraphQL to automatically turn Typescript types into GraphQL Schemas, and TypeGraphQL-Prisma to automatically generate simple CRUD Mutations from the Prisma (database) Schema. Prisma is our ORM thus npx prisma introspect
needs to be run once in a while to keep the schemas in sync.
To rebuild the Prisma runtime and autogenerated TypeGraphQL types (found in /graphql/generated/
) run npx prisma generate
.
Please DO NOT manually change the /graphql/generated
folder. If changes are needed, have a look at the Resolver Enhance Maps, or write your own Resolvers / Fields outside of that folder.
The GraphQL graph can be explored when visiting https://[backend-host]/apollo
which shows an interactive UI. Add { "authorization": "[SECRET ACCESS TOKEN]" }
to the HTTP Headers field, then you should be able to perform a query like:
query {
pupils(where: { firstname: { contains: "M" } }) {
id
firstname
lastname
}
}
We generally use camelCase if no external constraints apply.
Entities (top level resolvers) are autogenerated through Prisma in snake_case, we do not change that for consistency
and also follow it in associations. Resolvers that apply additional filtering should be prefixed by the superset (e.g. pupils
, pupilsActivated
, pupilsSearch
).
Mutations are unfortunately not associated to an entity technically, however we can still associate them semantically
by prefixing mutations by the main entity (e.g. pupilActivate
).
Mutations that create
, update
or delete
an entity should be named as such.
Mutations shall be placed in /graphql/[entity]/mutations.ts
, field resolvers in /graphql/[entity]/fields.ts
.
The classes shall be named Mutate[Entity]Resolver
and ExtendedFields[Entity]Resolver
.
If an error is thrown inside a resolver or a mutation an internal server error response is sent to the GraphQL client,
thus error handling is generally not needed. Error messages should generally not contain any secrets.
Use existing Error Classes from common/util/error.ts
for logical errors and graphql/error.ts
for GraphQL specific errors to classify errors correctly,
and expose them properly to users (everything else will be classified as "Internal Server Error" and not exposed to the user).
Mutations that modify an entity shall have the entity's primary key as the first parameters.
If the entity does not exist, the mutation should throw (in /graphql/util.ts
there are helpers to do exactly that).
Unfortunately Mutations cannot be "void" but have to return a value,
thus mutations which cannot return anything useful return a boolean and end with return true
.
All resolvers and mutations must be annotated with @Authorized
to ensure they're not accessible by anyone. To release a resolver to anyone explicitly, use @Authorized(Role.UNAUTHENTICATED)
.
For autogenerated entities this is done in /graphql/authorizations.ts
.
Associations should be annotated with @LimitEstimated
to ensure that the backend does not time out or run out of memory,
or if the association as high cardinality, it should accept the parameters take
and skip
implementing pagination and should be annotated with @LimitedQuery
.
Don't change anything in graphql/generated/*
(except for regenerating Prisma changes).
To mitigiate account enumeration attacks and the alike, every resolver that (indirectly) exposes some user information (i.e. whether an account with a certain email address or not)
should be rate limited. This can be done by annotating it with @RateLimit("Name", /* limit */ 100 /* in */ 5000 /* ms */)
. The rate limit applies per resolver per IP Address,
and is only kept in memory (so a restart will reset it).
As we expose a relatively mighty interface through GraphQL, users could potentially write GraphQL queries that produce so large result sets that they put heavy load on our systems.
To prevent this, before running queries, we briefly estimate how large the result set will be and abort queries that exceed a certain threshold.
For that, annotate one-to-many query resolvers that have a take
parameter with @LimitedQuery()
, this will use the limit passed to take
as an upper estimation for the cardinality,
for field resolvers use @LimitEstimated(/* estimation */ 10)
and give a brief estimation. The limits will then be multiplied along all paths in a GraphQL query,
i.e. subcoursesPublic(take: 100) { id }
might be okay as it only returns a maximum of 100 entries, but subcoursesPublic(take: 100) { participants { firstname }}
might not be okay,
as for every of the 100 subcourses we again return ~10 participants, resulting in 1000 results.