Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/card-service/knexfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module.exports = {
max: 10
},
migrations: {
tableName: 'knex_migrations'
tableName: 'card_service_knex_migrations'
}
},

Expand All @@ -29,7 +29,7 @@ module.exports = {
max: 10
},
migrations: {
tableName: 'knex_migrations'
tableName: 'card_service_knex_migrations'
}
},

Expand All @@ -41,7 +41,7 @@ module.exports = {
max: 10
},
migrations: {
tableName: 'knex_migrations'
tableName: 'card_service_knex_migrations'
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('cardPayments', function (table) {
table.uuid('id').notNullable().primary()
table.uuid('requestId').notNullable()

table.timestamp('requestedAt').defaultTo(knex.fn.now())
table.timestamp('finalizedAt').defaultTo(knex.fn.now())

table.string('cardWalletAddress').notNullable()
table.string('incomingPaymentUrl').notNullable()

table.integer('statusCode').nullable()

table.uuid('outgoingPaymentId')
table.uuid('terminalId')

table.index('cardWalletAddress')
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('cardPayments')
}
6 changes: 5 additions & 1 deletion packages/card-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@
},
"dependencies": {
"@adonisjs/fold": "^8.2.0",
"@interledger/open-payments": "7.0.0",
"@koa/cors": "^5.0.0",
"knex": "^3.1.0",
"koa": "^2.15.4",
"objection": "^3.1.5",
"pino": "^8.19.0"
"objection-db-errors": "^1.1.2",
"pino": "^8.19.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/koa": "2.15.0",
"@types/koa-bodyparser": "^4.3.12",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.4",
"@types/uuid": "^9.0.8",
"ts-node-dev": "^2.0.0"
}
}
16 changes: 16 additions & 0 deletions packages/card-service/src/card-payment/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { BaseModel } from '../shared/baseModel'

export class CardPayments extends BaseModel {
public static get tableName(): string {
return 'card_payments'
}

public requestId!: string
public requestedAt!: Date | null
public finalizedAt!: Date | null
public cardWalletAddress!: string
public incomingPaymentUrl?: string
public statusCode?: number
public outgoingPaymentId?: string
public terminalId!: string
}
128 changes: 128 additions & 0 deletions packages/card-service/src/shared/baseModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { BaseModel, Pagination, SortOrder } from './baseModel'
import { getPageInfo } from './pagination'

interface PageTestsOptions<Type> {
createModel: () => Promise<Type>
getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise<Type[]>
}

