Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions engine/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
extends: [
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'import/no-unresolved': 'off',
},
}
5 changes: 5 additions & 0 deletions engine/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# DHIS2 Platform
node_modules
.d2
src/locales
build
9 changes: 9 additions & 0 deletions engine/d2.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const config = {
type: 'lib',

entryPoints: {
lib: './src/index.ts',
},
}

module.exports = config
36 changes: 36 additions & 0 deletions engine/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@dhis2/data-engine",
"version": "3.14.6",
"description": "A standalone data query engine for DHIS2 REST API",
"main": "./build/cjs/index.js",
"module": "./build/es/index.js",
"types": "./build/types/index.d.ts",
"exports": {
"import": "./build/es/index.js",
"require": "./build/cjs/index.js",
"types": "./build/types/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/dhis2/app-runtime.git",
"directory": "engine"
},
"author": "Austin McGee <austin@dhis2.org>",
"license": "BSD-3-Clause",
"publishConfig": {
"access": "public"
},
"files": [
"build/**"
],
"scripts": {
"build:types": "tsc --emitDeclarationOnly --outDir ./build/types",
"build:package": "d2-app-scripts build",
"build": "concurrently -n build,types \"yarn build:package\" \"yarn build:types\"",
"watch": "NODE_ENV=development concurrently -n build,types \"yarn build:package --watch\" \"yarn build:types --watch\"",
"type-check": "tsc --noEmit --allowJs --checkJs",
"type-check:watch": "yarn type-check --watch",
"test": "d2-app-scripts test",
"coverage": "yarn test --coverage"
}
}
181 changes: 181 additions & 0 deletions engine/src/DataEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { getMutationFetchType } from './helpers/getMutationFetchType'
import { resolveDynamicQuery } from './helpers/resolveDynamicQuery'
import {
validateResourceQuery,
validateResourceQueries,
} from './helpers/validate'
import { requestOptionsToFetchType } from './links/RestAPILink/queryToRequestOptions'
import type { DataEngineLink } from './types/DataEngineLink'
import type { QueryExecuteOptions } from './types/ExecuteOptions'
import { JSON_PATCH_CONTENT_TYPE, type JSONPatch } from './types/JSONPatch'
import type { JsonMap, JsonValue } from './types/JsonValue'
import type { Mutation } from './types/Mutation'
import type { Query, ResourceQuery } from './types/Query'

const reduceResponses = (responses: JsonValue[], names: string[]) =>
responses.reduce<JsonMap>((out, response, idx) => {
out[names[idx]] = response
return out
}, {})

export class DataEngine {
private readonly link: DataEngineLink
public constructor(link: DataEngineLink) {
this.link = link
}

// Overload 1: When no generic is provided, accept any Query and return inferred type
public query(
query: Query,
options?: QueryExecuteOptions
): Promise<Record<keyof typeof query, unknown>>

// Overload 2: When generic is provided, enforce that query keys match the generic keys
public query<T extends Record<string, unknown>>(
query: Record<keyof T, ResourceQuery>,
options?: QueryExecuteOptions
): Promise<T>

public query<TResult extends Record<string, unknown>>(
query: Query,
{
variables = {},
signal,
onComplete,
onError,
}: QueryExecuteOptions = {}
): Promise<TResult | Record<keyof typeof query, unknown>> {
const names = Object.keys(query)
const queries = names
.map((name) => query[name])
.map((q) => resolveDynamicQuery(q, variables))

validateResourceQueries(queries, names)

return Promise.all(
queries.map((q) => {
return this.link.executeResourceQuery('read', q, {
signal,
})
})
)
.then((results) => {
const data = reduceResponses(results, names)
onComplete?.(data)
return data as TResult | Record<keyof typeof query, unknown>
})
.catch((error) => {
onError?.(error)
throw error
})
}

public mutate(
mutation: Mutation,
{
variables = {},
signal,
onComplete,
onError,
}: QueryExecuteOptions = {}
): Promise<JsonValue> {
const query = resolveDynamicQuery(mutation, variables)

const type = getMutationFetchType(mutation)
validateResourceQuery(type, query)

const result = this.link.executeResourceQuery(type, query, {
signal,
})
return result
.then((data) => {
onComplete?.(data)
return data
})
.catch((error) => {
onError?.(error)
throw error
})
}

public async fetch(
path: string,
init: RequestInit = {},
executeOptions?: QueryExecuteOptions
): Promise<unknown> {
const type = requestOptionsToFetchType(init)

if (path.includes('://')) {
throw new Error(
'Absolute URLs are not supported by the DHIS2 DataEngine fetch interface'
)
}
const uri = new URL(path, 'http://dummybaseurl')
const [, resource, id] =
/^\/([^/]+)(?:\/([^?]*))?/.exec(uri.pathname) ?? []

const params = Object.fromEntries(uri.searchParams)

if (type === 'read') {
const queryResult = await this.query(
{
result: {
resource,
id,
params,
} as ResourceQuery,
},
executeOptions
)
return queryResult.result
}
return this.mutate(
{
type,
resource,
id,
params,
partial: type === 'update' && init.method === 'PATCH',
data: init.body?.valueOf(),
} as Mutation,
executeOptions
)
}

public get(path: string, executeOptions?: QueryExecuteOptions) {
return this.fetch(path, { method: 'GET' }, executeOptions)
}
public post(path: string, body: any, executeOptions?: QueryExecuteOptions) {
return this.fetch(path, { method: 'POST', body }, executeOptions)
}
public put(path: string, body: any, executeOptions?: QueryExecuteOptions) {
return this.fetch(path, { method: 'PUT', body }, executeOptions)
}
public patch(
path: string,
body: any,
executeOptions?: QueryExecuteOptions
) {
return this.fetch(path, { method: 'PATCH', body }, executeOptions)
}
public jsonPatch(
path: string,
patches: JSONPatch,
executeOptions?: QueryExecuteOptions
) {
return this.fetch(
path,
{
method: 'PATCH',
body: patches as any,
headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE },
},
executeOptions
)
}
public delete(path: string, executeOptions?: QueryExecuteOptions) {
return this.fetch(path, { method: 'DELETE' }, executeOptions)
}
}

