Package Exports
- json-make-ts
- json-make-ts/dist/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (json-make-ts) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
gql-ts
Enables dynamic query features for your REST. Just like GraphQL but REST. How about using:
GET https://your.rest.api/orders?$fields=id,amount,items.length&customer_id=123&$sort=createdAt:ASC&limit=10
Instead of:
POST https://your.rest.api/orders
{
query {
order(customer_id: 123, orderBy: { createdAt: asc }, limit: 10) {
id,
amount,
items {
length
}
}
}
}
Or even more f*ck:
POST https://your.rest.api/orders/get_by_customer_for_screen_abc
{
"customer_id": "123"
}
GraphQL is too verbose, heavy and 100% depends on their huge libs. I just love the idea of having a dynamic endpoint that clients can customize their query.
It's the best match if you already have a REST application and want to and dynamic query feature
Installation
- This is for typescript only. So we need typescript
- reflect-metadata must be turned on (
tsconfig.json
)
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
- Install via
npm
or[yarn](https://www.npmjs.com/package/yarn)
:
npm install --save gql-ts
Getting started
Assume we have a user model stored in MongoDB:
interface IUser {
_id: ObjectId;
name: string;
age: number;
phoneNumber: string;
address: string;
email: string;
}
const UserModel = mongodb.collection<IUser>('user')
To start with, we define a corresponding GQL model:
@GQLObject("user")
export class GQLUser extends GQLModel<IUser, GQLUser> {
@GQLField()
_id: string;
@GQLField()
name: string;
@GQLField()
age: number;
@GQLField()
phoneNumber: string;
@GQLField()
address: string;
@GQLField()
email: string;
}
Add a static resolver method to this class to handle queries. For now, just returns all users:
@GQLRootResolver()
static rootResolve(query: GQLQuery) {
return UserModel.find().toArray()
}
Next, add this model to GQLGlobal. It's the pre-defined object to manage GQL's models:
// Call this function somewhere in your code, just make sure it's triggered once:
function initGQL() {
// This script to handle ObjecId -> string conversion
GQLU.Parsers.unshift((gql, spec, val) => {
if (spec.rawType == GQLBaseType.STRING && ObjectId.isValid(val)) {
return `${val}`;
}
});
GQLGlobal.add(GQLUser);
}
Finally, let's use it in our controller, for example an express
routing handler:
app.get('/users', function (req, res) {
const query = GQLGlobal.queryFromHttpQuery(q, GQLUser);
query.resolve().then(users => res.send(users))
})
Now we can query to the /users
endpoint dynamically:
# To get all users
GET https://localhost/users?$fields=*
# To get only users' name and phone
GET https://localhost/users?$fields=name,phoneNumber
Model definition
Quick Example:
@GQLObject("user")
export class GQLUser extends GQLModel<IUser, GQLUser> {
@GQLField()
_id: string;
@GQLResolver({ matches: () => true })
static async rootResolve(query: GQLQuery) {
return []
}
}
Model declaration:
A GQL model class must be decorated with GQLObject
and extends from GQLModel
class:
GQLObject
is the decoration for GQL model class. It has only one argument:name
: Name of the model, cannot be duplicated with other models
GQLModel<DataModel, GQLModelClass>
is the base class of every GQL model. It requires 2 generic arguments:DataModel
: class or interface of the raw data modelGQLModelClass
: the current class itself
Model components
There are 3 main components in a model:
- GQLField: Defines data fields in a model and their properties
- GQLResolver: Functions that handle query logic. How to retrieve the data
- GQLMapper: Functions that convert (format) data object to GQL ojbect
GQLField
:
Field decorator, define a field in a model, the only argument is the options of the field IGQLFieldOptions
:
type: () => GQLType
(Optional): a function that returns the type of the field, can be one value of enumGQLBaseType
or another GQL model. Default is the field's defined type. Note: It must be explicitly declared if it's not primitive type.dataName: string
(Optional): Name of the associated field in data object. Default is the field name.tags: string[]
(Optional): Tags of the field, only used in customized logic. Default is empty.autoSelect: boolean
(Optional): Whether this field is selected in wildcard (*
) selection or not. Default istrue
if the field type is primitive, otherwise isfalse
.
GQLResolver
:
Function decorator, indicate functions that handle queries:
priority: number
: A model can have multiple resolvers, use this option to indicate their ordermatches: (GQLFilter) => boolean
: A function to verify if this resolver is able to handle the query or not. Usually, it use GQLU.byFields helper function to apply required & optional fields logic.
A resolver function receives only one arguments, it's the GQLQuery.
GQLMapper
:
Usually, all fields will be mapped automatically from data object to GQL object. GQLMapper
can be used in case of customization or association.
Example: Customize mapping for total
field:
@GQLObject("transaction")
export class GQLTransaction extends GQLModel<ITransaction, GQLTransaction> {
@GQLField()
_id: string;
@GQLField({})
price: number;
@GQLField()
quantity: number;
@GQLField()
total: number;
@GQLMapper({ fields: ['total'], addRawFields: ['price', 'quantity'] })
static async totalMapping(query: GQLQuery, transactions: GQLTransaction[]) {
transactions.forEach(tr => tr.total = tr.raw.price * tr.raw.quantity)
return transactions
}
}
Code samples
Some code samples for common use cases:
Resolve query logic
// In this example, we handle query for field `_id`
@GQLResolver({ matches: GQLU.byFields(['_id'], []) })
static async rootResolve(query: GQLQuery) {
// Get value of the query fields if there is
const ids = query.filter.get('_id').batch()
.filter(id => ObjectID.isValid(id))
.map(id => new ObjectID(id));
// construct db query object, GQLU.notEmpty will omit all empty fields
const q = GQLU.notEmpty({
_id: ids.length > 0 ? {$in: ids} : null
});
return await UserModel.find(q, { projection: query.QueryFields })
}
Resolve with pagination & sort
// Resolver for 2 fields (optional) `_id`, `phone`
@GQLResolver({ matches: GQLU.byFields([], ['_id', 'phoneNumber']) })
static rootResolve(query: GQLQuery) {
const ids = query.filter.get('_id').batch().filter((id: string) => ObjectID.isValid(id)).map((id: string) => new ObjectID(id));
const phones = query.filter.get('phoneNumber').batch()
// construct db query object
const q = GQLU.notEmpty({
_id: ids.length > 0 ? {$in: ids} : null,
phoneNumber: phones.length > 0 ? {$in: phones} : null
});
// handle pagination
if (query.pagination.from?._id) {
_.set(q, '_id.$gt', new ObjectId(query.pagination.from?._id))
}
if (query.pagination.to?._id) {
_.set(q, '_id.$lt', new ObjectId(query.pagination.to?._id))
}
const cursor = UserModel.find(q, { projection: query.QueryFields });
// handle order by
const sort = query.sort;
if (!_.isEmpty(sort)) {
cursor.sort(sort.fields.map(f => ([f.field, f.OrderNumber])));
}
// handle limit
if (_.isNumber(query.pagination?.limit)) {
cursor.limit(query.pagination?.limit)
}
return cursor.toArray()
}
Too complicated ? Usually I write a helper function and use it for every resolver:
@GQLResolver({ matches: GQLU.byFields([], ['_id', 'phoneNumber']) })
static async rootResolve(query: GQLQuery) {
const ids = query.filter.get('_id').batch()
.filter(id => ObjectID.isValid(id))
.map(id => new ObjectID(id));
const phones = query.filter.get('phoneNumber').batch()
// construct db query object
const q = GQLU.notEmpty({
_id: ids.length > 0 ? {$in: ids} : null,
phoneNumber: phones.length > 0 ? {$in: phones} : null
});
return await gqlMongoQuery(GQLUser, query, UserModel, q);
}
The helper function, this is for mongodb but you can modify it with to db driver easily:
function gqlMongoQuery<T>(gqlModel, gqlQuery: GQLQuery, mgCollection: mongodb.Collection<T>, mgQuery: any) {
const gql = gqlQuery.gql
const spec = GQLGlobal.get(gqlModel);
_.keys(gqlQuery.pagination.from)
.map(k => spec.getKey(k))
.filter(ks => ks != null && !_.isEmpty(gqlQuery.pagination.from[ks.key]))
.forEach(ks => {
const val = gqlQuery.pagination.from[ks.key]
const qVal = mongodb.ObjectID.isValid(val) ? new mongodb.ObjectID(val) : GQLU.gqlParse(gql, ks, val)
_.set(mgQuery, `${ks.key}.$gt`, qVal)
})
_.keys(gqlQuery.pagination.to)
.map(k => spec.getKey(k))
.filter(ks => ks != null && !_.isEmpty(gqlQuery.pagination.to[ks.key]))
.forEach(ks => {
const val = gqlQuery.pagination.to[ks.key]
const qVal = mongodb.ObjectID.isValid(val) ? new mongodb.ObjectID(val) : GQLU.gqlParse(gql, ks, val)
_.set(mgQuery, `${ks.key}.$lt`, qVal)
})
const cursor = mgCollection.find(mgQuery).project(GQLU.arrToObj(gqlQuery.QueryFields, f => f, f => 1));
const sort = gqlQuery.sort;
if (!hera.isEmpty(sort)) {
cursor.sort(sort.fields.map(f => ([f.field, f.OrderNumber])));
}
if (_.isNumber(gqlQuery?.pagination?.limit)) {
cursor.limit(gqlQuery?.pagination?.limit)
}
return cursor.toArray()
}
Some queries with pagination and sorting:
# To get all users
GET https://localhost/users?$fields=*
# To get user by id
GET https://localhost/users?$fields=*&_id=609ffe100000000000000000
# To get only users with pagination
GET https://localhost/users?$fields=*&$from=_id:609ffe100000000000000000&$limit=20&$sort=_id:ASC
Association mapping
There might be associations in the model and we should setup custom mapper for nested query:
// Assume that we have 2 associated GQL objects User and Company:
@GQLObject('company')
class GQLCompnay extends GQLModel<ICompany, GQLCompany> {
@GQLField()
_id: string;
@GQLField()
name: string;
@GQLResolver({matches: GQLU.byFields([], ['_id'])})
static async rootResolve(query: GQLQuery) {
const ids = query.filter.get('_id').batch()
.filter(id => ObjectID.isValid(id))
.map(id => new ObjectID(id));
// construct db query object
const q = GQLU.notEmpty({
_id: ids.length > 0 ? {$in: ids} : null
});
return await gqlMongoQuery(GQLCompany, query, CompanyModel, q);
}
}
@GQLObject('user')
class GQLUser extends GQLModel<IUser, GQLUser> {
@GQLField()
_id: string
// a user will belong to a company, indicated by field: `company_id`
@GQLField({type: () => GQLCompany, dataName: 'company_id'})
company: GQLCompany
@GQLResolver({matches: GQLU.byFields([], ['_id'])})
static async rootResolve(query: GQLQuery) {
// ... resolve by `_id`, just like above
}
}
We've defined a reference for company
field in GQLUser
, but it's not define how exactly the data is mapped, we have to create an explicit mapper for it:
class GQLUser extends GQLModel<IUser, GQLUser> {
// ...
@GQLMapper({fields: ['company']})
static async companyMapping(query: GQLQuery, users: GQLUsers[]) {
// get the list of associated company ids
const companyIds = _.uniq(users.map(u => u.raw.company_id))
// retreive the query and add filter for those company ids
const subQuery = query.select.get('company')?.subQuery || query.emptyQuery(GQLCompany)
subQuery.filter.add(new GQLFieldFilter('_id', companyIds));
subQuery.select.addRawField('_id');
// get the companies:
const companies = await subQuery.resolve();
// mapping data
users.forEach(u => u.company = companies.find(c => c.raw._id == u.raw.company_id)
return users
}
}
Now we can select nested company from the user query:
GET https://localhost/users?$fields=*,company._id,company.name
Still too complicated ? We have a helper decorator to handle that mapping called GQLFieldRevMapping (sorry for the bad name). The case above become much simpler:
class GQLUser extends GQLModel<IUser, GQLUser> {
// ...
@GQLField({type: () => GQLCompany, dataName: 'company_id'})
@GQLFieldRevMapping({queryField: '_id'}) // <- here, just this line instead of a long mapper
company: GQLCompany
}
Meta resolver
Sometimes, we need to retrieve other information beside of the data (eg: count
number of matched objects, next
, prev
link for pagination...). We called them metadata
:
@GQLObject('user')
class GQLUser extends GQLModel<IUser, GQLUser> {
@GQLField()
_id: string
@GQLField()
company_id: string;
@GQLResolver({matches: GQLU.byFields([], ['_id', 'company'])})
static async rootResolve(query: GQLQuery) {
const q = this.makeDbQuery(query)
return await gqlMongoQuery(GQLUser, query, UserModel, q)
}
@GQLMetaResolver({field: 'count'})
static async metaCountResolve(query: GQLQuery) {
const q = this.makeDbQuery(query)
return await UserModel.count(q)
}
static makeDbQuery(query: GQLQuery) {
const ids = query.filter.get('_id').batch()
.filter(id => ObjectID.isValid(id))
.map(id => new ObjectID(id));
const companyIds = query.filter.get('company_id').batch()
return GQLU.notEmpty({
_id: ids.length > 0 ? {$in: ids} : null,
company_id: companyIds.length > 0 ? {$in: companyIds} : null
})
}
}
In above example, we defined a metadata
field called count
that return the number of matched users for the query. Now we can query it from the HTTP request:
GET https://localhost/users?$fields=*&company_id=1&$meta=count
Permission filter
... To be added. Check Whitelist / blacklist filters
Other objects
GQLUtils
GQLU
is an utilities class used in the lib. It has many useful functions that might be very helpful during applying this lib:
GQLU.notEmpty(data: any, isEmpty: Function, deep: boolean)
: Returns a new object that filtered out all empty fields.data
: the object to be filterisEmpty
: function that check if an object is empty or not (default:GQLU.isEmpty
)deep
: Deep or shallow filter (default:false
)
GQLU.isEmpty(data: any)
: Checks if a data is empty or notisEmpty(obj?: any): boolean { return ((obj == null || obj === NaN || obj === false) || (isString(obj) && obj.length == 0) || ((obj instanceof Array) && obj.length == 0) || ((obj instanceof Object) && Object.keys(obj).length == 0)); }
GQLU.whiteListSelect(query: GQLQuery, ...whiteList: string[])
: ThrowsGQLUnauthorizedQuery
if the query select a field that not in the passed whitelistGQLU.blackListSelect(query: GQLQuery, ...blacklist: string[])
: Same as above, but for blacklistGQLU.whiteListFilter(query: GQLQuery, ...whitelist: string[])
: Same as above, but for filter instead of selectGQLU.blackListFilter(query: GQLQuery, ...blacklist: string[])
: Same as above, for blacklist filterGQLU.requireFilter(query: GQLQuery, ...requireds: string[])
: Same as above, the query must have filter on all passed fieldsGQLU.byFields(requiredFields: string[], optionalFields?: string[])
: Returns a matching function forGQLQuery
requiredFields
: Returnfalse
if the query filter doesn't have query on one of these fieldsoptionalFields
: Returnfalse
if the query filter have at least one field that is not in this list orrequiredFields
list.
GQLGlobal
GQLGlobal
is an instance of class GQL
. It's a repository that manage all GQL models. It means, in order to use a GQL model, it has to be added into a GQL
object. Usually, using GQLGLobal
is enough. But we could have multiple GQL
object in some advance situations.
GQLQuery
GQLQuery
contains all information of a query. It includes:
select: GQLSelect
fields: GQLFieldSelect[]
: List of selected fieldsfield: string
: Field nametype: GQLType
: Type of the fieldsubQuery: GQLQuery
: Sub query of that field if there is
rawFields: string[]
: List of raw fields. Raw fields is fields should be retrieved from the raw data objects (usually selected from database in resolver)- while normal fields is fields will be responsed from the GQLget(field: string)
: Get select info of a field or null if there's noneadd(...fields: string[])
: Add fields into selectionaddRawField(...fields: string[])
: Add fields into raw selection
filter: GQLFilter
filters: GQLFieldFilter[]
: List of filtered fieldsfield: string
: Name of the fieldfirst(): string
: Returns the value of the filterbatch(): string[]
: return the list of the filter values
pagination: GQLPagination
Contains pagination informationfrom: {[field: string]: string}
: Dictionary contains lower bound infosto: {[field: string]: string}
: Dictionary contains upper bound infoslimit: number
: Limit sizeoffset: number
: Offset / skip number
sort: GQLSort
: Sort / Orderby informationfields: GQLSortField[]
: List of sorted fieldsfield: string
: field nameorder: 'ASC' | 'DESC'
OrderNumber: number
1
forASC
and-1
forDESC
meta: GQLMetaSelect
: Metadata informatfields: string[]
: List of selected metadata fields
resolve(): Promise<any[]>
: Resolve the query, returns matched & processed GQL objectsresolveMeta(): Promise<any>
: Resolve query's metadata. Returns an objects contains selected metadatahasMeta: boolean
: Whether a query has metadata or notemptyQuery(model: GQLModel)
: returns an empty query for a specified modelsQueryFields: string[]
List of field that must be retrieved in data objects. Usually it's fields that have to selected from database query in resolver.
Decorators
Some useful decorators that helps writing GQLModel much simpler:
@GQLRootResolver
: If your model only have 1 resolver, use this decorator instead@GQLIdenticalMapping
: Instead of using defaultParser
ormapper
. The field value will be copied identcally from data object@GQLFieldRevMapping
: Use to mapping association between modelstargetType: GQLModel
: Type of the target model. Default is the type of the field.queryField: string
: Name of the field in target model that is associated with the current field. Default is'id'
extractField: string
: name of the field to get mapping value from the source objects. Default is name of the current field. Advance usecase only.extractor: (obj) => any
: Function that extracts the mapping values from the source objects. Default is getting value of theextractField
. Advance usecase only.rawField: string
: name of the raw field to be added in the target object. Default is thequeryField
. Advance usecase only.mappingFilter(sourceObject, targetObject) => boolean
: Function determining should we map a targetObject into the sourceObject. Default is just==
comparison between values ofextractField
fromsourceObject
andqueryField
oftargetObject
mappingFunc(sourceObject, targetObjects)
: Function determine exactly how to map from a list targetObjets into a sourceObject. Advance usecase only.
It might look complicated. But usually, there're only 2 things we have to think about:
queryField
: If the mapping field is notid
. Eg for MongoDB: It's_id
mappingFilter
: When the==
is not enough. Eg for MongoDB:ObjectId
must be compared viaequals
method
Issue Reporting
If you have found a bug or have a feature request, feel free to report them at this repository issues section.
Contributing
You are welcome