export const getPageTests = <Type extends BaseModel>({
createModel,
getPage
}: PageTestsOptions<Type>): void => {
describe('Common BaseModel pagination', (): void => {
let modelsCreated: Type[]

beforeEach(async (): Promise<void> => {
modelsCreated = []
for (let i = 0; i < 22; i++) {
modelsCreated.push(await createModel())
}
modelsCreated.reverse() // default sort order is DESC
})

test.each`
pagination | expected | description
${undefined} | ${{ length: 20, first: 0, last: 19 }} | ${'Defaults to fetching first 20 items'}
${{ first: 10 }} | ${{ length: 10, first: 0, last: 9 }} | ${'Can change forward pagination limit'}
${{ after: 0 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Can paginate forwards from a cursor'}
${{ first: 10, after: 9 }} | ${{ length: 10, first: 10, last: 19 }} | ${'Can paginate forwards from a cursor with a limit'}
${{ before: 20 }} | ${{ length: 20, first: 0, last: 19 }} | ${'Can paginate backwards from a cursor'}
${{ last: 5, before: 10 }} | ${{ length: 5, first: 5, last: 9 }} | ${'Can paginate backwards from a cursor with a limit'}
${{ after: 0, before: 19 }} | ${{ length: 20, first: 1, last: 20 }} | ${'Providing before and after results in forward pagination'}
`('$description', async ({ pagination, expected }): Promise<void> => {
if (pagination?.after !== undefined) {
pagination.after = modelsCreated[pagination.after].id
}
if (pagination?.before !== undefined) {
pagination.before = modelsCreated[pagination.before].id
}
const models = await getPage(pagination)
expect(models).toHaveLength(expected.length)
expect(models[0].id).toEqual(modelsCreated[expected.first].id)
expect(models[expected.length - 1].id).toEqual(
modelsCreated[expected.last].id
)
})

test.each`
pagination | expectedError | description
${{ last: 10 }} | ${"Can't paginate backwards from the start."} | ${"Can't change backward pagination limit on it's own."}
${{ first: -1 }} | ${'Pagination index error'} | ${"Can't request less than 0"}
${{ first: 101 }} | ${'Pagination index error'} | ${"Can't request more than 100"}
`('$description', async ({ pagination, expectedError }): Promise<void> => {
await expect(getPage(pagination)).rejects.toThrow(expectedError)
})

test.each`
order | description
${SortOrder.Asc} | ${'Backwards/Forwards pagination results in same order for ASC.'}
${SortOrder.Desc} | ${'Backwards/Forwards pagination results in same order for DESC.'}
`('$description', async ({ order }): Promise<void> => {
if (order === SortOrder.Asc) {
// model was in DESC order so needs to be reverted back to ASC
modelsCreated.reverse()
}
const paginationForwards = {
first: 10
}
const modelsForwards = await getPage(paginationForwards, order)
const paginationBackwards = {
last: 10,
before: modelsCreated[10].id
}
const modelsBackwards = await getPage(paginationBackwards, order)
expect(modelsForwards).toHaveLength(10)
expect(modelsBackwards).toHaveLength(10)
expect(modelsForwards).toEqual(modelsBackwards)
})

test.each`
pagination | cursor | start | end | hasNextPage | hasPreviousPage | sortOrder
${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Desc}
${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Desc}
${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Desc}
${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Desc}
${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Desc}
${null} | ${null} | ${0} | ${19} | ${true} | ${false} | ${SortOrder.Asc}
${{ first: 5 }} | ${null} | ${0} | ${4} | ${true} | ${false} | ${SortOrder.Asc}
${{ first: 22 }} | ${null} | ${0} | ${21} | ${false} | ${false} | ${SortOrder.Asc}
${{ first: 3 }} | ${3} | ${4} | ${6} | ${true} | ${true} | ${SortOrder.Asc}
${{ last: 5 }} | ${9} | ${4} | ${8} | ${true} | ${true} | ${SortOrder.Asc}
`(
'pagination $pagination with cursor $cursor in $sortOrder order',
async ({
pagination,
cursor,
start,
end,
hasNextPage,
hasPreviousPage,
sortOrder
}): Promise<void> => {
if (sortOrder === SortOrder.Asc) {
modelsCreated.reverse()
}
if (cursor) {
if (pagination.last) pagination.before = modelsCreated[cursor].id
else pagination.after = modelsCreated[cursor].id
}

const page = await getPage(pagination, sortOrder)
const pageInfo = await getPageInfo({
getPage: (pagination, sortOrder) => getPage(pagination, sortOrder),
page,
sortOrder
})
expect(pageInfo).toEqual({
startCursor: modelsCreated[start].id,
endCursor: modelsCreated[end].id,
hasNextPage,
hasPreviousPage
})
}
)
})
}

test.todo('test suite must contain at least one test')
144 changes: 144 additions & 0 deletions packages/card-service/src/shared/baseModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
Model,
ModelOptions,
Page,
Pojo,
QueryBuilder,
QueryContext
} from 'objection'
import { DbErrors } from 'objection-db-errors'
import { v4 as uuid } from 'uuid'

export interface Pagination {
after?: string // Forward pagination: cursor.
before?: string // Backward pagination: cursor.
first?: number // Forward pagination: limit.
last?: number // Backward pagination: limit.
}

export interface PageInfo {
startCursor?: string
endCursor?: string
hasNextPage: boolean
hasPreviousPage: boolean
}