export default DataEngine
2 changes: 2 additions & 0 deletions engine/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FetchError } from './FetchError'
export { InvalidQueryError } from './InvalidQueryError'
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe('getMutationFetchType', () => {
).toBe('delete')
expect(
getMutationFetchType({
id: '123',
type: 'json-patch',
resource: 'test',
data: {},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { FetchType } from '../types/ExecuteOptions'
import { Mutation } from '../types/Mutation'

export const getMutationFetchType = (mutation: Mutation): FetchType =>
mutation.type === 'update'
? mutation.partial
? 'update'
: 'replace'
: mutation.type
export const getMutationFetchType = (mutation: Mutation): FetchType => {
if (mutation.type === 'update') {
return mutation.partial ? 'update' : 'replace'
}
return mutation.type
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { InvalidQueryError } from '../types/InvalidQueryError'
import { InvalidQueryError } from '../errors/InvalidQueryError'
import { ResolvedResourceQuery } from '../types/Query'

const validQueryKeys = ['resource', 'id', 'params', 'data']
const validTypes = [
const validQueryKeys = new Set(['resource', 'id', 'params', 'data'])
const validTypes = new Set([
'read',
'create',
'update',
'replace',
'delete',
'json-patch',
]
])

export const getResourceQueryErrors = (
type: string,
query: ResolvedResourceQuery
): string[] => {
if (!validTypes.includes(type)) {
if (!validTypes.has(type)) {
return [`Unknown query or mutation type ${type}`]
}
if (typeof query !== 'object') {
Expand Down Expand Up @@ -47,12 +47,10 @@ export const getResourceQueryErrors = (
"Mutation type 'json-patch' requires property 'data' to be of type Array"
)
}
const invalidKeys = Object.keys(query).filter(
(k) => !validQueryKeys.includes(k)
)
invalidKeys.forEach((k) => {
const invalidKeys = Object.keys(query).filter((k) => !validQueryKeys.has(k))
for (const k of invalidKeys) {
errors.push(`Property ${k} is not supported`)
})
}

return errors
}
Expand Down
5 changes: 5 additions & 0 deletions engine/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './DataEngine'
export * from './links'
export * from './errors'

export * from './types'
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
import type {
DataEngineLink,
DataEngineLinkExecuteOptions,
FetchType,
JsonValue,
ResolvedResourceQuery,
} from '../engine'
} from '../types/DataEngineLink'
import type { FetchType } from '../types/ExecuteOptions'
import type { JsonValue } from '../types/JsonValue'
import type { ResolvedResourceQuery } from '../types/Query'

export type CustomResourceFactory = (
type: FetchType,
Expand All @@ -21,9 +21,9 @@ export interface CustomLinkOptions {
}

export class CustomDataLink implements DataEngineLink {
private failOnMiss: boolean
private loadForever: boolean
private data: CustomData
private readonly failOnMiss: boolean
private readonly loadForever: boolean
private readonly data: CustomData
public constructor(
customData: CustomData,
{ failOnMiss = true, loadForever = false }: CustomLinkOptions = {}
Expand All @@ -49,7 +49,7 @@ export class CustomDataLink implements DataEngineLink {
`No data provided for resource type ${query.resource}!`
)
}
return Promise.resolve(null)
return null
}

switch (typeof customResource) {
Expand All @@ -60,7 +60,7 @@ export class CustomDataLink implements DataEngineLink {
return customResource
case 'function': {
const result = await customResource(type, query, options)
if (typeof result === 'undefined' && this.failOnMiss) {
if (result === undefined && this.failOnMiss) {
throw new Error(
`The custom function for resource ${query.resource} must always return a value but returned ${result}`
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { DataEngineLink } from '../engine'
import type { DataEngineLink } from '../types/DataEngineLink'

export class ErrorLink implements DataEngineLink {
private errorMessage: string
private readonly errorMessage: string
public constructor(errorMessage: string) {
this.errorMessage = errorMessage
}
public executeResourceQuery() {
console.error(this.errorMessage)
return Promise.reject(this.errorMessage)
return Promise.reject(new Error(this.errorMessage))
}
}
Loading
Loading