export enum SortOrder {
Asc = 'ASC',
Desc = 'DESC'
}

class PaginationQueryBuilder<M extends Model, R = M[]> extends QueryBuilder<
M,
R
> {
ArrayQueryBuilderType!: PaginationQueryBuilder<M, M[]>
SingleQueryBuilderType!: PaginationQueryBuilder<M, M>
MaybeSingleQueryBuilderType!: PaginationQueryBuilder<M, M | undefined>
NumberQueryBuilderType!: PaginationQueryBuilder<M, number>
PageQueryBuilderType!: PaginationQueryBuilder<M, Page<M>>

/** TODO: Base64 encode/decode the cursors
* Buffer.from("Hello World").toString('base64')
* Buffer.from("SGVsbG8gV29ybGQ=", 'base64').toString('ascii')
*/

/** getPage
* The pagination algorithm is based on the Relay connection specification.
* Please read the spec before changing things:
* https://relay.dev/graphql/connections.htm
* @param pagination Pagination - cursors and limits.
* @param sortOrder SortOrder - Asc/Desc sort order.
* @returns Model[] An array of Models that form a page.
*/
getPage(
pagination?: Pagination,
sortOrder: SortOrder = SortOrder.Desc
): this {
const tableName = this.modelClass().tableName
if (
typeof pagination?.before === 'undefined' &&
typeof pagination?.last === 'number'
)
throw new Error("Can't paginate backwards from the start.")

const first = pagination?.first || 20
if (first < 0 || first > 100) throw new Error('Pagination index error')
const last = pagination?.last || 20
if (last < 0 || last > 100) throw new Error('Pagination index error')
/**
* Forward pagination
*/
if (typeof pagination?.after === 'string') {
const comparisonOperator = sortOrder === SortOrder.Asc ? '>' : '<'
return this.whereRaw(
`("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`,
[this.modelClass().tableName, pagination.after]
)
.orderBy([
{ column: 'createdAt', order: sortOrder },
{ column: 'id', order: sortOrder }
])
.limit(first)
}
/**
* Backward pagination
*/
if (typeof pagination?.before === 'string') {
const comparisonOperator = sortOrder === SortOrder.Asc ? '<' : '>'
const order = sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc
return this.whereRaw(
`("${tableName}"."createdAt", "${tableName}"."id") ${comparisonOperator} (select "${tableName}"."createdAt" :: TIMESTAMP, "${tableName}"."id" from ?? where "${tableName}"."id" = ?)`,
[this.modelClass().tableName, pagination.before]
)
.orderBy([
{ column: 'createdAt', order },
{ column: 'id', order }
])
.limit(last)
.runAfter((models) => {
if (Array.isArray(models)) {
return models.reverse()
}
})
}

return this.orderBy([
{ column: 'createdAt', order: sortOrder },
{ column: 'id', order: sortOrder }
]).limit(first)
}
}

export class PaginationModel extends DbErrors(Model) {
QueryBuilderType!: PaginationQueryBuilder<this>
static QueryBuilder = PaginationQueryBuilder
}

export abstract class BaseModel extends PaginationModel {
public static get modelPaths(): string[] {
return [__dirname]
}

public id!: string
public createdAt!: Date
public updatedAt!: Date

public $beforeInsert(context: QueryContext): void {
super.$beforeInsert(context)
this.id = this.id || uuid()
}

public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void {
this.updatedAt = new Date()
}

$formatJson(json: Pojo): Pojo {
json = super.$formatJson(json)
return {
...json,
createdAt: json.createdAt.toISOString(),
updatedAt: json.updatedAt.toISOString()
}
}
}
8 changes: 8 additions & 0 deletions packages/card-service/src/shared/baseService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { TransactionOrKnex } from 'objection'

import { Logger } from 'pino'

export interface BaseService {
logger: Logger
knex?: TransactionOrKnex
}
3 changes: 3 additions & 0 deletions packages/card-service/src/shared/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface FilterString {
in?: string[]
}
Loading
Loading