├── .github ├── FUNDING.yml ├── contributing.md ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.yml ├── workflows │ ├── trigger-docs.yml │ ├── automerge.yml │ ├── lint.yml │ ├── build.yml │ ├── deploy-api-docs.yml │ ├── release.yml │ └── test.yml ├── dependabot.yml ├── semantic.yml └── pull_request_template.md ├── src ├── Support │ ├── array │ │ ├── index.ts │ │ └── wrap.ts │ ├── initialiser │ │ ├── index.ts │ │ ├── collect.ts │ │ ├── paginate.ts │ │ └── factory.ts │ ├── function │ │ ├── value.ts │ │ ├── index.ts │ │ ├── isObjectLiteral.ts │ │ ├── isUserLandClass.ts │ │ ├── poll.ts │ │ ├── transformKeys.ts │ │ ├── retry.ts │ │ └── dataGet.ts │ ├── string │ │ ├── uuid.ts │ │ ├── kebab.ts │ │ ├── snake.ts │ │ ├── ucFirst.ts │ │ ├── pascal.ts │ │ ├── isUuid.ts │ │ ├── includesAll.ts │ │ ├── plural.ts │ │ ├── singular.ts │ │ ├── camel.ts │ │ ├── title.ts │ │ ├── finish.ts │ │ ├── start.ts │ │ ├── limit.ts │ │ ├── words.ts │ │ ├── before.ts │ │ ├── after.ts │ │ ├── is.ts │ │ ├── afterLast.ts │ │ ├── beforeLast.ts │ │ └── index.ts │ ├── type.ts │ └── GlobalConfig.ts ├── Contracts │ ├── Arrayable.ts │ ├── Jsonable.ts │ ├── FormatsQueryParameters.ts │ ├── HasFactory.ts │ ├── RequestMiddleware.ts │ ├── FactoryHooks.ts │ ├── AttributeCaster.ts │ ├── HandlesApiResponse.ts │ ├── Configuration.ts │ └── ApiCaller.ts ├── Exceptions │ ├── BaseException.ts │ ├── LogicException.ts │ ├── InvalidOffsetException.ts │ └── InvalidArgumentException.ts ├── Calliope │ ├── Factory │ │ └── Factory.ts │ ├── Concerns │ │ ├── HasTimestamps.ts │ │ ├── SoftDeletes.ts │ │ └── GuardsAttributes.ts │ └── AncestryCollection.ts ├── index.ts ├── array.ts └── Services │ └── ApiResponseHandler.ts ├── .husky ├── pre-commit └── commit-msg ├── tests ├── tsconfig.json ├── mock │ ├── Configuration │ │ └── DateTime.ts │ ├── Factories │ │ ├── TeamFactory.ts │ │ ├── FileFactory.ts │ │ ├── ShiftFactory.ts │ │ ├── FolderFactory.ts │ │ ├── ContractFactory.ts │ │ └── UserFactory.ts │ ├── Models │ │ ├── Shift.ts │ │ ├── File.ts │ │ ├── Team.ts │ │ ├── Folder.ts │ │ ├── Contract.ts │ │ └── User.ts │ └── fetch-mock.ts ├── vitest.config.ts ├── test-helpers.ts ├── setupTests.ts ├── Support │ ├── initialiser.test.ts │ ├── array.test.ts │ └── GlobalConfig.test.ts ├── Calliope │ ├── Concerns │ │ └── GuardsAttributes.test.ts │ └── AncestryCollection.test.ts └── Services │ └── ApiResponseHandler.test.ts ├── .editorconfig ├── release.config.js ├── .gitignore ├── typedoc.json ├── README.md ├── tsconfig.json ├── docs ├── readme.md ├── services │ ├── api-response-handler.md │ ├── api.md │ └── readme.md ├── testing │ └── readme.md ├── .vuepress │ └── links.ts ├── getting-started │ ├── readme.md │ └── installation.md ├── calliope │ ├── model-collection.md │ ├── ancestry-collection.md │ ├── timestamps.md │ └── api-calls.md ├── helpers │ ├── event-emitter.md │ └── global-config.md └── prologue │ ├── contributing.md │ └── project-policies.md ├── .commitlintrc.cjs ├── LICENSE.txt ├── rollup.config.ts └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nandi95 2 | -------------------------------------------------------------------------------- /src/Support/array/index.ts: -------------------------------------------------------------------------------- 1 | export { default as wrap } from './wrap'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | ## Please review the contribution guideline in the [documentation](https://upfrontjs.com/prologue/contributing). 2 | -------------------------------------------------------------------------------- /src/Support/initialiser/index.ts: -------------------------------------------------------------------------------- 1 | export { default as collect } from './collect'; 2 | export { default as factory } from './factory'; 3 | export { default as paginate } from './paginate'; 4 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The UpfrontJS Code of Conduct can be found in the [documentation](https://upfrontjs.com/prologue/project-policies.html#code-of-conduct). 4 | -------------------------------------------------------------------------------- /src/Contracts/Arrayable.ts: -------------------------------------------------------------------------------- 1 | export default interface Arrayable { 2 | /** 3 | * Get the instance as an array. 4 | * 5 | * @return array 6 | */ 7 | toArray: () => T[]; 8 | } 9 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": [ 7 | "./**/*.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/Support/function/value.ts: -------------------------------------------------------------------------------- 1 | export default function value(val: T, ...args: any[]): T extends (...args: any[]) => infer R ? R : T { 2 | return typeof val === 'function' ? val(...args) : val as any; 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | end_of_line = crlf 7 | indent_style = space 8 | indent_size = 4 9 | 10 | [{*.yml, *.yaml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /src/Contracts/Jsonable.ts: -------------------------------------------------------------------------------- 1 | export default interface Jsonable { 2 | /** 3 | * Convert object into JSON. 4 | */ 5 | // https://github.com/microsoft/TypeScript/issues/1897 6 | toJSON: () => ReturnType; 7 | } 8 | -------------------------------------------------------------------------------- /src/Exceptions/BaseException.ts: -------------------------------------------------------------------------------- 1 | export default abstract class BaseException extends Error { 2 | /** 3 | * The name of the called exception class. 4 | * 5 | * @type {string} 6 | */ 7 | public abstract override get name(): string; 8 | } 9 | -------------------------------------------------------------------------------- /src/Support/string/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | /** 4 | * Generate a uuid of version 4 using the [uuid](https://www.npmjs.com/package/uuid) package. 5 | * 6 | * @return {string} 7 | */ 8 | export default function uuid(): string { 9 | return v4(); 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | contact_links: 4 | - 5 | about: "Please ask and answer usage questions in the discussion section." 6 | name: Question 7 | url: "https://github.com/upfrontjs/framework/discussions/new?category=q-a" 8 | -------------------------------------------------------------------------------- /src/Support/string/kebab.ts: -------------------------------------------------------------------------------- 1 | import snake from './snake'; 2 | 3 | /** 4 | * Transform the string to kebab-case. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function kebab(str: string): string { 11 | return snake(str).replace(/_/g, '-'); 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/snake.ts: -------------------------------------------------------------------------------- 1 | import snakeCase from 'lodash.snakecase'; 2 | 3 | /** 4 | * Transform the string to snake_case. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function snake(str: string): string { 11 | return snakeCase(str); 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/ucFirst.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Uppercase the first letter. 3 | * 4 | * @param {string} str 5 | * 6 | * @return {string} 7 | */ 8 | export default function ucFirst(str: T): Capitalize { 9 | return str.charAt(0).toUpperCase() + str.slice(1) as Capitalize; 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/initialiser/collect.ts: -------------------------------------------------------------------------------- 1 | import Collection from '../Collection'; 2 | 3 | /** 4 | * Create a collection from the array. 5 | * 6 | * @param {any} items 7 | * 8 | * @return {Collection} 9 | */ 10 | export default function collect(items?: T | T[]): Collection { 11 | return new Collection(items); 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/pascal.ts: -------------------------------------------------------------------------------- 1 | import ucFirst from './ucFirst'; 2 | import camel from './camel'; 3 | 4 | /** 5 | * Transform the string to PascalCase. 6 | * 7 | * @param {string} str 8 | * 9 | * @return {string} 10 | */ 11 | export default function pascal(str: string): string { 12 | return ucFirst(camel(str)); 13 | } 14 | -------------------------------------------------------------------------------- /src/Exceptions/LogicException.ts: -------------------------------------------------------------------------------- 1 | import BaseException from './BaseException'; 2 | 3 | /** 4 | * Error that occurs when trying to execute logic that is not supported. 5 | */ 6 | export default class LogicException extends BaseException { 7 | public get name(): string { 8 | return 'LogicException'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/string/isUuid.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'uuid'; 2 | 3 | /** 4 | * Determine whether the given string is a UUID of version 4. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function isUuid(str: unknown): str is string { 11 | return typeof str === 'string' && validate(str); 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/includesAll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test whether all the tokens included in the string. 3 | * 4 | * @param {string} str 5 | * @param {string[]} tokens 6 | * 7 | * @return {boolean} 8 | */ 9 | export default function includesAll(str: string, tokens: string[]): boolean { 10 | return tokens.every(token => str.includes(token)); 11 | } 12 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidOffsetException.ts: -------------------------------------------------------------------------------- 1 | import BaseException from './BaseException'; 2 | 3 | /** 4 | * Error that occurs when accessing an unexpected property on a structure. 5 | */ 6 | export default class InvalidOffsetException extends BaseException { 7 | public get name(): string { 8 | return 'InvalidOffsetException'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/string/plural.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | 3 | /** 4 | * Get the plural form of the string using the [pluralize](https://www.npmjs.com/package/pluralize) package. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function plural(str: string): string { 11 | return pluralize.plural(str); 12 | } 13 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidArgumentException.ts: -------------------------------------------------------------------------------- 1 | import BaseException from './BaseException'; 2 | 3 | /** 4 | * Error that occurs when providing an unexpected argument to a function. 5 | */ 6 | export default class InvalidArgumentException extends BaseException { 7 | public get name(): string { 8 | return 'InvalidArgumentException'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/string/singular.ts: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | 3 | /** 4 | * Get the singular form of the string using the [pluralize](https://www.npmjs.com/package/pluralize) package. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function singular(str: string): string { 11 | return pluralize.singular(str); 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/camel.ts: -------------------------------------------------------------------------------- 1 | import title from './title'; 2 | 3 | /** 4 | * Transform the string to camelCase. 5 | * 6 | * @param {string} str 7 | * 8 | * @return {string} 9 | */ 10 | export default function camel(str: string): string { 11 | const titleCase = title(str).replace(/ /g, ''); 12 | 13 | return titleCase.charAt(0).toLowerCase() + titleCase.slice(1); 14 | } 15 | -------------------------------------------------------------------------------- /src/Support/function/index.ts: -------------------------------------------------------------------------------- 1 | export { default as dataGet } from './dataGet'; 2 | export { default as retry } from './retry'; 3 | export { default as transformKeys } from './transformKeys'; 4 | export { default as isUserLandClass } from './isUserLandClass'; 5 | export { default as isObjectLiteral } from './isObjectLiteral'; 6 | export { default as value } from './value'; 7 | export { default as poll } from './poll'; 8 | -------------------------------------------------------------------------------- /tests/mock/Configuration/DateTime.ts: -------------------------------------------------------------------------------- 1 | export class DateTime { 2 | private readonly internalValue; 3 | 4 | public constructor(value: any) { 5 | this.internalValue = value; 6 | } 7 | 8 | public value(): any { 9 | return this.internalValue; 10 | } 11 | } 12 | 13 | export function dateTime(value: any): DateTime { 14 | return new DateTime(value); 15 | } 16 | -------------------------------------------------------------------------------- /src/Support/function/isObjectLiteral.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determine whether the given value is a non-null object not including the array type. 3 | * 4 | * @param {any} value 5 | * 6 | * @return {boolean} 7 | */ 8 | export default function isObjectLiteral>(value: any): value is NonNullable { 9 | return value !== null && typeof value === 'object' && !Array.isArray(value); 10 | } 11 | -------------------------------------------------------------------------------- /src/Support/function/isUserLandClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determine whether the given value is a user defined class that can be called with the "new" keyword. 3 | * 4 | * @param {any} value 5 | * 6 | * @return {boolean} 7 | */ 8 | export default function isUserLandClass any>(value: any): value is T { 9 | return value instanceof Function && /^\s*class\s+/.test(String(value)); 10 | } 11 | -------------------------------------------------------------------------------- /tests/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { isCI } from 'std-env'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | bail: 1, 7 | reporters: isCI ? ['dot', 'github-actions'] : ['default'], 8 | setupFiles: ['./tests/setupTests.ts'], 9 | coverage: { 10 | reportsDirectory: './tests/coverage' 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/Support/string/title.ts: -------------------------------------------------------------------------------- 1 | import ucFirst from './ucFirst'; 2 | import snake from './snake'; 3 | 4 | /** 5 | * Transform the string to Title Case. 6 | * 7 | * @param {string} str 8 | * 9 | * @return {string} 10 | */ 11 | export default function title(str: string): string { 12 | return ucFirst(snake(str)) 13 | .split('_') 14 | .reduce((previous: string, next: string) => previous + ' ' + ucFirst(next)); 15 | } 16 | -------------------------------------------------------------------------------- /tests/mock/Factories/TeamFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import type Team from '../Models/Team'; 4 | 5 | export default class TeamFactory extends Factory { 6 | public override definition(): Attributes { 7 | return { 8 | name: 'Main' 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Support/array/wrap.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeArray } from '../type'; 2 | 3 | /** 4 | * Ensure the given value is an array. 5 | * 6 | * @param {any} value 7 | * 8 | * @return {array}; 9 | */ 10 | export default function wrap(value?: MaybeArray): T[] { 11 | if (!arguments.length) { 12 | return []; 13 | } 14 | 15 | if (!Array.isArray(value)) { 16 | value = [value] as T[]; 17 | } 18 | 19 | return value; 20 | } 21 | -------------------------------------------------------------------------------- /src/Support/initialiser/paginate.ts: -------------------------------------------------------------------------------- 1 | import Paginator from '../Paginator'; 2 | 3 | /** 4 | * Construct a paginator instance. 5 | * 6 | * @param {any[]=} items 7 | * @param {number} itemsPerPage 8 | * @param {boolean} wrapsAround 9 | * 10 | * @return {Paginator} 11 | */ 12 | export default function paginate(items?: any[], itemsPerPage = 10, wrapsAround = false): Paginator { 13 | return new Paginator(items, itemsPerPage, wrapsAround); 14 | } 15 | -------------------------------------------------------------------------------- /tests/mock/Models/Shift.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import ShiftFactory from '../Factories/ShiftFactory'; 3 | 4 | export default class Shift extends Model { 5 | public override getName(): string { 6 | return 'Shift'; 7 | } 8 | 9 | public factory(): ShiftFactory { 10 | return new ShiftFactory; 11 | } 12 | 13 | public override get fillable(): string[] { 14 | return ['*']; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Support/string/finish.ts: -------------------------------------------------------------------------------- 1 | type Finish = S extends `${string}${ST}` ? S : `${S}${ST}`; 2 | 3 | /** 4 | * Ensure the string ends with the given string. 5 | * 6 | * @param {string} str 7 | * @param {string} token. 8 | * 9 | * @return {string} 10 | */ 11 | export default function finish(str: S, token: ST): Finish { 12 | return (str.endsWith(token) ? str : str + token) as Finish; 13 | } 14 | -------------------------------------------------------------------------------- /src/Support/string/start.ts: -------------------------------------------------------------------------------- 1 | type Start = T extends `${ST}${string}` ? T : `${ST}${T}`; 2 | 3 | /** 4 | * Ensure the string starts with the given string. 5 | * 6 | * @param {string} str 7 | * @param {string} token. 8 | * 9 | * @return {string} 10 | */ 11 | export default function start(str: T, token: ST): Start { 12 | return (str.startsWith(token) ? str : token + str) as Start; 13 | } 14 | -------------------------------------------------------------------------------- /tests/mock/Factories/FileFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import type { default as FileModel } from '../Models/File'; 4 | 5 | export default class FileFactory extends Factory { 6 | public override definition(): Attributes { 7 | return { 8 | name: 'image.jpg', 9 | url: 'https://picsum.photos/200' 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@types/semantic-release').GlobalConfig} */ 2 | export default { 3 | branches: [ 4 | 'release\\/([0-9])\\.x', 5 | 'main' 6 | ], 7 | plugins: [ 8 | "@semantic-release/commit-analyzer", 9 | ["@semantic-release/release-notes-generator", { 10 | preset: 'conventionalcommits' 11 | }], 12 | "@semantic-release/npm", 13 | "@semantic-release/git", 14 | "@semantic-release/github" 15 | ] 16 | }; 17 | -------------------------------------------------------------------------------- /src/Support/string/limit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Limit the number of characters on the string. 3 | * 4 | * @param {string} str 5 | * @param {number} count - The number of characters to keep. 6 | * @param {string} limiter - The string to be appended at the end after the limit. 7 | * 8 | * @return {string} 9 | */ 10 | export default function limit(str: string, count: number, limiter = '...'): string { 11 | const string = str.substring(0, count); 12 | 13 | return str.length > string.length ? string + limiter : string; 14 | } 15 | -------------------------------------------------------------------------------- /tests/mock/Factories/ShiftFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import type Shift from '../Models/Shift'; 4 | 5 | export default class ShiftFactory extends Factory { 6 | public override definition(): Attributes { 7 | return { 8 | startTime: new Date().toISOString(), 9 | finishTime: new Date(new Date().getHours() + 6) 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Support/string/words.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Limit the number of words on the string. 3 | * 4 | * @param {string} str 5 | * @param {number} count - The number of words to keep. 6 | * @param {string} limiter - The string to be appended at the end after the limit. 7 | * 8 | * @return {string} 9 | */ 10 | export default function words(str: string, count: number, limiter = '...'): string { 11 | const sentence = str.split(' ').slice(0, count).join(' '); 12 | 13 | return sentence.length === str.length ? str : sentence + limiter; 14 | } 15 | -------------------------------------------------------------------------------- /tests/mock/Factories/FolderFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import type Folder from '../Models/Folder'; 4 | 5 | export default class FileFactory extends Factory { 6 | public override definition(): Attributes { 7 | return { 8 | name: 'my folder', 9 | createdAt: new Date().toISOString(), 10 | updatedAt: new Date().toISOString() 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Contracts/FormatsQueryParameters.ts: -------------------------------------------------------------------------------- 1 | import type { QueryParams } from '../Calliope/Concerns/BuildsQuery'; 2 | 3 | /** 4 | * Interface prescribing the expected signature of the query parameter formatting function. 5 | */ 6 | export default interface FormatsQueryParameters { 7 | /** 8 | * The method that customises the outgoing query parameter keys. 9 | * 10 | * @param {QueryParams} parameters 11 | */ 12 | formatQueryParameters: (parameters: QueryParams & Record) => Record; 13 | } 14 | -------------------------------------------------------------------------------- /tests/mock/Models/File.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import FileFactory from '../Factories/FileFactory'; 3 | 4 | /** 5 | * File Model. 6 | * Named FileModel to avoid naming clash with built-in File. 7 | */ 8 | export default class File extends Model { 9 | public override getName(): string { 10 | return 'File'; 11 | } 12 | 13 | public override get endpoint(): string { 14 | return 'files'; 15 | } 16 | 17 | public factory(): FileFactory { 18 | return new FileFactory; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/mock/Factories/ContractFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import type Contract from '../Models/Contract'; 4 | 5 | export default class ContractFactory extends Factory { 6 | public override definition(): Attributes { 7 | return { 8 | startDate: new Date().toISOString(), 9 | endDate: null, 10 | rate: 1, 11 | active: true 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/test-helpers.ts: -------------------------------------------------------------------------------- 1 | export const types = [ 2 | 1, 3 | Number, 4 | true, 5 | Boolean, 6 | 'val', 7 | String, 8 | [], 9 | Array, 10 | {}, 11 | Object, 12 | (): void => {}, 13 | Function, 14 | Symbol, 15 | Symbol(), 16 | null, 17 | undefined, 18 | class C {}, 19 | new class C {}, 20 | BigInt, 21 | Map, 22 | new Map, 23 | Set, 24 | new Set, 25 | WeakSet, 26 | new WeakSet, 27 | Date, 28 | new Date, 29 | FormData, 30 | new FormData, 31 | Event 32 | ]; 33 | -------------------------------------------------------------------------------- /.github/workflows/trigger-docs.yml: -------------------------------------------------------------------------------- 1 | name: Trigger docs build 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'docs/**/*' 7 | branches: 8 | - main 9 | 10 | jobs: 11 | dispatch: 12 | timeout-minutes: 10 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Repository Dispatch 16 | uses: peter-evans/repository-dispatch@v2 17 | with: 18 | token: ${{ secrets.DOCS_REPO_ACCESS_TOKEN }} 19 | repository: upfrontjs/docs 20 | event-type: build-docs 21 | client-payload: '{"ref": "${{ github.ref }}", "repo": "${{ github.repository }}"}' 22 | -------------------------------------------------------------------------------- /src/Support/initialiser/factory.ts: -------------------------------------------------------------------------------- 1 | import type Model from '../../Calliope/Model'; 2 | import type Factory from '../../Calliope/Factory/Factory'; 3 | import FactoryBuilder from '../../Calliope/Factory/FactoryBuilder'; 4 | 5 | /** 6 | * Return the Factory builder. 7 | * 8 | * @param {Model} modelConstructor 9 | * @param {number} amount 10 | * 11 | * @return {Factory} 12 | */ 13 | export default function factory = Factory>( 14 | modelConstructor: new () => T, 15 | amount = 1 16 | ): FactoryBuilder { 17 | return new FactoryBuilder(modelConstructor).times(amount); 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Auto Merge Dependency Updates 2 | 3 | on: 4 | pull_request: 5 | types: [ready_for_review, opened, synchronize, reopened] 6 | paths: 7 | - 'package-lock.json' 8 | pull_request_review: 9 | paths: 10 | - 'package-lock.json' 11 | 12 | jobs: 13 | run: 14 | timeout-minutes: 10 15 | if: github.event.pull_request.draft == false 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: tjenkinson/gh-action-auto-merge-dependency-updates@v1 19 | with: 20 | allowed-actors: dependabot-preview[bot], dependabot[bot] 21 | merge-method: squash 22 | -------------------------------------------------------------------------------- /src/Support/string/before.ts: -------------------------------------------------------------------------------- 1 | type Before< 2 | S extends string, 3 | ST extends string 4 | > = S extends `${infer P1}${ST}${string}` ? P1 : S extends `${string}${ST}` ? '' : S; 5 | 6 | /** 7 | * Get the string up to and not including the given token. 8 | * 9 | * @param {string} str 10 | * @param {string} token - The string to search for. 11 | * 12 | * @return {string} 13 | */ 14 | export default function before(str: T, token: ST): Before { 15 | if (token === '') return '' as Before; 16 | 17 | return (str.includes(token) ? str.substring(0, str.indexOf(token)) : '') as Before; 18 | } 19 | -------------------------------------------------------------------------------- /tests/setupTests.ts: -------------------------------------------------------------------------------- 1 | import GlobalConfig from '../src/Support/GlobalConfig'; 2 | import type Configuration from '../src/Contracts/Configuration'; 3 | import { vi, beforeEach } from 'vitest'; 4 | 5 | export const config: GlobalConfig = new GlobalConfig; 6 | 7 | export const now = new Date(vi.getRealSystemTime()); 8 | vi.useFakeTimers({ now }); 9 | config.set('baseEndPoint', 'https://test-api-endpoint.com'); 10 | 11 | beforeEach(() => { 12 | config.reset(); 13 | config.set('baseEndPoint', 'https://test-api-endpoint.com'); 14 | // set back to now as some test suites might update this 15 | vi.setSystemTime(now); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: npm 5 | directory: '/' 6 | target-branch: release/0.x 7 | schedule: 8 | interval: monthly 9 | time: '00:00' 10 | open-pull-requests-limit: 10 11 | commit-message: 12 | prefix: fix 13 | prefix-development: chore 14 | include: scope 15 | 16 | 17 | - package-ecosystem: github-actions 18 | directory: '/' 19 | target-branch: release/0.x 20 | schedule: 21 | interval: monthly 22 | time: '00:00' 23 | open-pull-requests-limit: 10 24 | commit-message: 25 | prefix: fix 26 | prefix-development: chore 27 | include: scope 28 | -------------------------------------------------------------------------------- /src/Support/string/after.ts: -------------------------------------------------------------------------------- 1 | type After< 2 | S extends string, 3 | ST extends string 4 | > = S extends `${string}${ST}${infer P}` ? P : S extends `${string}${ST}` ? '' : S; 5 | 6 | /** 7 | * Get part of the string after the given token. 8 | * 9 | * @param {string} str 10 | * @param {string} token - The string to search for. 11 | * 12 | * @return {string} 13 | */ 14 | export default function after(str: T, token: ST): After { 15 | if (token === '') return '' as After; 16 | 17 | return (str.includes(token) ? str.substring(str.indexOf(token) + token.length, str.length) : '') as After; 18 | } 19 | -------------------------------------------------------------------------------- /src/Contracts/HasFactory.ts: -------------------------------------------------------------------------------- 1 | import type Factory from '../Calliope/Factory/Factory'; 2 | import type Model from '../Calliope/Model'; 3 | 4 | /** 5 | * Interface to typehint the factory method signature. 6 | */ 7 | export default interface HasFactory { 8 | /** 9 | * The method responsible for returning the instantiated model factory class. 10 | * 11 | * @return {Factory} 12 | */ 13 | factory?: >() => T; 14 | 15 | /** 16 | * This interface should have a property in common with implementer. 17 | * And FactoryBuilder among others depends on the getName method. 18 | */ 19 | getName: () => string; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | api-docs 3 | index.*.*js* 4 | array.*.*js* 5 | string.*.*js* 6 | types 7 | /Support 8 | *.tgz 9 | tslib.es6-* 10 | 11 | # in case gh-pages manually managed 12 | index.html 13 | modules.html 14 | interfaces/ 15 | classes/ 16 | assets/ 17 | functions/ 18 | .nojekyll 19 | 20 | # testing 21 | tests/cache/ 22 | /tests/coverage 23 | 24 | # IDEs and editors 25 | .idea 26 | .vscode 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | 33 | # cache 34 | .eslintcache 35 | tsconfig.tsbuildinfo 36 | .rollup.cache 37 | 38 | # logs 39 | *.log 40 | 41 | # dependencies 42 | node_modules 43 | 44 | # system Files 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /tests/mock/Models/Team.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import User from './User'; 3 | import TeamFactory from '../Factories/TeamFactory'; 4 | 5 | export default class Team extends Model { 6 | public override getName(): string { 7 | return 'Team'; 8 | } 9 | 10 | public override get fillable(): string[] { 11 | return ['*']; 12 | } 13 | 14 | protected override readonly timestamps = false; 15 | 16 | protected override readonly softDeletes = false; 17 | 18 | public $users(): User { 19 | return this.hasMany(User); 20 | } 21 | 22 | public factory(): TeamFactory { 23 | return new TeamFactory; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": [ 4 | "src/index.ts" 5 | ], 6 | "tsconfig": "tsconfig.json", 7 | "out": "api-docs", 8 | "name": "UpfrontJS", 9 | "readme": "./README.md", 10 | "plugin": [], 11 | "includeVersion": true, 12 | "cleanOutputDir": true, 13 | "intentionallyNotExported": [ 14 | "WhereDescription", 15 | "Order", 16 | "Operator", 17 | "Direction", 18 | "Relation", 19 | "WithProperty", 20 | "BooleanOperator", 21 | "BuiltInCastType" 22 | ], 23 | "validation": { 24 | "notExported": true, 25 | "invalidLink": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/mock/Models/Folder.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import FolderFactory from '../Factories/FolderFactory'; 3 | 4 | export default class Folder extends Model { 5 | public id = 0; 6 | 7 | public name = ''; 8 | 9 | public parentId = 0; 10 | 11 | public override get keyType(): 'number' { 12 | return 'number'; 13 | } 14 | 15 | public override getName(): string { 16 | return 'Folder'; 17 | } 18 | 19 | public factory(): FolderFactory { 20 | return new FolderFactory; 21 | } 22 | 23 | public $parent(): Folder { 24 | return this.belongsTo(Folder, 'parentId'); 25 | } 26 | 27 | public $children(): Folder { 28 | return this.hasMany(Folder, 'parentId'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Support/string/is.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check whether the string matches the given string. 3 | * 4 | * @param {string} str 5 | * @param {string|RegExp} compareValue - The Regexp or the string to test for. "*" functions as a wildcard. 6 | * @param {string} ignoreCase - Flag indicating whether the casing should be ignored or not. 7 | * 8 | * @return {boolean} 9 | */ 10 | export default function is(str: string, compareValue: RegExp | string, ignoreCase = false): boolean { 11 | if (typeof compareValue === 'string') { 12 | compareValue = new RegExp( 13 | compareValue.replace(/\*/g, '.*'), 14 | ignoreCase ? 'i' : '' 15 | ); 16 | } 17 | 18 | const match = compareValue.exec(str); 19 | 20 | return !!match && !!match.length && match[0] === str; 21 | } 22 | -------------------------------------------------------------------------------- /src/Support/string/afterLast.ts: -------------------------------------------------------------------------------- 1 | type AfterLast< 2 | S extends string, 3 | ST extends string 4 | > = S extends `${string}${ST}${infer P1}` 5 | ? P1 extends `${string}${ST}${string}` ? AfterLast : P1 6 | : S extends `${string}${ST}` ? '' : S; 7 | 8 | /** 9 | * Get part of the string after the last found instance of the given token. 10 | * 11 | * @param {string} str 12 | * @param {string} token - The string to search for. 13 | * 14 | * @return {string} 15 | */ 16 | export default function afterLast(str: T, token: ST): AfterLast { 17 | if (token === '') return '' as AfterLast; 18 | 19 | return (str.includes(token) 20 | ? str.substring(str.lastIndexOf(token) + token.length, str.length) 21 | : '') as AfterLast; 22 | } 23 | -------------------------------------------------------------------------------- /src/Contracts/RequestMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { SimpleAttributes } from '../Calliope/Concerns/HasAttributes'; 2 | import type { Method, CustomHeaders } from '../Calliope/Concerns/CallsApi'; 3 | import type { QueryParams } from '../Calliope/Concerns/BuildsQuery'; 4 | 5 | type TransformedRequest = { 6 | data?: FormData | SimpleAttributes; 7 | customHeaders?: CustomHeaders; 8 | queryParameters?: Partial | SimpleAttributes; 9 | }; 10 | 11 | export default interface RequestMiddleware { 12 | handle: ( 13 | url: string, 14 | method: Method, 15 | data?: FormData | SimpleAttributes, 16 | customHeaders?: CustomHeaders, 17 | queryParameters?: Partial | SimpleAttributes 18 | ) => Promise | TransformedRequest; 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Code 2 | 3 | on: 4 | pull_request: 5 | types: [ready_for_review, opened, synchronize, reopened] 6 | paths: 7 | - '**/*.ts' 8 | branches: 9 | - main 10 | - 'release/*' 11 | 12 | jobs: 13 | eslint: 14 | timeout-minutes: 10 15 | if: github.event.pull_request.draft == false 16 | runs-on: ubuntu-latest 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 19 | cancel-in-progress: true 20 | steps: 21 | - uses: actions/checkout@v5 22 | - uses: actions/setup-node@v5 23 | with: 24 | cache: 'npm' 25 | - name: Install dependencies 26 | run: npm ci --ignore-scripts 27 | - name: ESLint 28 | run: node node_modules/.bin/eslint . --ext .ts 29 | -------------------------------------------------------------------------------- /src/Support/string/beforeLast.ts: -------------------------------------------------------------------------------- 1 | type BeforeLast = ST extends '' 2 | ? '' 3 | : S extends `${infer P1}${ST}${infer P2}` 4 | ? P1 extends `${string}${ST}${string}` 5 | ? BeforeLast 6 | : P2 extends `${string}${ST}${string}` ? `${P1}${ST}${BeforeLast}` : P1 7 | : S extends `${string}${ST}` ? '' : S; 8 | 9 | /** 10 | * Get the string before the last instance of the found token. 11 | * 12 | * @param {string} str 13 | * @param {string} token - The string to search for. 14 | * 15 | * @return {string} 16 | */ 17 | export default function beforeLast(str: T, token: ST): BeforeLast { 18 | if (token === '') return '' as BeforeLast; 19 | 20 | return (str.includes(token) ? str.substring(0, str.lastIndexOf(token)) : '') as BeforeLast; 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | UpfrontJs logo 3 |

4 | 5 | ![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/upfrontjs/framework?color=%233ac200&include_prereleases&label=latest%20version&sort=semver&style=flat-square) 6 | ![Github stars](https://img.shields.io/github/stars/upfrontjs/framework?color=blue&label=github%20stars&style=flat-square) 7 | ![Npm Downloads](https://img.shields.io/npm/dm/@upfrontjs/framework?label=npm%20downloads&style=flat-square&color=blue) 8 | 9 |

10 | Upfront is modern, scalable data handling solution for the web. Easily manage complex data without compromising developer experience. 11 |

12 | 13 | #### Find out more about upfront over in the [documentation](https://www.upfrontjs.com/). 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | types: [ready_for_review, opened, synchronize, reopened] 6 | paths: 7 | - 'tsconfig.json' 8 | - 'rollup.config.js' 9 | - 'src/**/*.ts' 10 | - 'package-lock.json' 11 | branches: 12 | - main 13 | - 'release/*' 14 | 15 | jobs: 16 | rollup: 17 | timeout-minutes: 10 18 | if: github.event.pull_request.draft == false 19 | runs-on: ubuntu-latest 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 22 | cancel-in-progress: true 23 | steps: 24 | - uses: actions/checkout@v5 25 | - uses: actions/setup-node@v5 26 | with: 27 | cache: 'npm' 28 | - name: Install dependencies 29 | run: npm ci --ignore-scripts 30 | - name: Rollup 31 | run: npm run build 32 | -------------------------------------------------------------------------------- /src/Contracts/FactoryHooks.ts: -------------------------------------------------------------------------------- 1 | import type Model from '../Calliope/Model'; 2 | import type ModelCollection from '../Calliope/ModelCollection'; 3 | 4 | /** 5 | * Interface typehints the possible hooks and states for factories. 6 | */ 7 | export default interface FactoryHooks { 8 | /** 9 | * The factory hook to be called where the value might be changed. 10 | * 11 | * @param {Model|ModelCollection} modelOrCollection 12 | */ 13 | afterMaking?: (modelOrCollection: ModelCollection | T) => void; 14 | 15 | /** 16 | * The factory hook to be called where the value might be changed. 17 | * 18 | * @param {Model|ModelCollection} modelOrCollection 19 | */ 20 | afterCreating?: (modelOrCollection: ModelCollection | T) => void; 21 | 22 | /** 23 | * The class can be indexed by strings 24 | */ 25 | [method: string]: CallableFunction | undefined; 26 | } 27 | -------------------------------------------------------------------------------- /src/Support/function/poll.ts: -------------------------------------------------------------------------------- 1 | export default async function poll( 2 | fn: () => Promise, 3 | wait: number | ((result: T, attempts: number) => number) = 0, 4 | until?: Date | ((result: T, attempts: number) => boolean) 5 | ): Promise { 6 | let attempts = 0; 7 | 8 | return new Promise((resolve, reject) => { 9 | const check = async () => { 10 | try { 11 | attempts++; 12 | const result = await fn(); 13 | 14 | if (until && (until instanceof Date ? new Date() >= until : until(result, attempts))) { 15 | resolve(result); 16 | return; 17 | } 18 | 19 | setTimeout(() => void check(), typeof wait === 'function' ? wait(result, attempts) : wait); 20 | } catch (err: unknown) { 21 | reject(err); 22 | } 23 | }; 24 | 25 | void check(); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "importHelpers": true, 5 | "module": "ESNext", 6 | "sourceMap": true, 7 | "downlevelIteration": true, 8 | "esModuleInterop": true, 9 | "lib": [ 10 | "ESNext", 11 | "DOM" 12 | ], 13 | "target": "ES6", 14 | "noEmitOnError": true, 15 | "moduleResolution": "node", 16 | "removeComments": false, 17 | "pretty": true, 18 | "allowSyntheticDefaultImports": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noPropertyAccessFromIndexSignature": false, 21 | "resolveJsonModule": true, 22 | }, 23 | "include": [ 24 | "src/*.ts", 25 | "jest.config.ts", 26 | "rollup.config.ts" 27 | ], 28 | "exclude": [ 29 | "**/node_modules/", 30 | "**/.*" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/Support/string/index.ts: -------------------------------------------------------------------------------- 1 | export { default as after } from './after'; 2 | export { default as afterLast } from './afterLast'; 3 | export { default as before } from './before'; 4 | export { default as beforeLast } from './beforeLast'; 5 | export { default as camel } from './camel'; 6 | export { default as finish } from './finish'; 7 | export { default as includesAll } from './includesAll'; 8 | export { default as is } from './is'; 9 | export { default as isUuid } from './isUuid'; 10 | export { default as kebab } from './kebab'; 11 | export { default as limit } from './limit'; 12 | export { default as pascal } from './pascal'; 13 | export { default as plural } from './plural'; 14 | export { default as singular } from './singular'; 15 | export { default as snake } from './snake'; 16 | export { default as start } from './start'; 17 | export { default as title } from './title'; 18 | export { default as ucFirst } from './ucFirst'; 19 | export { default as uuid } from './uuid'; 20 | export { default as words } from './words'; 21 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: https://raw.githubusercontent.com/upfrontjs/design/main/upfrontjs.png 4 | heroText: 5 | tagline: Isomorphic data handling framework complementary to active record systems. 6 | actions: 7 | - text: Get Started → 8 | link: /getting-started/ 9 | type: primary 10 | features: 11 | - title: Simple 12 | details: The syntax naturally lends itself to how you work with objects from ORM frameworks. 13 | - title: Robust 14 | details: Typescript and vast amount of testing and ensures no matter how big project you drop this in, it's going to work. 15 | - title: Customisable 16 | details: Written with the developer in mind. While keeping it simple, you're not locked into any patterns. 17 | footer: MIT Licensed | Copyright © 2020-present Nandor Kraszlan 18 | --- 19 | 20 |

Supported by

21 | 22 |
23 | Jet Brains logo 24 |
25 | -------------------------------------------------------------------------------- /src/Contracts/AttributeCaster.ts: -------------------------------------------------------------------------------- 1 | import type { Attributes } from '../Calliope/Concerns/HasAttributes'; 2 | 3 | /** 4 | * Interface describes what's expected to be implemented 5 | * by a class that is tasked with value casting. 6 | * 7 | * @see CastsAttributes.prototype.implementsCaster 8 | */ 9 | export default interface AttributeCaster { 10 | /** 11 | * Transform the attribute from the underlying model value and return it. 12 | * 13 | * @param {any} value - the value to return 14 | * @param {object} attributes - receives a clone of the raw attributes 15 | * 16 | * @return {any} 17 | */ 18 | get: (value: unknown, attributes: Attributes) => unknown; 19 | 20 | /** 21 | * Transform the attribute to its underlying model values and return it. 22 | * 23 | * @param {any} value - the value to set 24 | * @param {object} attributes - receives a clone of the raw attributes 25 | * 26 | * @return {any} 27 | */ 28 | set: (value: unknown, attributes: Attributes) => unknown; 29 | } 30 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@commitlint/types').UserConfig} 3 | */ 4 | module.exports = { 5 | extends: ['@commitlint/config-conventional'], 6 | 7 | rules: { 8 | 'scope-enum': [ 9 | 2, 10 | 'always', 11 | [ 12 | 'attributes', // guarding and casting can also go under attributes 13 | 'global-config', 14 | 'exception', 15 | 'events', 16 | 'services', 17 | 'helpers', 18 | 'collection', 19 | 'model', 20 | 'model-collection', 21 | 'ancestry-collection', 22 | 'paginator', 23 | 'factory', 24 | 'query-builder', 25 | 'timestamps', // soft-deletes can also go under timestamps 26 | 'relations', 27 | 'api-calls', 28 | 'deps', 29 | 'deps-dev', 30 | 'internal' // things that are not meant to be used outside the package 31 | ] 32 | ], 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Nandor Kraszlan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/services/api-response-handler.md: -------------------------------------------------------------------------------- 1 | # Api Response Handler 2 | 3 | ApiResponseHandler is responsible for handling the [`ApiResponse`](./readme.md#apiresponse) that was returned by the [`ApiCaller`](./readme.md#apicaller). It is the implementation of the [`HandlesApiResponse`](./readme.md#handlesapiresponse) interface that is used by default. 4 | 5 | You may [override its methods](./readme.md#using-custom-services) to add or [customise its functionality](./readme.md#extending-services). 6 | 7 | On top of the `HandlesApiResponse`'s `handle` method for the sake of brevity the following methods have been added: 8 | 9 | #### handleSuccess 10 | 11 | The `handleSuccess` method attempts to parse the [`ApiResponse`](./readme.md#apiresponse) and returns its value. 12 | 13 | #### handleError 14 | 15 | The `handleError` is an async method that throws the captured error from [handleSuccess](#handlesuccess). 16 | 17 | #### handleFinally 18 | 19 | The `handleFinally` method is an empty function for the sake of type suggestion when customising with any logic after the requests. This runs regardless of whether an error was throw or not in the previous [handleError](#handleerror) method. 20 | -------------------------------------------------------------------------------- /tests/mock/Models/Contract.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import User from './User'; 3 | import ContractFactory from '../Factories/ContractFactory'; 4 | import Team from './Team'; 5 | 6 | type ContractableType = 'team' | 'user'; 7 | 8 | export default class Contract extends Model { 9 | public contractableId?: number; 10 | 11 | public contractableType?: ContractableType; 12 | 13 | public contractable?: Team | User; 14 | 15 | public override getName(): string { 16 | return 'Contract'; 17 | } 18 | 19 | public override get fillable(): string[] { 20 | return ['*']; 21 | } 22 | 23 | public factory(): ContractFactory { 24 | return new ContractFactory; 25 | } 26 | 27 | public $user(): User { 28 | return this.hasOne(User); 29 | } 30 | 31 | /** 32 | * Entities that may be contracted 33 | */ 34 | public $contractable(): this { 35 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 36 | return this.morphTo((self, _data) => { 37 | return self.contractableType === 'team' ? Team : User; 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Support/initialiser.test.ts: -------------------------------------------------------------------------------- 1 | import * as init from '../../src/Support/initialiser'; 2 | import Collection from '../../src/Support/Collection'; 3 | import Paginator from '../../src/Support/Paginator'; 4 | import User from '../mock/Models/User'; 5 | import FactoryBuilder from '../../src/Calliope/Factory/FactoryBuilder'; 6 | import { describe, expect, it } from 'vitest'; 7 | 8 | describe('initialiser helpers', () => { 9 | describe('collect()', () => { 10 | it('should create a collection by calling the collect() helper method', () => { 11 | expect(init.collect([1, 2])).toBeInstanceOf(Collection); 12 | expect(init.collect([1, 2]).first()).toBe(1); 13 | }); 14 | }); 15 | 16 | describe('paginate()', () => { 17 | it('should create a paginator by calling the paginate() helper method', () => { 18 | expect(init.paginate([1, 2], 10, false)).toBeInstanceOf(Paginator); 19 | }); 20 | }); 21 | 22 | describe('factory()', () => { 23 | it('should create a factory builder by calling the factory() helper method', () => { 24 | expect(init.factory(User)).toBeInstanceOf(FactoryBuilder); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/zeke/semantic-pull-requests#configuration 2 | 3 | # Always validate the PR title AND all the commits 4 | titleAndCommits: true 5 | 6 | # Allow use of Merge commits (eg on github: "Merge branch 'master' into feature/ride-unicorns") 7 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 8 | allowMergeCommits: true 9 | 10 | # Allow use of Revert commits (eg on github: "Revert "feat: ride unicorns"") 11 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 12 | allowRevertCommits: true 13 | 14 | # Scopes matching the ones defined in .commitlintrc.js file 15 | scopes: 16 | - 'attributes' # guarding and casting can also go under attributes 17 | - 'global-config' 18 | - 'exception' 19 | - 'events' 20 | - 'services' 21 | - 'helpers' 22 | - 'collection' 23 | - 'model' 24 | - 'model-collection' 25 | - 'ancestry-collection' 26 | - 'paginator' 27 | - 'factory' 28 | - 'query-builder' 29 | - 'timestamps' # soft-deletes can also go under timestamps 30 | - 'relations' 31 | - 'api-calls' 32 | - 'deps' 33 | - 'deps-dev' 34 | - 'internal' # things that are not meant to be used outside of the package 35 | -------------------------------------------------------------------------------- /src/Calliope/Factory/Factory.ts: -------------------------------------------------------------------------------- 1 | import type Model from '../Model'; 2 | import GlobalConfig from '../../Support/GlobalConfig'; 3 | import type FactoryHooks from '../../Contracts/FactoryHooks'; 4 | import type { ResolvableAttributes } from './FactoryBuilder'; 5 | 6 | export default class Factory implements FactoryHooks { 7 | [method: string]: CallableFunction; 8 | 9 | /** 10 | * The instance of the randomisation library if set. 11 | */ 12 | public random? = new GlobalConfig().get('randomDataGenerator'); 13 | 14 | /** 15 | * Define the model's default attributes. 16 | * 17 | * @param {Model} _emptyModel - an empty instance of the target model. 18 | * @param {number} _loopIndex - the index of the current loop. 19 | * 20 | * @return {Attributes} 21 | */ 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | public definition(_emptyModel: T, _loopIndex: number): ResolvableAttributes { 24 | return {}; 25 | } 26 | 27 | /** 28 | * Get the name of this factory class. 29 | * 30 | * @return {string} 31 | */ 32 | public getClassName(): string { 33 | return this.constructor.name; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | **Search terms** 15 | The terms you searched for in the issues before opening a new issue. 16 | 17 | **Is your feature request related to a problem? Please describe.** 18 | A clear and concise description of what the problem is. Ex. When using ... I always have to [...] 19 | 20 | **Describe the solution you'd like** 21 | A clear and concise description of what you want to happen. 22 | 23 | **Describe how this would benefit the project/others** 24 | A clear and concise description of how this will improve the library or the work of others. 25 | Ex. This shortens the frequently repeated method calls when the developer [...] 26 | 27 | **Describe alternatives you've considered** 28 | A clear and concise description of any alternative solutions or features you've considered and whether there is a workaround currently. 29 | 30 | **Additional context** 31 | Add any other context or screenshots about the feature request here. 32 | -------------------------------------------------------------------------------- /src/Contracts/HandlesApiResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The http library agnostic response. 3 | */ 4 | export interface ApiResponse< 5 | T = any[] | Record | string | null 6 | > extends Pick { 7 | /** 8 | * The parsed response content. 9 | * (in the case of libraries like axios) 10 | */ 11 | data?: T; 12 | 13 | /** 14 | * The request that got this response. 15 | */ 16 | request?: Record & (RequestInit | XMLHttpRequest); 17 | 18 | /** 19 | * The url the request was sent to. 20 | */ 21 | url?: string; 22 | 23 | /** 24 | * Additional information. 25 | */ 26 | [key: string]: any; 27 | 28 | /** 29 | * The fetch json method resolving to the given type. 30 | */ 31 | json?: () => Promise>; 32 | } 33 | 34 | /** 35 | * Interface prescribes what's expected to be implemented 36 | * by an object that is used for handling the requests. 37 | */ 38 | export default interface HandlesApiResponse { 39 | /** 40 | * Handle the promised response. 41 | * 42 | * @param promise 43 | * 44 | * @return {Promise} 45 | */ 46 | handle: (promise: Promise) => Promise; 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/deploy-api-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Api Docs 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src/**/*.ts' 7 | branches: 8 | - main 9 | 10 | jobs: 11 | deploy: 12 | timeout-minutes: 10 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - uses: actions/setup-node@v5 17 | with: 18 | cache: 'npm' 19 | - name: Install dependencies 20 | run: npm ci --ignore-scripts 21 | - name: Build api docs 22 | run: npm run docs:api 23 | - name: Update gh-pages branch 24 | run: | 25 | git config --global user.email "nandor.kraszlan@gmail.com" 26 | git config --global user.name "Nandor Kraszlan" 27 | rm -rf types/ 28 | cp -r ./api-docs/* ./ 29 | git add -f assets 30 | git add -f classes 31 | git add -f interfaces 32 | git add -f functions 33 | git add -f types 34 | touch .nojekyll 35 | git add -f .nojekyll 36 | git add -f index.html 37 | git add -f modules.html 38 | git fetch 39 | git switch gh-pages 40 | git commit -m "Updates from ${{ github.ref }} - {{ github.sha }}" --no-verify 41 | git merge origin/main 42 | git push 43 | -------------------------------------------------------------------------------- /src/Contracts/Configuration.ts: -------------------------------------------------------------------------------- 1 | import type ApiCaller from './ApiCaller'; 2 | import type HandlesApiResponse from './HandlesApiResponse'; 3 | import type RequestMiddleware from './RequestMiddleware'; 4 | 5 | /** 6 | * Interface serves as a typehint to see what might be in the config. 7 | * 8 | * @link {GlobalConfig.configuration} 9 | */ 10 | export default interface Configuration { 11 | /** 12 | * The ApiCaller used by the library. 13 | */ 14 | api?: new () => ApiCaller; 15 | 16 | /** 17 | * The HandlesApiResponse used by the library. 18 | */ 19 | apiResponseHandler?: new () => HandlesApiResponse; 20 | 21 | /** 22 | * The date time library to be used. 23 | * 24 | * @type {any} - expects a function or class constructor 25 | */ 26 | datetime?: CallableFunction | (new (arg?: any) => any); 27 | 28 | /** 29 | * The base url endpoint where the backend api is located. 30 | */ 31 | baseEndPoint?: string; 32 | 33 | /** 34 | * The headers to be merged into request configuration. 35 | */ 36 | headers?: HeadersInit; 37 | 38 | /** 39 | * The randomisation library made available in the Factory classes if set. 40 | */ 41 | randomDataGenerator?: any; 42 | 43 | /** 44 | * Middleware executed just before values being passed to the ApiCaller 45 | * 46 | * @see {CallsApi.prototype.call} 47 | */ 48 | requestMiddleware?: RequestMiddleware; 49 | } 50 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ### 🔗 Linked issue 9 | 10 | 11 | 12 | ### ❓ Type of change 13 | 14 | 15 | 16 | - [ ] 📖 Documentation (updates to the documentation or readme) 17 | - [ ] 🐞 Bug fix (a non-breaking change that fixes an issue) 18 | - [ ] 👌 Enhancement (improving an existing functionality like performance) 19 | - [ ] ✨ New feature (a non-breaking change that adds functionality) 20 | - [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change) 21 | 22 | ### 📚 Description 23 | 24 | 25 | 26 | 27 | 28 | ### 📝 Checklist 29 | 30 | 31 | 32 | 33 | 34 | - [ ] I have linked an issue or discussion. 35 | - [ ] I have updated the documentation accordingly. 36 | 37 | -------------------------------------------------------------------------------- /tests/mock/Factories/UserFactory.ts: -------------------------------------------------------------------------------- 1 | import Factory from '../../../src/Calliope/Factory/Factory'; 2 | import type { Attributes } from '../../../src/Calliope/Concerns/HasAttributes'; 3 | import Team from '../Models/Team'; 4 | import type User from '../Models/User'; 5 | 6 | export default class UserFactory extends Factory { 7 | public override definition(_model: User, index: number): Attributes { 8 | return { 9 | name: 'username ' + String(index) 10 | }; 11 | } 12 | 13 | public withTeam(): Attributes { 14 | const team = Team.factory().createOne(); 15 | 16 | return { 17 | // the foreign key is required at the same time with the team as when it 18 | // calls the relation at constructor we need to know the foreign key value upfront 19 | teamId: team.getKey(), 20 | // the team has to include the primary key to ensure sync. 21 | /** @see {HasRelations.prototype.addRelation} */ 22 | team: team // both model and model attributes are acceptable 23 | }; 24 | } 25 | 26 | public nameOverridden(): Attributes { 27 | return { 28 | name: 'overridden name' 29 | }; 30 | } 31 | 32 | public calledWithArguments(model: User, index: number): Attributes { 33 | return { 34 | modelAttribute: model.getName(), 35 | index 36 | }; 37 | } 38 | 39 | public resolvedName(): Attributes { 40 | return { 41 | name: () => 'resolved name' 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Switch to the following when reached V1 2 | 3 | #name: Release 4 | # 5 | #on: 6 | # push: 7 | # branches: 8 | # - 'release/*' 9 | # 10 | #jobs: 11 | # publish: 12 | # runs-on: ubuntu-latest 13 | # steps: 14 | # - uses: actions/checkout@v3 15 | # with: 16 | # fetch-depth: 0 17 | # - uses: actions/setup-node@v3 18 | # with: 19 | # node-version: 15 20 | # check-latest: true 21 | # registry-url: https://registry.npmjs.org/ 22 | # - name: Build 23 | # run: npm ci && npm run build 24 | # 25 | # - name: Release 26 | # env: 27 | # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | # - run: npx semantic-release --debug 30 | 31 | name: Release 32 | 33 | on: 34 | release: 35 | types: [created] 36 | 37 | jobs: 38 | publish: 39 | timeout-minutes: 10 40 | runs-on: ubuntu-latest 41 | permissions: 42 | contents: read 43 | id-token: write 44 | steps: 45 | - uses: actions/checkout@v5 46 | - uses: actions/setup-node@v5 47 | with: 48 | registry-url: https://registry.npmjs.org/ 49 | - name: Install dependencies 50 | run: npm ci --ignore-scripts 51 | - name: Build library 52 | run: npm run build 53 | - name: Publish library 54 | run: npm publish --provenance --access public 55 | env: 56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | - uses: actions/setup-node@v5 58 | with: 59 | registry-url: https://npm.pkg.github.com/ 60 | - run: npm publish --access public 61 | env: 62 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | -------------------------------------------------------------------------------- /src/Support/function/transformKeys.ts: -------------------------------------------------------------------------------- 1 | import { camel, snake } from '../string'; 2 | import isObjectLiteral from './isObjectLiteral'; 3 | 4 | /** 5 | * Utility to recursively format the keys according to the server argument. 6 | * 7 | * @param {object} attributes - The object which should be formatted. 8 | * @param {'camel' | 'snake'} [casing='camel'] - Whether to use camelCase or snake_case. 9 | * 10 | * @return {object} 11 | */ 12 | export default function transformKeys>( 13 | attributes: Record, casing?: 'camel' | 'snake' 14 | ): T; 15 | export default function transformKeys( 16 | attributes: Record, 17 | casing: 'camel' | 'snake' = 'camel' 18 | ): Record { 19 | const dataWithKeyCasing: Record = {}; 20 | 21 | Object.keys(attributes).forEach(key => { 22 | dataWithKeyCasing[casing === 'camel' ? camel(key) : snake(key)] = 23 | // If attributes[key] is a model/collection or otherwise a constructible structure 24 | // it would count as an object literal, so we add a not Object constructor. 25 | // check. This prevents it from becoming an object literal, and in turn 26 | // its prototype chain keys turning into the new object's own keys. 27 | isObjectLiteral(attributes[key]) && (attributes[key] as new() => any).constructor === Object 28 | ? transformKeys(attributes[key] as Record, casing) 29 | : Array.isArray(attributes[key]) 30 | ? (attributes[key] as any[]).map(item => { 31 | // same check as above 32 | return isObjectLiteral(item) && item.constructor === Object 33 | ? transformKeys(item, casing) 34 | : item; 35 | }) 36 | : attributes[key]; 37 | }); 38 | 39 | return dataWithKeyCasing; 40 | } 41 | -------------------------------------------------------------------------------- /src/Contracts/ApiCaller.ts: -------------------------------------------------------------------------------- 1 | import type { Method, CustomHeaders } from '../Calliope/Concerns/CallsApi'; 2 | import type { ApiResponse } from './HandlesApiResponse'; 3 | 4 | /** 5 | * Interface prescribes what's expected to be implemented 6 | * by an object that initiates api requests. 7 | * 8 | * @link {API.prototype.call} 9 | * @link {CallsApi.prototype.call} 10 | */ 11 | export default interface ApiCaller { 12 | /** 13 | * Optional property containing request configuration object. 14 | * 15 | * @type {Partial?} 16 | */ 17 | requestOptions?: Partial; 18 | 19 | /** 20 | * If defined it should return a request configuration object. 21 | * 22 | * @param {string} url - The endpoint the request goes to. 23 | * @param {Method} method - The method the request uses. 24 | * @param {object=} data - The optional data to send with the request. 25 | * 26 | * @return {Partial} 27 | */ 28 | initRequest?: ( 29 | url: string, 30 | method: Method, 31 | data?: FormData | Record, 32 | queryParameters?: Record 33 | ) => Partial | Promise>; 34 | 35 | /** 36 | * The expected signature of the call method. 37 | * 38 | * @param {string} url - The endpoint the request goes to. 39 | * @param {Method} method - The method the request uses. 40 | * @param {object=} data - The optional data to send with the request. 41 | * @param {object=} customHeaders - Custom headers to merge into the request. 42 | * 43 | * @return {Promise} 44 | */ 45 | call: ( 46 | url: string, 47 | method: Method, 48 | data?: FormData | Record, 49 | customHeaders?: CustomHeaders, 50 | queryParameters?: Record 51 | ) => Promise; 52 | } 53 | -------------------------------------------------------------------------------- /docs/testing/readme.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Upfront is fully tested to give as much confidence in the code as possible. To carry on this ethos in your application, upfront offers helpful tools for testing with mock data and test outgoing requests. 4 | 5 | ## Testing service implementations 6 | Swapping out [services](../services/readme.md) of upfront is easy as setting them in the [GlobalConfig](../helpers/global-config.md). 7 | For example if you want to test your custom method you added to a model, you could do something similar to the following: 8 | 9 | 10 | 11 | 12 | 13 | ```js 14 | import { GlobalConfig } from '@upfrontjs/framework'; 15 | import User from '@/Models' 16 | 17 | const config = new GlobalConfig; 18 | 19 | describe('customAjaxMethod()', () => { 20 | const mockFunc = vi.fn(); 21 | const user = new User; 22 | config.set('api', { 23 | handle: mockFunc 24 | }); 25 | 26 | it('should initiate a GET request', async () => { 27 | await user.customAjaxRequest(); 28 | 29 | expect(mockFunc).toHaveBeenCalledWith('url', 'get', 'data', 'customHeaders') 30 | }); 31 | }); 32 | ``` 33 | 34 | 35 | 36 | 37 | ```ts 38 | import { GlobalConfig } from '@upfrontjs/framework'; 39 | import type { Configuration } from '@upfrontjs/framework'; 40 | import type MyConfig from '@/MyConfig'; 41 | import User from '@/Models/User' 42 | 43 | const config: GlobalConfig = new GlobalConfig; 44 | 45 | describe('customAjaxMethod()', () => { 46 | const mockFunc = vi.fn(); 47 | const user = new User; 48 | config.set('api', { 49 | handle: mockFunc 50 | }); 51 | 52 | it('should initiate a GET request', async () => { 53 | await user.customAjaxRequest(); 54 | 55 | expect(mockFunc).toHaveBeenCalledWith('url', 'get', 'data', 'customHeaders') 56 | }); 57 | }); 58 | ``` 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /docs/.vuepress/links.ts: -------------------------------------------------------------------------------- 1 | import { SidebarConfig } from "vuepress"; 2 | import { NavbarConfig } from "@vuepress/theme-default/lib/shared/nav"; 3 | 4 | const sidebar: SidebarConfig = [ 5 | { 6 | text: 'Prologue', 7 | collapsible: true, 8 | children: [ 9 | '/prologue/contributing', 10 | '/prologue/project-policies' 11 | ] 12 | }, 13 | { 14 | text: 'Getting Started', 15 | collapsible: true, 16 | children: [ 17 | '/getting-started/', 18 | '/getting-started/installation' 19 | ] 20 | }, 21 | { 22 | text: 'Calliope', 23 | collapsible: true, 24 | children: [ 25 | '/calliope/', 26 | '/calliope/attributes', 27 | '/calliope/api-calls', 28 | '/calliope/query-building', 29 | '/calliope/relationships', 30 | '/calliope/timestamps', 31 | '/calliope/model-collection', 32 | '/calliope/ancestry-collection' 33 | ] 34 | }, 35 | { 36 | text: 'Services', 37 | collapsible: true, 38 | children: [ 39 | '/services/', 40 | '/services/api', 41 | '/services/api-response-handler' 42 | ] 43 | }, 44 | { 45 | text: 'Helpers', 46 | collapsible: true, 47 | children: [ 48 | '/helpers/', 49 | '/helpers/collection', 50 | '/helpers/pagination', 51 | '/helpers/global-config', 52 | '/helpers/event-emitter' 53 | ] 54 | }, 55 | { 56 | text: 'Testing', 57 | collapsible: true, 58 | children: [ 59 | '/testing/', 60 | '/testing/factories' 61 | ] 62 | }, 63 | { 64 | text: 'Cookbook', 65 | link: '/cookbook' 66 | }, 67 | ] 68 | 69 | const navbar: NavbarConfig = [ 70 | { text: 'API', link: 'https://api.upfrontjs.com/framework', target:'_blank' } 71 | ] 72 | 73 | export default { 74 | sidebar, 75 | navbar 76 | } 77 | -------------------------------------------------------------------------------- /tests/mock/Models/User.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import Team from './Team'; 3 | import UserFactory from '../Factories/UserFactory'; 4 | import Shift from './Shift'; 5 | import Contract from './Contract'; 6 | import { default as FileModel } from './File'; 7 | 8 | export default class User extends Model { 9 | public override getName(): string { 10 | return 'User'; 11 | } 12 | 13 | public override get endpoint(): string { 14 | return 'users'; 15 | } 16 | 17 | public override get fillable(): string[] { 18 | return ['*']; 19 | } 20 | 21 | public factory(): UserFactory { 22 | return new UserFactory; 23 | } 24 | 25 | public $invalidRelationDefinition(): Team { 26 | return new Team(); 27 | } 28 | 29 | public $team(): Team { 30 | return this.belongsTo(Team, 'teamId'); 31 | } 32 | 33 | public $teamWithoutForeignKey(): Team { 34 | return this.belongsTo(Team); 35 | } 36 | 37 | public $contract(): Contract { 38 | return this.hasOne(Contract, 'userId'); 39 | } 40 | 41 | public $contractWithoutForeignKey(): Contract { 42 | return this.hasOne(Contract); 43 | } 44 | 45 | public $shifts(): Shift { 46 | return this.hasMany(Shift, 'userId'); 47 | } 48 | 49 | public $shiftsWithoutForeignKey(): Shift { 50 | return this.hasMany(Shift); 51 | } 52 | 53 | public $inverseShifts(): Shift { 54 | return this.belongsToMany(Shift, 'users'); 55 | } 56 | 57 | public $inverseShiftsWithoutRelationName(): Shift { 58 | return this.belongsToMany(Shift); 59 | } 60 | 61 | public $files(): FileModel { 62 | return this.morphMany(FileModel, 'User'); 63 | } 64 | 65 | public $filesWithoutMorphName(): FileModel { 66 | return this.morphMany(FileModel); 67 | } 68 | 69 | public $file(): FileModel { 70 | return this.morphOne(FileModel, 'User'); 71 | } 72 | 73 | public $fileWithoutMorphName(): FileModel { 74 | return this.morphOne(FileModel); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Support/function/retry.ts: -------------------------------------------------------------------------------- 1 | import value from './value'; 2 | import type { MaybeArray } from '../type'; 3 | 4 | /** 5 | * Utility to re-run the given promise function until it resolves 6 | * or until the number of tries was exceeded. 7 | * 8 | * @param fn - The function returning a promise to be called. 9 | * @param {number} [maxRetries=3] - The number of times the function should be retried. 10 | * If an array is given, it will be used as the wait time between each try. 11 | * @param {number|function} [timeout=0] - The wait time between attempts in milliseconds. 12 | * If 0, it will not wait. 13 | * If a function, it will be called with the number of retries left. 14 | * @param {function} [errorCheck] - A function returning a boolean depending on if error should be retried. 15 | * 16 | * @example 17 | * // try up to four times with 2s delay between each try 18 | * const model = await retry(Model.find(1), 4, 2000); 19 | * 20 | * @return {Promise} 21 | */ 22 | 23 | export default async function retry( 24 | fn: () => Promise, 25 | maxRetries: MaybeArray = 3, 26 | timeout: number | ((currentAttemptCount: number) => number) = 0, 27 | errorCheck?: (err: unknown) => boolean 28 | ): Promise { 29 | return new Promise((resolve, reject) => { 30 | let retries = 0; 31 | 32 | const attempt = () => { 33 | fn().then(resolve).catch((err: unknown) => { 34 | if (errorCheck && !errorCheck(err)) { 35 | reject(err); 36 | } 37 | 38 | const timeOutValue = Array.isArray(maxRetries) ? maxRetries[retries]! : timeout; 39 | 40 | if (retries++ < (Array.isArray(maxRetries) ? maxRetries.length : maxRetries)) { 41 | if (timeOutValue) { 42 | setTimeout(attempt, value(timeOutValue, retries)); 43 | } else { 44 | attempt(); 45 | } 46 | } else { 47 | reject(err); 48 | } 49 | }); 50 | }; 51 | 52 | attempt(); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | types: [ready_for_review, opened, synchronize, reopened] 6 | paths: 7 | - '**/tsconfig.json' 8 | - 'tests/**/*.ts' 9 | - 'src/**/*.ts' 10 | - 'package-lock.json' 11 | branches: 12 | - main 13 | - 'release/*' 14 | 15 | jobs: 16 | setup-test: 17 | if: github.event.pull_request.draft == false 18 | runs-on: ubuntu-latest 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 21 | cancel-in-progress: true 22 | steps: 23 | - uses: actions/checkout@v5 24 | test-browser: 25 | needs: setup-test 26 | timeout-minutes: 10 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v5 30 | - uses: actions/setup-node@v5 31 | with: 32 | node-version: 'lts/*' 33 | cache: 'npm' 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Run tests on jsdom 37 | run: npm run test -- --environment=jsdom 38 | test-node: 39 | needs: setup-test 40 | timeout-minutes: 10 41 | runs-on: ubuntu-latest 42 | strategy: 43 | matrix: 44 | node: ['lts/*', 'current'] 45 | fail-fast: false 46 | steps: 47 | - uses: actions/checkout@v5 48 | - uses: actions/setup-node@v5 49 | with: 50 | node-version: ${{ matrix.node }} 51 | cache: 'npm' 52 | - name: Install dependencies 53 | run: npm ci 54 | - name: Run tests on node ${{ matrix.node }} 55 | run: npm run test 56 | test-edge: 57 | needs: setup-test 58 | timeout-minutes: 10 59 | runs-on: ubuntu-latest 60 | strategy: 61 | matrix: 62 | node: ['lts/*', 'current'] 63 | fail-fast: false 64 | steps: 65 | - uses: actions/checkout@v5 66 | - uses: actions/setup-node@v5 67 | with: 68 | node-version: ${{ matrix.node }} 69 | cache: 'npm' 70 | - name: Install dependencies 71 | run: npm ci 72 | - name: Run tests on node ${{ matrix.node }} 73 | run: npm run test -- --environment edge-runtime 74 | -------------------------------------------------------------------------------- /tests/Support/array.test.ts: -------------------------------------------------------------------------------- 1 | import Collection from '../../src/Support/Collection'; 2 | import Paginator from '../../src/Support/Paginator'; 3 | import * as arr from '../../src/Support/array'; 4 | import { describe, expect, it } from 'vitest'; 5 | import '../../src/array'; 6 | // The Array/Array.prototype methods are the same as the helper methods so can be tested at the same time 7 | 8 | describe('array helpers', () => { 9 | describe('collect()', () => { 10 | it('should return a collection by calling collect()', () => { 11 | expect([1, 2].collect()).toBeInstanceOf(Collection); 12 | }); 13 | 14 | it('should create a collection by calling collect() statically', () => { 15 | expect(Array.collect()).toBeInstanceOf(Collection); 16 | }); 17 | }); 18 | 19 | describe('paginate()', () => { 20 | it('should return a paginator by calling paginate() on an array', () => { 21 | expect([1, 2].paginate()).toBeInstanceOf(Paginator); 22 | }); 23 | 24 | it('should return a paginator by calling paginate() statically', () => { 25 | expect(Array.paginate([1, 2])).toBeInstanceOf(Paginator); 26 | }); 27 | }); 28 | 29 | describe('wrap()', () => { 30 | it('should wrap a value in an array if it isn\'t already an array', () => { 31 | expect(arr.wrap([1, 2])).toStrictEqual([1, 2]); 32 | expect(arr.wrap(1)).toStrictEqual([1]); 33 | expect(arr.wrap([])).toStrictEqual([]); 34 | expect(arr.wrap([[]])).toStrictEqual([[]]); 35 | 36 | expect(Array.wrap([1, 2])).toStrictEqual([1, 2]); 37 | expect(Array.wrap(1)).toStrictEqual([1]); 38 | expect(Array.wrap()).toStrictEqual([]); 39 | expect(Array.wrap([[]])).toStrictEqual([[]]); 40 | }); 41 | 42 | it('should wrap falsy values', () => { 43 | [false, '', 0, null, undefined].forEach(val => { 44 | expect(arr.wrap(val)).toStrictEqual([val]); 45 | }); 46 | }); 47 | 48 | it('should return an empty array if no argument given', () => { 49 | expect(arr.wrap()).toStrictEqual([]); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /docs/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## What is it? 4 | It's a model centric data handling solution. With it, you can write expressive, readable, concise syntax that you already know and love from back-end MVC frameworks while staying back-end agnostic. It provides an elegant interface to complex data with a plethora of features to access it, and additional helpers for common tasks like handling lists, pagination, string manipulation and more. 5 | 6 | ```ts 7 | import User from '@models/User'; 8 | 9 | const students = await User.where('is_student', true).with('grades').get(); 10 | 11 | const excellentStudentNames = students 12 | .filter(student => student.grades.average('value') > 4) 13 | .pluck('name'); 14 | ``` 15 | 16 | Furthermore, the package can be run in multiple environments, including node, react-native, electron etc. 17 | 18 | ## What does it solve? 19 | There are number of solutions out there for fetching data and working with the response. However not all of these might be as maintainable as one would hope. With state management, you might have a getter for users, but those users include all users, meaning for a custom collection you would need a new getter method. An on-demand ajax request written specifically to solve a single issue, while it is simple to do, it quickly gets repetitive and laborious to maintain. [Upfront](./installation.md) solves the above by creating abstraction over the data in a unified api. Just like the above examples it can be used to fetch data on demand or be complimentary to state management libraries. 20 | 21 | ## Caveats 22 | While using this package increases ease of access and cuts down development time, it can also have unintended consequences. By pushing more logic to the client side you may expose backend logic such as data relations. Furthermore, if you're incorrectly implementing the [backend requirements](./installation.md#backend-requirements) you may introduce vulnerabilities such as sql injection. 23 | 24 | --- 25 | 26 | As always you're encouraged to explore the [source](https://github.com/upfrontjs/framework) yourself or look at the [api reference](https://upfrontjs.github.io/framework/) to gain insight on how the package works, and the [tests](https://github.com/upfrontjs/framework/tree/main/tests) to see how it's used. 27 | -------------------------------------------------------------------------------- /docs/calliope/model-collection.md: -------------------------------------------------------------------------------- 1 | # Model Collection 2 | 3 | ModelCollection is a subclass of the [Collection](../helpers/collection.md), therefore all methods are inherited. The following methods have been updated to use either the model's [is](./readme.md#is) method, or the [primary key](./readme.md#getkey) of the model for comparison between models. `unique`, `hasDuplicates`, `duplicates`, `diff`, `only`, `except`, `intersect`, `delete`, `union`, `includes`. The signature of the before mentioned methods has not changed. In addition, the ModelCollection ensures that only [Models](./readme.md) are included in the collection. On top of the collection's methods couple of others has been added that are only relevant to model values. 4 | 5 | ## Methods 6 | 7 | [[toc]] 8 | 9 | #### modelKeys 10 | 11 | The `modelKeys` method returns the [primary key](./readme.md#getkey) of the models on a Collection. 12 | ```js 13 | import User from '@Models/User'; 14 | 15 | const modelCollection = await User.get(); 16 | modelCollection.modelKeys(); // Collection[1, 2, 3, ...ids] 17 | ``` 18 | 19 | #### findByKey 20 | 21 | The `findByKey` method returns the Model or ModelCollection depending on the argument. The method can take the ids as a single argument or as an array or collection. Optionally you may give it a second argument which will be returned if the id is not found in the model collection. 22 | ```js 23 | import User from '@Models/User'; 24 | import { Model } from '@upfrontjs/framework'; 25 | 26 | const defaultModel = new Model; 27 | const modelCollection = await User.get(); 28 | modelCollection.findByKey(1); // User1 29 | modelCollection.findByKey([1, 2]); // ModelCollection[User1, User2] 30 | modelCollection.findByKey(43, defaultModel); // Model 31 | ``` 32 | 33 | #### isModelCollection 34 | 35 | 36 | The `isModelCollection` static method same as the [isCollection](../helpers/collection.md#iscollection) method on the collection, is used to evaluate that the given value is a ModelCollection. 37 | ```js 38 | import { ModelCollection, Collection } from '@upfrontjs/framework'; 39 | 40 | const modelCollection = await User.get(); 41 | ModelCollection.isModelCollection(modelCollection); // true 42 | ModelCollection.isModelCollection([Model1, Model2]); // false 43 | Collection.isCollection(modelCollection); // true 44 | ``` 45 | 46 | --- 47 | 48 | ::: tip 49 | The `map` method returns a ModelCollection if the return of the given callback is a [Model](./readme.md), otherwise it returns a [Collection](../helpers/collection.md). 50 | ::: 51 | -------------------------------------------------------------------------------- /src/Support/type.ts: -------------------------------------------------------------------------------- 1 | /* Utility types for transforming other types */ 2 | import type Model from '../Calliope/Model'; 3 | 4 | /** 5 | * Make the properties defined in the union required. 6 | */ 7 | export type RequireSome, K extends keyof T> = Omit & Required>; 8 | 9 | /** 10 | * Make the properties defined in the union optional. 11 | */ 12 | export type PartialSome, K extends keyof T> = Omit & Partial>; 13 | 14 | /** 15 | * Make the type either the initial value or an array of it. 16 | */ 17 | export type MaybeArray = T | T[]; 18 | 19 | /** 20 | * Make the type either the initial value or a promise of it. 21 | */ 22 | export type MaybePromise = Promise | T; 23 | 24 | /** 25 | * Set every property nested or otherwise to optional. 26 | */ 27 | export type DeepPartial = T extends Record 28 | ? { [P in keyof T]?: DeepPartial } 29 | : T; 30 | 31 | /** 32 | * Get the keys of the given type where the value matches the given argument. 33 | */ 34 | export type KeysMatching = { [K in keyof T]: T[K] extends never ? V : K }[keyof T]; 35 | 36 | /** 37 | * Get the keys of the given type where the value doesn't match the given argument. 38 | */ 39 | export type KeysNotMatching = { [K in keyof T]: T[K] extends V ? never : K }[keyof T]; 40 | 41 | /** 42 | * Make an intersection type from the given object type or interface union. 43 | */ 44 | export type UnionToIntersection> = (T extends any ? (x: T) => any : never) extends ( 45 | x: infer U 46 | ) => any ? U : never; 47 | 48 | /** 49 | * Hack to get the instance type of the given constructable. 50 | * 51 | * Derived from {@link https://github.com/Microsoft/TypeScript/issues/5863|this discussion} 52 | * 53 | * Declaring {@link https://www.typescriptlang.org/docs/handbook/2/functions.html#declaring-this-in-a-function|this} 54 | * 55 | * This can be used like: 56 | * @example 57 | * class Parent { 58 | * public static newInstance(this: T): T['prototype'] { 59 | * return new this 60 | * } 61 | * } 62 | * class Child extends Parent {} 63 | * const child = Child.newInstance(); // Will be the instance type of Child 64 | */ 65 | export type StaticToThis = { 66 | new(...args: any[]): T; 67 | prototype: T; 68 | }; 69 | 70 | /** 71 | * Generic object data type. 72 | */ 73 | export type Data = Record> = T; 74 | -------------------------------------------------------------------------------- /src/Support/function/dataGet.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeArray, Data } from '../type'; 2 | import type Collection from '../Collection'; 3 | 4 | /** 5 | * Utility to safely access values on a deeply nested structure. 6 | * If path doesn't exist, return the default value. 7 | * 8 | * @param {array|object=} data - the structure to search. 9 | * @param {string|string[]} path - the path to the value delimited by `'.'` 10 | * @param {*} defaultValue - the value to return if the path doesn't exist. 11 | * 12 | * @example 13 | * const result1 = dataGet([{key:{prop:1}}], '0.key.prop') // 1; 14 | * const result2 = dataGet([{key:{prop:1}}], '*.key.prop') // [1]; 15 | */ 16 | export default function dataGet( 17 | data: Collection | MaybeArray | undefined = undefined, 18 | path: Collection | MaybeArray, 19 | defaultValue?: T 20 | ): T | undefined { 21 | if (!data) { 22 | return defaultValue; 23 | } 24 | 25 | if (typeof path === 'object' && 'toArray' in path && typeof path.toArray === 'function') { 26 | path = path.toArray(); 27 | } 28 | 29 | const segments = Array.isArray(path) ? path : (path as string).split('.'); 30 | let value = data; 31 | 32 | for (let i = 0; i < segments.length; i++) { 33 | if (segments[i] === '*') { 34 | if (typeof value === 'object' && 'toArray' in value && typeof value.toArray === 'function') { 35 | value = (value as Collection).toArray(); 36 | } 37 | 38 | if (!Array.isArray(value)) { 39 | return defaultValue; 40 | } 41 | 42 | value = value.map((v: Data) => { 43 | return dataGet(v, segments.slice(i + 1), defaultValue)!; 44 | }); 45 | 46 | const stars = segments.slice(i).filter(k => k === '*').length; 47 | 48 | if (stars > 1) { 49 | // every star in lower iterations will be flattened 50 | value = (value as Data[]).flat(stars); 51 | } 52 | 53 | // skip every star and the next key 54 | i += stars + 1; 55 | 56 | // if every result is actually the default value, return the default value 57 | if ((value as Data[]).every(v => String(v) === String(defaultValue))) { 58 | return defaultValue; 59 | } 60 | 61 | continue; 62 | } 63 | 64 | if (!(segments[i]! in value)) { 65 | i = segments.length; 66 | return defaultValue; 67 | } 68 | 69 | value = value[segments[i] as keyof typeof value]; 70 | } 71 | 72 | return value as T; 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Model from './Calliope/Model'; 2 | import Collection from './Support/Collection'; 3 | import ModelCollection from './Calliope/ModelCollection'; 4 | import Paginator from './Support/Paginator'; 5 | import GlobalConfig from './Support/GlobalConfig'; 6 | import Factory from './Calliope/Factory/Factory'; 7 | import ApiResponseHandler from './Services/ApiResponseHandler'; 8 | import API from './Services/API'; 9 | import EventEmitter from './Support/EventEmitter'; 10 | import AncestryCollection from './Calliope/AncestryCollection'; 11 | 12 | export { 13 | Model, 14 | Collection, 15 | ModelCollection, 16 | Paginator, 17 | GlobalConfig, 18 | Factory, 19 | ApiResponseHandler, 20 | API, 21 | EventEmitter, 22 | AncestryCollection 23 | }; 24 | 25 | import type AttributeCaster from './Contracts/AttributeCaster'; 26 | import type HandlesApiResponse from './Contracts/HandlesApiResponse'; 27 | import type ApiCaller from './Contracts/ApiCaller'; 28 | import type Configuration from './Contracts/Configuration'; 29 | import type { 30 | Attributes, 31 | AttributeKeys, 32 | SimpleAttributes, 33 | SimpleAttributeKeys, 34 | RawAttributes, 35 | Getters 36 | } from './Calliope/Concerns/HasAttributes'; 37 | import type FactoryHooks from './Contracts/FactoryHooks'; 38 | import type { CastType } from './Calliope/Concerns/CastsAttributes'; 39 | import type { QueryParams } from './Calliope/Concerns/BuildsQuery'; 40 | import type FormatsQueryParameters from './Contracts/FormatsQueryParameters'; 41 | import type { ApiResponse } from './Contracts/HandlesApiResponse'; 42 | import type { Events, Listener } from './Support/EventEmitter'; 43 | import type { Method, CustomHeaders } from './Calliope/Concerns/CallsApi'; 44 | import type { ResolvableAttributes } from './Calliope/Factory/FactoryBuilder'; 45 | import type { Order } from './Support/Collection'; 46 | import type RequestMiddleware from './Contracts/RequestMiddleware'; 47 | 48 | export type { 49 | AttributeCaster, 50 | ApiCaller, 51 | HandlesApiResponse, 52 | Configuration, 53 | Attributes, 54 | FactoryHooks, 55 | CastType, 56 | QueryParams, 57 | FormatsQueryParameters, 58 | ApiResponse, 59 | Events, 60 | Listener, 61 | AttributeKeys, 62 | Method, 63 | SimpleAttributes, 64 | SimpleAttributeKeys, 65 | CustomHeaders, 66 | RawAttributes, 67 | ResolvableAttributes, 68 | Order, 69 | RequestMiddleware, 70 | Getters 71 | }; 72 | 73 | export * from './Support/type'; 74 | export * from './Support/array'; 75 | export * from './Support/string'; 76 | export * from './Support/function'; 77 | export * from './Support/initialiser'; 78 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: File a bug/issue 3 | labels: [ bug ] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please read the contribution docs before creating a bug report 9 | 👉 https://upfrontjs.com/prologue/contributing.html#issues-and-prs 10 | - type: checkboxes 11 | id: has-searched-issues 12 | attributes: 13 | label: Is there an existing issue for this? 14 | description: Please search to see if an issue already exists for the bug you encountered. 15 | options: 16 | - label: I have searched the existing issues 17 | required: true 18 | - type: textarea 19 | id: search-terms 20 | attributes: 21 | label: Search terms 22 | description: The terms you searched for in the issues before opening a new issue. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: current-behaviour 27 | attributes: 28 | label: Current Behavior 29 | description: A concise description of what you're experiencing. 30 | validations: 31 | required: false 32 | - type: textarea 33 | id: expected-behaviour 34 | attributes: 35 | label: Expected Behavior 36 | description: A concise description of what you expected to happen. 37 | validations: 38 | required: false 39 | - type: textarea 40 | id: environment 41 | attributes: 42 | label: Environment 43 | description: | 44 | examples: 45 | - **OS**: Ubuntu 20.04 46 | - **Node**: 13.14.0 47 | - **npm**: 7.6.3 48 | value: | 49 | - OS: 50 | - Node: 51 | - npm: 52 | render: markdown 53 | validations: 54 | required: false 55 | - type: textarea 56 | id: context 57 | attributes: 58 | label: Anything else? 59 | description: | 60 | Links? References? Anything that will give more context about the issue you are encountering! 61 | 62 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 63 | validations: 64 | required: false 65 | - type: textarea 66 | id: reproduction 67 | attributes: 68 | label: Reproduction 69 | description: Please provide a link to a repo that can reproduce the problem you ran into, unless the issue is obvious and easy to understand without more context. This will make sure your problem can be addressed faster. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "need reproduction" label. If no reproduction is provided it might get closed. 70 | placeholder: Reproduction 71 | validations: 72 | required: false 73 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import type Collection from './Support/Collection'; 2 | import type Paginator from './Support/Paginator'; 3 | import collect from './Support/initialiser/collect'; 4 | import paginate from './Support/initialiser/paginate'; 5 | import wrap from './Support/array/wrap'; 6 | 7 | declare global { 8 | /** 9 | * Globally available methods on Array.prototype. 10 | */ 11 | interface Array { 12 | /** 13 | * Create a collection from the array. 14 | * 15 | * @return {Collection} 16 | */ 17 | collect: () => Collection; 18 | 19 | /** 20 | * Construct a paginator instance. 21 | * 22 | * @return {Paginator} 23 | */ 24 | paginate: () => Paginator; 25 | } 26 | 27 | /** 28 | * Globally available methods on Array. 29 | */ 30 | interface ArrayConstructor { 31 | /** 32 | * Create a collection from the array. 33 | * 34 | * @param {any} items 35 | * 36 | * @return {Collection} 37 | */ 38 | collect: (items?: any[]) => ReturnType; 39 | 40 | /** 41 | * Ensure the given value is an array. 42 | * 43 | * @param {any} value 44 | * 45 | * @return {array}; 46 | */ 47 | wrap: (value?: any) => any[]; 48 | 49 | /** 50 | * Construct a paginator instance. 51 | * 52 | * @param {any} items 53 | * 54 | * @return {Paginator} 55 | */ 56 | paginate: (items: any[]) => ReturnType; 57 | } 58 | } 59 | 60 | if (!('collect' in Array.prototype)) { 61 | Object.defineProperty(Array.prototype, 'collect', { 62 | value: function (): ReturnType { 63 | return collect(this); 64 | } 65 | }); 66 | } 67 | 68 | if (!('collect' in Array)) { 69 | Object.defineProperty(Array, 'collect', { 70 | value: function (items?: any[]): ReturnType { 71 | return collect(items); 72 | } 73 | }); 74 | } 75 | 76 | if (!('paginate' in Array.prototype)) { 77 | Object.defineProperty(Array.prototype, 'paginate', { 78 | value: function (): ReturnType { 79 | return paginate(this as []); 80 | } 81 | }); 82 | } 83 | 84 | if (!('paginate' in Array)) { 85 | Object.defineProperty(Array, 'paginate', { 86 | value: function (items: any[]): ReturnType { 87 | return paginate(items); 88 | } 89 | }); 90 | } 91 | 92 | if (!('wrap' in Array)) { 93 | Object.defineProperty(Array, 'wrap', { 94 | value: function (value?: any) { 95 | return arguments.length ? wrap(value) : wrap(); 96 | } 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /src/Services/ApiResponseHandler.ts: -------------------------------------------------------------------------------- 1 | import type HandlesApiResponse from '../Contracts/HandlesApiResponse'; 2 | import type { ApiResponse } from '../Contracts/HandlesApiResponse'; 3 | import type { Method } from '../Calliope/Concerns/CallsApi'; 4 | 5 | /** 6 | * The default HandlesApiResponse implementation used by upfrontjs. 7 | * 8 | * @link {HandlesApiResponse} 9 | */ 10 | export default class ApiResponseHandler implements HandlesApiResponse { 11 | /** 12 | * @inheritDoc 13 | */ 14 | public async handle( 15 | promise: Promise< 16 | ApiResponse & 17 | { request: { method: 'CONNECT' | 'connect' | 'HEAD' | 'head' | 'OPTIONS' | 'options' | 'TRACE' | 'trace' } } 18 | > 19 | // omit to discourage accessing such on response where it's not available 20 | ): Promise | undefined>; 21 | public async handle(promise: Promise>): Promise; 22 | public async handle(promise: Promise): Promise { 23 | return promise 24 | .then(async response => this.handleResponse(response)) 25 | .catch(async (error: unknown) => this.handleError(error)) 26 | .finally(() => this.handleFinally()); 27 | } 28 | 29 | /** 30 | * Handle successful request. 31 | * 32 | * @param {ApiResponse} response 33 | * 34 | * @return {Promise} 35 | * 36 | * @throws {ApiResponse} 37 | */ 38 | public async handleResponse( 39 | response: ApiResponse & 40 | { request: { method: 'CONNECT' | 'connect' | 'HEAD' | 'head' | 'OPTIONS' | 'options' | 'TRACE' | 'trace' } } 41 | ): Promise | undefined>; 42 | public async handleResponse(response: ApiResponse): Promise; 43 | public async handleResponse(response: ApiResponse): Promise { 44 | if (response.status >= 400) { 45 | throw response; 46 | } 47 | 48 | if (response.status < 200 || response.status > 299 || response.status === 204) { 49 | return undefined; 50 | } 51 | 52 | const method = response.request?.method?.toUpperCase() as Uppercase | undefined; 53 | 54 | if (method && ['OPTIONS', 'HEAD', 'TRACE', 'CONNECT'].includes(method)) { 55 | // the user might just want the headers or debug info 56 | // so return the whole response 57 | return response; 58 | } 59 | 60 | if (typeof response.json === 'function') { 61 | return response.json(); 62 | } 63 | 64 | return undefined; 65 | } 66 | 67 | /** 68 | * Handle errors that occurred during the promise execution. 69 | * 70 | * @param {any} rejectReason 71 | * 72 | * @return {void} 73 | */ 74 | public async handleError(rejectReason: unknown): Promise { 75 | return Promise.reject(rejectReason); 76 | } 77 | 78 | /** 79 | * If extending, you may do any final operations after the request. 80 | * 81 | * @return {void} 82 | */ 83 | public handleFinally(): void { 84 | // 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/mock/fetch-mock.ts: -------------------------------------------------------------------------------- 1 | import Collection from '../../src/Support/Collection'; 2 | import type { Method } from '../../src'; 3 | import { isObjectLiteral } from '../../src'; 4 | import { vi } from 'vitest'; 5 | 6 | /** 7 | * The mock of fetch. 8 | */ 9 | const spy = vi.spyOn(globalThis, 'fetch').mockRejectedValue('Implementation not set.'); 10 | 11 | /** 12 | * The values permitted in the response body for serialisation. 13 | */ 14 | type ResponseBody = any[] | Record | string | undefined; 15 | 16 | /** 17 | * Create a response from the given body and optionally response initialisation values. 18 | * @param response 19 | * @param responseInit 20 | */ 21 | const buildResponse = ( 22 | response?: ResponseBody, 23 | responseInit?: ResponseInit 24 | ): Response => { 25 | function getBody(val?: any): string { 26 | if (typeof val === 'string') { 27 | return val; 28 | } else if (Array.isArray(val)) { 29 | return JSON.stringify(val); 30 | } else if (Collection.isCollection(val)) { 31 | return JSON.stringify(val.toArray()); 32 | } else if (val?.body) { 33 | return getBody(val.body); 34 | } else if (isObjectLiteral(val)) { 35 | return JSON.stringify(val); 36 | } 37 | 38 | if (arguments.length === 1 && val === undefined) { 39 | return JSON.stringify(undefined); 40 | } 41 | 42 | return JSON.stringify({ data: 'value' }); 43 | } 44 | 45 | return new Response(getBody(response), responseInit); 46 | }; 47 | 48 | /** 49 | * Methods controlling the fetch mock. 50 | */ 51 | const fetchMock = { 52 | resetMocks: (): typeof spy => { 53 | spy.mockReset(); 54 | return spy.mockRejectedValue('Implementation not set.'); 55 | }, 56 | mockResponseOnce: (body: ResponseBody, init?: ResponseInit): typeof spy => { 57 | return spy.mockResolvedValueOnce(buildResponse(body, init)); 58 | }, 59 | mockRejectOnce: (val: Error | string): typeof spy => spy.mockRejectedValueOnce(val) 60 | }; 61 | 62 | /** 63 | * The shape of the request to inspect. 64 | */ 65 | interface RequestDescriptor { 66 | url: string; 67 | method: Uppercase; 68 | headers: Headers; 69 | body?: unknown; 70 | } 71 | 72 | /** 73 | * Arrange information into an array of objects. 74 | */ 75 | export const getRequests = (): RequestDescriptor[] => { 76 | // @ts-expect-error 77 | return spy.mock.calls.map((array: [string, Omit]) => { 78 | return Object.assign(array[1], { url: array[0] }); 79 | }); 80 | }; 81 | 82 | /** 83 | * Get the last call from the mock. 84 | */ 85 | export const getLastRequest = (): RequestDescriptor | undefined => { 86 | const calls = getRequests(); 87 | 88 | if (!calls.length) { 89 | return undefined; 90 | } 91 | 92 | const lastCall = calls[calls.length - 1]; 93 | 94 | if (lastCall && 'body' in lastCall && typeof lastCall.body === 'string') { 95 | try { 96 | lastCall.body = JSON.parse(lastCall.body); 97 | // eslint-disable-next-line no-empty,@typescript-eslint/no-unused-vars 98 | } catch (e: unknown) {} 99 | } 100 | 101 | return lastCall; 102 | }; 103 | 104 | export default fetchMock; 105 | -------------------------------------------------------------------------------- /src/Support/GlobalConfig.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import cloneDeep from 'lodash.clonedeep'; 3 | import type Configuration from '../Contracts/Configuration'; 4 | 5 | type WithProperty = T & { 6 | [P in K]: Exclude

7 | }; 8 | 9 | export default class GlobalConfig> { 10 | /** 11 | * The configuration object. 12 | * 13 | * @protected 14 | */ 15 | protected static configuration: Configuration & Record = {}; 16 | 17 | /** 18 | * Keys marked for not be deeply cloned when setting and returning values. 19 | */ 20 | public static usedAsReference: (PropertyKey | keyof Configuration)[] = ['headers']; 21 | 22 | /** 23 | * The config constructor. 24 | * 25 | * @param {object} configuration 26 | */ 27 | public constructor(configuration?: T) { 28 | if (configuration) { 29 | merge(GlobalConfig.configuration, configuration); 30 | } 31 | } 32 | 33 | /** 34 | * Get a value from the config. 35 | * 36 | * @param {string} key 37 | * @param {any=} defaultVal 38 | */ 39 | public get(key: K, defaultVal?: T[K]): T[K]; 40 | public get(key: PropertyKey, defaultVal?: D): D; 41 | public get(key: PropertyKey, defaultVal?: D): D { 42 | if (!this.has(key)) { 43 | return defaultVal!; 44 | } 45 | 46 | const value = GlobalConfig.configuration[key as string]; 47 | 48 | if (GlobalConfig.usedAsReference.includes(key) || GlobalConfig.usedAsReference.includes('*')) { 49 | return value; 50 | } 51 | 52 | return typeof value === 'function' ? value : cloneDeep(value); 53 | } 54 | 55 | /** 56 | * Determine whether a key is set in the config or not. 57 | * 58 | * @param {string} key 59 | */ 60 | public has(key: K): this is GlobalConfig> { 61 | return key in GlobalConfig.configuration; 62 | } 63 | 64 | /** 65 | * Set a config value. 66 | * 67 | * @param {string} key 68 | * @param {any} value 69 | */ 70 | public set(key: K, value: T[K]): asserts this is GlobalConfig>; 71 | public set(key: K, value: V): asserts this is GlobalConfig>; 72 | public set(key: string, value: unknown): void { 73 | if (GlobalConfig.usedAsReference.includes(key) || GlobalConfig.usedAsReference.includes('*')) { 74 | GlobalConfig.configuration[key] = value; 75 | 76 | return; 77 | } 78 | 79 | GlobalConfig.configuration[key] = typeof value === 'function' ? value : cloneDeep(value); 80 | } 81 | 82 | /** 83 | * Remove a config value. 84 | * 85 | * @param {string} key 86 | */ 87 | public unset(key: K): asserts this is GlobalConfig> { 88 | delete GlobalConfig.configuration[key]; 89 | } 90 | 91 | /** 92 | * Empty the configuration. 93 | * 94 | * @return {this} 95 | */ 96 | // @ts-expect-error 97 | public reset(): asserts this is GlobalConfig> { 98 | GlobalConfig.configuration = {}; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | 4 | 5 | 6 | 7 | ```shell 8 | npm install @upfrontjs/framework 9 | ``` 10 | 11 | 12 | 13 | 14 | 15 | ```shell 16 | yarn install @upfrontjs/framework 17 | ``` 18 | 19 | 20 | 21 | 22 | 23 | The library is transpiled to ES6 (currently the lowest supported version), but if you're using [Typescript](https://www.typescriptlang.org/), you could choose to use the source `.ts` files. To do so, import files from `/src` folder as opposed to the library root. 24 | ```js 25 | import { Model } from '@upfrontjs/framework'; 26 | ``` 27 | vs 28 | ```ts 29 | import { Model } from '@upfrontjs/framework/src'; 30 | ``` 31 | 32 | This way you can deliver the files with the correct optimisation to your audience. 33 | 34 | ::: tip 35 | Instead of writing `'@upfrontjs/framework/src'` at every import you could choose to alias it to something shorter like `'@upfrontjs'` in the bundler of your choice. (If doing so, don't forget to add the aliases to your typescript and test runner configuration too if applicable.) 36 | ::: 37 | 38 | You're now ready to [write your first model](../calliope/readme.md#creating-models). 39 | 40 | ## Optional steps 41 | Add your base [endpoint](../helpers/global-config.md#baseendpoint) to the [configuration](../helpers/global-config.md) in your entry file like so: 42 | ```js 43 | import { GlobalConfig } from '@upfrontjs/framework'; 44 | 45 | new GlobalConfig({ 46 | baseEndPoint: 'https://your-api-endpoint.com' 47 | }) 48 | ``` 49 | 50 | If you have any custom [service](../services/readme.md) implementations add them to the configuration: 51 | ```js 52 | import { GlobalConfig } from '@upfrontjs/framework'; 53 | import MyHandler from './Services/MyHandler'; 54 | 55 | new GlobalConfig({ 56 | apiResponseHandler: MyHandler, 57 | }) 58 | ``` 59 | 60 | ## Backend requirements 61 | 62 | Given UpfrontJS is back-end agnostic, there are 2-3 requirements that needs to be fulfilled by the server in order for this package to work as expected. These are the: 63 | - **Parsing the request** 64 | - Your server should be capable of parsing the query string or request body sent by upfront. The shape of the request depends on the used [ApiCaller](../services/readme.md#apicaller) implementation. Users with REST apis using the default [API](../services/api.md) service may also take a look at the [anatomy of the request](../services/api.md#shape-of-the-request) the service generates. 65 | - **Responding with appropriate data** 66 | - The returned data should be representative of the query string/body. 67 | - It is in a format that the used [HandlesApiResponse](../services/readme.md#handlesapiresponse) implementation can parse into an object or array of objects. 68 | - **Endpoints defined** 69 | 70 | If using a REST api and the default [API](../services/api.md) service: 71 | - Your server should implement the expected REST endpoints which are the following using the example of a users: 72 | - `GET users/` - index endpoint returning all users. 73 | - `POST users/` - endpoint used for saving the user data. 74 | - `GET users/{id}` - get a single user. 75 | - `PUT/PATCH users/{id}` - endpoint used to update partially or in full the user data. 76 | - `DELETE users/{id}` - delete a single user. 77 | 78 | ::: tip 79 | Note that if you expect to experience high traffic for some unique data, you should probably still write a dedicated RPC endpoint for it, instead of parsing the query and letting an ORM figure it out. 80 | ::: 81 | -------------------------------------------------------------------------------- /docs/calliope/ancestry-collection.md: -------------------------------------------------------------------------------- 1 | # Ancestry Collection 2 | 3 | Sometimes you may encounter data structures that are meant to be sorted based on their relation to each other. For example messages and their replies, folders and their sub folders, tasks and their subtasks, etc... However, sometimes the data sources might not return the data in an easily digestible structured format but as a flat array. 4 | 5 | For these occasions you may use the AncestryCollection. The AncestryCollection is a subclass of the [ModelCollection](./model-collection.md), therefore all methods are inherited. Keep in mind that inherited methods will be operating on the top level items as one might expect. 6 | 7 | ## Properties 8 | 9 | #### depthName 10 | 11 | 12 | This string determines the name of the attribute that is set on the models when constructing the collection using the [treeOf](#treeof) method. 13 | 14 | ## Methods 15 | 16 | [[toc]] 17 | 18 | #### treeOf 19 | 20 | 21 | The `treeOf` static method creates the AncestryCollection from the given [ModelCollection](./model-collection.md). This arranges the models as the child of their respective models. 22 | Optionally the method takes 2 more arguments: 23 | - `parentKey` (default: `'parentId'`) - the name of the attribute that contains the parent's identifier. 24 | - `childrenRelation` (default: `'children'`): - the name of the relation the child models are nested under. 25 | 26 | This will also assign the [depth](#depthname) value to the model based on its position on the tree. 27 | ```js 28 | import { AncestryCollection } from '@upfrontjs/framework'; 29 | import Folder from '~/Models/Folder'; 30 | 31 | const folders = await Folder.get(); 32 | const folderTree = AncestryCollection.treeOf(folders); 33 | ``` 34 | 35 | #### flatten 36 | 37 | The `flatten` method deconstructs the tree to a flat [ModelCollection](./model-collection.md). 38 | 39 | ```js 40 | import { AncestryCollection } from '@upfrontjs/framework'; 41 | import Folder from '~/Models/Folder'; 42 | 43 | const folders = await Folder.get(); 44 | const folderTree = AncestryCollection.treeOf(folders); 45 | 46 | folderTree.flatten(); // ModelCollection 47 | ``` 48 | 49 | ::: tip 50 | This allows to implement a simple `find()` or `contains()` logic by calling `!!folderTree.flatten().findByKey(1)`. 51 | ::: 52 | 53 | #### leaves 54 | 55 | The `leaves` method returns a [ModelCollection](./model-collection.md) containing all the models that does not have any children. With the analogy of a tree, it will not include roots, branches, only the models at the end of the bloodline. 56 | 57 | ```js 58 | import { AncestryCollection } from '@upfrontjs/framework'; 59 | import Folder from '~/Models/Folder'; 60 | 61 | const folders = await Folder.get(); 62 | const folderTree = AncestryCollection.treeOf(folders); 63 | 64 | folderTree.leaves(); // ModelCollection 65 | ``` 66 | 67 | #### isAncestryCollection 68 | 69 | 70 | The `isAncestryCollection` static method same as the [isModelCollection](./model-collection.md#ismodelcollection) method on the ModelCollection, is used to evaluate that the given value is an AncestryCollection. 71 | ```js 72 | import { AncestryCollection, ModelCollection, Collection } from '@upfrontjs/framework'; 73 | import Folder from '~/Models/Folder'; 74 | 75 | const modelCollection = await Folder.get(); 76 | const folderTree = AncestryCollection.treeOf(modelCollection); 77 | 78 | AncestryCollection.isAncestryCollection(modelCollection); // false 79 | AncestryCollection.isAncestryCollection(folderTree); // true 80 | 81 | ModelCollection.isModelCollection(folderTree); // true 82 | Collection.isCollection(folderTree); // true 83 | ``` 84 | -------------------------------------------------------------------------------- /src/Calliope/Concerns/HasTimestamps.ts: -------------------------------------------------------------------------------- 1 | import HasRelations from './HasRelations'; 2 | import type Model from '../Model'; 3 | import InvalidArgumentException from '../../Exceptions/InvalidArgumentException'; 4 | 5 | export default class HasTimestamps extends HasRelations { 6 | /** 7 | * The name of the created at attribute on the server side. 8 | * 9 | * @type {string} 10 | * 11 | * @protected 12 | */ 13 | protected static readonly createdAt: string = 'created_at'; 14 | 15 | /** 16 | * The name of the updated at attribute on the server side. 17 | * 18 | * @type {string} 19 | * 20 | * @protected 21 | */ 22 | protected static readonly updatedAt: string = 'updated_at'; 23 | 24 | /** 25 | * Indicates if the model should expect timestamps. 26 | * 27 | * @type {boolean} 28 | */ 29 | protected readonly timestamps: boolean = true; 30 | 31 | /** 32 | * Get the name of the created at attribute. 33 | * 34 | * @return {string} 35 | */ 36 | public getCreatedAtName(): string { 37 | return this.setStringCase((this.constructor as unknown as HasTimestamps).createdAt as string); 38 | } 39 | 40 | /** 41 | * Get the name of the updated at attribute. 42 | * 43 | * @return {string} 44 | */ 45 | public getUpdatedAtName(): string { 46 | return this.setStringCase((this.constructor as unknown as HasTimestamps).updatedAt as string); 47 | } 48 | 49 | /** 50 | * Determine if the model uses timestamps. 51 | * 52 | * @return {boolean} 53 | */ 54 | public usesTimestamps(): boolean { 55 | return this.timestamps; 56 | } 57 | 58 | /** 59 | * Update the timestamps on remote. 60 | * 61 | * @return {Promise} 62 | */ 63 | public async touch(): Promise { 64 | if (!this.usesTimestamps()) { 65 | return this; 66 | } 67 | 68 | this.throwIfModelDoesntExistsWhenCalling('touch'); 69 | 70 | const updatedAt = this.getUpdatedAtName(); 71 | 72 | return this.setModelEndpoint() 73 | .patch({ [updatedAt]: new Date().toISOString() }) 74 | .then(model => { 75 | if (!(updatedAt in model)) { 76 | throw new InvalidArgumentException('\'' + updatedAt + '\' is not found in the response model.'); 77 | } 78 | 79 | return this.setAttribute(updatedAt, model.getAttribute(updatedAt)).syncOriginal(updatedAt); 80 | }); 81 | } 82 | 83 | /** 84 | * Refresh the timestamps from remote. 85 | * 86 | * @return {Promise} 87 | */ 88 | public async freshTimestamps(): Promise { 89 | if (!this.usesTimestamps()) { 90 | return this; 91 | } 92 | 93 | this.throwIfModelDoesntExistsWhenCalling('freshTimestamps'); 94 | 95 | const createdAt = this.getCreatedAtName(); 96 | const updatedAt = this.getUpdatedAtName(); 97 | 98 | return this.select([createdAt, updatedAt]) 99 | .whereKey((this as unknown as Model).getKey()!) 100 | .setModelEndpoint() 101 | .get() 102 | .then(model => { 103 | if (!(createdAt in model)) { 104 | throw new InvalidArgumentException('\'' + createdAt + '\' is not found in the response model.'); 105 | } 106 | if (!(updatedAt in model)) { 107 | throw new InvalidArgumentException('\'' + updatedAt + '\' is not found in the response model.'); 108 | } 109 | 110 | return this.setAttribute(createdAt, (model as Model).getAttribute(createdAt)) 111 | .setAttribute(updatedAt, (model as Model).getAttribute(updatedAt)) 112 | .syncOriginal([createdAt, updatedAt]); 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Calliope/Concerns/SoftDeletes.ts: -------------------------------------------------------------------------------- 1 | import HasTimestamps from './HasTimestamps'; 2 | import type Model from '../Model'; 3 | import LogicException from '../../Exceptions/LogicException'; 4 | import type { SimpleAttributes } from './HasAttributes'; 5 | 6 | export default class SoftDeletes extends HasTimestamps { 7 | /** 8 | * The name of the deleted at attribute on the server side. 9 | * 10 | * @type {string} 11 | * 12 | * @protected 13 | */ 14 | protected static readonly deletedAt: string = 'deleted_at'; 15 | 16 | /** 17 | * Indicates if the model should expect a timestamp for soft-deletion. 18 | * 19 | * @type {boolean} 20 | */ 21 | protected readonly softDeletes: boolean = true; 22 | 23 | /** 24 | * Determine if the model instance has been soft-deleted. 25 | * 26 | * @return {boolean} 27 | */ 28 | public trashed(): boolean { 29 | return !!this.getAttribute(this.getDeletedAtName()); 30 | } 31 | 32 | /** 33 | * Get the name of the deleted at attribute. 34 | * 35 | * @return {string} 36 | */ 37 | public getDeletedAtName(): string { 38 | return this.setStringCase((this.constructor as unknown as SoftDeletes).deletedAt as string); 39 | } 40 | 41 | /** 42 | * Determine if the model uses soft deletes timestamp. 43 | * 44 | * @return {boolean} 45 | */ 46 | public usesSoftDeletes(): boolean { 47 | return this.softDeletes; 48 | } 49 | 50 | /** 51 | * Delete the model. 52 | * 53 | * @param {object=} data 54 | * 55 | * @return {Promise} 56 | */ 57 | public override async delete( 58 | data?: FormData | SimpleAttributes | SimpleAttributes 59 | ): Promise { 60 | if (!this.usesSoftDeletes()) { 61 | return super.delete(data); 62 | } 63 | 64 | const deletedAt = this.getDeletedAtName(); 65 | 66 | if (this.getAttribute(deletedAt)) { 67 | return this as unknown as T; 68 | } 69 | 70 | this.throwIfModelDoesntExistsWhenCalling('delete'); 71 | 72 | this.setModelEndpoint(); 73 | 74 | if (!data) { 75 | data = { [deletedAt]: new Date().toISOString() }; 76 | } else if (data instanceof FormData) { 77 | const serverCasedDeletedAt = this.setServerStringCase(deletedAt); 78 | if (!data.has(serverCasedDeletedAt)) { 79 | data.append(serverCasedDeletedAt, new Date().toISOString()); 80 | } 81 | } else if (!(deletedAt in data)) { 82 | // @ts-expect-error - string is in fact can be used to index here 83 | data[deletedAt] = new Date().toISOString(); 84 | } 85 | 86 | return super.delete(data).then(model => { 87 | return this.setAttribute(deletedAt, model.getAttribute(deletedAt)) 88 | .syncOriginal(deletedAt) as unknown as T; 89 | }); 90 | } 91 | 92 | /** 93 | * Set the deleted at attribute to null on remote. 94 | * 95 | * @return {Promise} 96 | */ 97 | public async restore(): Promise { 98 | if (!this.usesSoftDeletes() || !this.getAttribute(this.getDeletedAtName())) { 99 | return this; 100 | } 101 | 102 | if (!(this as unknown as Model).getKey()) { 103 | throw new LogicException( 104 | 'Attempted to call restore on \'' + (this as unknown as Model).getName() 105 | + '\' when it doesn\'t have a primary key.' 106 | ); 107 | } 108 | 109 | return this.setModelEndpoint() 110 | .patch({ [this.getDeletedAtName()]: null }) 111 | .then(model => { 112 | const deletedAt = this.getDeletedAtName(); 113 | 114 | return this.setAttribute(deletedAt, model.getAttribute(deletedAt, null)).syncOriginal(deletedAt); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /docs/calliope/timestamps.md: -------------------------------------------------------------------------------- 1 | # Timestamps 2 | 3 | Timestamps are a feature of the model used for tracking changes on your entities. With it, you can check when was the model soft deleted, created or last updated. 4 | 5 | ## Timestamps 6 | 7 | ### Properties 8 | 9 | #### createdAt 10 | 11 | The `createdAt` is a static property on the model. The default value is `'createdAt'`. You may override this if the expected timestamp attribute is named differently. 12 | The letter casing is no concern here as [getCreatedAtName](#getcreatedatname) will update it to the correct casing. 13 | 14 | #### updatedAt 15 | 16 | The `updatedAt` is a static property on the model. The default value is `'updatedAt'`. You may override this if the expected timestamp attribute is named differently. 17 | The letter casing is no concern here as [getUpdatedAtName](#getupdatedatname) will update it to the correct casing. 18 | 19 | #### timestamps 20 | 21 | The `timestamps` is a read only attribute that signifies whether the model uses timestamps or not. The default value is `true`; 22 | 23 | ### Methods 24 | 25 | #### getCreatedAtName 26 | 27 | The `getCreatedAtName` method returns the value of the static [createdAt](#createdat) value with the letter casing set to the given [attributeCasing](./attributes.md#attributecasing). 28 | 29 | #### getUpdatedAtName 30 | 31 | The `getUpdatedAtName` method returns the value of the static [updatedAt](#updatedat) value with the letter casing set to the given [attributeCasing](./attributes.md#attributecasing). 32 | 33 | #### usesTimestamps 34 | 35 | The `usesTimestamps` method returns the value of the [timestamps](#timestamps-3) 36 | 37 | #### touch 38 | 39 | 40 | The `touch` method sends a `PATCH` request with the new [updatedAt](#getupdatedatname) attribute value. It updates the attribute from the response data. 41 | 42 | ::: tip 43 | Your backend should probably not trust this input, but generate its own timestamp. 44 | ::: 45 | 46 | #### freshTimestamps 47 | 48 | 49 | The `freshTimestamps` method sends `GET` request [selecting](./query-building.md#select) only the [createdAt](#getcreatedatname) and [updatedAt](#getupdatedatname) attributes, which are updated from the response on success. 50 | 51 | ## Soft Deletes 52 | 53 | ### Properties 54 | 55 | #### deletedAt 56 | 57 | The `deletedAt` is a static property on the model. The default value is `'deletedAt'`. You may override this if the expected timestamp attribute is named differently. 58 | The letter casing is no concern here as [getDeletedAtName](#getdeletedatname) will update it to the correct casing. 59 | 60 | #### softDeletes 61 | 62 | The `softDeletes` is a read only attribute that signifies whether the model uses soft deleting or not. The default value is `true`; 63 | 64 | #### trashed 65 | 66 | The `trashed` is a getter property that returns a boolean depending on whether the model has the [deletedAt](#getdeletedatname) set to a truthy value. 67 | 68 | ### Methods 69 | 70 | #### getDeletedAtName 71 | 72 | The `getDeletedAtName` method returns the value of the static [deletedAt](#deletedat) value with the letter casing set to the given [attributeCasing](./attributes.md#attributecasing). 73 | 74 | #### usesSoftDeletes 75 | 76 | The `usesSoftDeletes` method returns the value of the [softDeletes](#softdeletes) 77 | 78 | #### delete 79 | 80 | 81 | The `delete` method is an extension of the api calling method [delete](./api-calls.md#delete). If the model is not [using softDeletes](#usessoftdeletes) the logic will fall back to the original [delete](./api-calls.md#delete) method's logic therefore, method accepts an optional object argument which will be sent along on the request in the body. 82 | 83 | This method sends a `DELETE` request with the new [deletedAt](#getdeletedatname) attribute value. It updates the attribute from the response data. 84 | 85 | ::: tip 86 | Your backend should probably not trust this input, but generate its own timestamp. 87 | ::: 88 | 89 | #### restore 90 | 91 | 92 | The `restore` methods sends a `PATCH` request with the nullified [deletedAt](#getdeletedatname) attribute value. It updates the attribute to `null` on successful request. 93 | 94 | 95 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import terser from '@rollup/plugin-terser'; 3 | import bundleSize from 'rollup-plugin-output-size'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { readFileSync } from 'node:fs'; 6 | import { glob } from 'glob'; 7 | import * as path from 'node:path'; 8 | import type { InputOptions, RollupOptions, SourcemapPathTransformOption } from 'rollup'; 9 | 10 | const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); 11 | 12 | const banner = ` 13 | /*! ================================ 14 | ${pkg.name} v${pkg.version} 15 | (c) 2020-present ${pkg.author} 16 | Released under ${pkg.license} License 17 | ================================== */ 18 | `; 19 | 20 | const commonConfig: InputOptions = { 21 | external: [ 22 | ...Object.keys(pkg.dependencies ?? {}), 23 | ...Object.keys(pkg.peerDependencies ?? {}) 24 | ], 25 | plugins: [ 26 | // it doesn't find the config by default and doesn't emit interface files 27 | // eslint-disable-next-line max-len 28 | // todo -https://github.com/rollup/plugins/pull/791/files#diff-77ceb76f06466d761730b952567396e6b5c292cc4044441cdfdf048b4614881dR83 check those tests 29 | typescript({ tsconfig: './build.tsconfig.json' }), 30 | terser({ 31 | format: { 32 | comments: (_node, comment) => { 33 | if (comment.type === 'comment2') { 34 | return comment.value.includes('@upfront'); 35 | } 36 | 37 | return false; 38 | } 39 | } 40 | }), 41 | bundleSize() 42 | ] 43 | }; 44 | 45 | const sourcemapPathTransform: SourcemapPathTransformOption = relativeSourcePath => { 46 | return relativeSourcePath.replaceAll('.ts', '.d.ts') 47 | .replaceAll('src/', 'types/'); 48 | }; 49 | 50 | const rollupConfig: RollupOptions[] = [ 51 | { 52 | input: 'src/index.ts', 53 | output: [ 54 | { 55 | file: pkg.main, 56 | format: 'cjs', 57 | sourcemap: true, 58 | sourcemapPathTransform, 59 | banner 60 | }, 61 | { 62 | file: pkg.module, 63 | format: 'es', 64 | sourcemap: true, 65 | sourcemapPathTransform, 66 | banner 67 | } 68 | ], 69 | ...commonConfig 70 | }, 71 | 72 | { 73 | input: Object.fromEntries( 74 | glob.sync('src/Support/{array,string,function}/*.ts').map(file => [ 75 | // This removes `src/` as well as the file extension from each 76 | // file, so e.g., src/nested/foo.js becomes nested/foo 77 | path.relative( 78 | 'src', 79 | file.slice(0, file.length - path.extname(file).length) 80 | ), 81 | // This expands the relative paths to absolute paths, so e.g. 82 | // src/nested/foo becomes /project/src/nested/foo.js 83 | fileURLToPath(new URL(file, import.meta.url)) 84 | ]) 85 | ), 86 | output: { 87 | format: 'es', 88 | sourcemap: true, 89 | sourcemapPathTransform, 90 | dir: './' 91 | }, 92 | ...commonConfig 93 | }, 94 | 95 | { 96 | input: 'src/array.ts', 97 | output: [ 98 | { 99 | file: 'array.min.cjs', 100 | format: 'cjs', 101 | sourcemap: true, 102 | sourcemapPathTransform, 103 | banner 104 | }, 105 | { 106 | file: 'array.es.min.js', 107 | format: 'es', 108 | sourcemap: true, 109 | sourcemapPathTransform, 110 | banner 111 | } 112 | ], 113 | ...commonConfig 114 | }, 115 | 116 | { 117 | input: 'src/string.ts', 118 | output: [ 119 | { 120 | file: 'string.min.cjs', 121 | format: 'cjs', 122 | sourcemap: true, 123 | sourcemapPathTransform, 124 | banner 125 | }, 126 | { 127 | file: 'string.es.min.js', 128 | format: 'es', 129 | sourcemap: true, 130 | sourcemapPathTransform, 131 | banner 132 | } 133 | ], 134 | ...commonConfig 135 | } 136 | ]; 137 | 138 | export default rollupConfig; 139 | -------------------------------------------------------------------------------- /tests/Calliope/Concerns/GuardsAttributes.test.ts: -------------------------------------------------------------------------------- 1 | import Model from '../../../src/Calliope/Model'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | 4 | class TestClass extends Model { 5 | public override getName(): string { 6 | return 'TestClass'; 7 | } 8 | 9 | public override get fillable(): string[] { 10 | return ['attr1']; 11 | } 12 | } 13 | 14 | let guardedObject: Model; 15 | 16 | describe('GuardsAttributes', () => { 17 | beforeEach(() => { 18 | guardedObject = new TestClass; 19 | }); 20 | 21 | describe('getFillable()', () => { 22 | it('should return the fillable array', () => { 23 | expect(guardedObject.getFillable()).toStrictEqual(['attr1']); 24 | guardedObject.setFillable(['attr1', 'attr2']); 25 | expect(guardedObject.getFillable()).toStrictEqual(['attr1', 'attr2']); 26 | }); 27 | }); 28 | 29 | describe('setFillable()', () => { 30 | it('should set the fillable array', () => { 31 | guardedObject.setFillable(['attr2']); 32 | expect(guardedObject.getFillable()).toStrictEqual(['attr2']); 33 | }); 34 | }); 35 | 36 | describe('setGuarded()', () => { 37 | beforeEach(() => { 38 | class TestGuardingClass extends Model { 39 | public override getName(): string { 40 | return 'TestGuardingClass'; 41 | } 42 | 43 | public override guardedAttributes = ['attr1']; 44 | } 45 | 46 | guardedObject = new TestGuardingClass; 47 | }); 48 | 49 | it('should set the fillable array', () => { 50 | guardedObject.setGuarded(['attr2']); 51 | expect(guardedObject.getGuarded()).toStrictEqual(['attr2']); 52 | }); 53 | }); 54 | 55 | describe('getGuarded()', () => { 56 | it('should return the fillable array', () => { 57 | guardedObject.setGuarded(['attr1', 'attr2']); 58 | expect(guardedObject.getGuarded()).toStrictEqual(['attr1', 'attr2']); 59 | }); 60 | }); 61 | 62 | describe('mergeGuarded()', () => { 63 | it('should merge the guarded array', () => { 64 | guardedObject.mergeGuarded(['attr2']); 65 | expect(guardedObject.getGuarded()).toStrictEqual(['*', 'attr2']); 66 | }); 67 | }); 68 | 69 | describe('mergeFillable()', () => { 70 | it('should merge the fillable array', () => { 71 | guardedObject.mergeFillable(['attr2']); 72 | expect(guardedObject.getFillable()).toStrictEqual(['attr1', 'attr2']); 73 | }); 74 | }); 75 | 76 | describe('isFillable()', () => { 77 | it('should determine whether the attribute is fillable or not', () => { 78 | expect(guardedObject.isFillable('attr1')).toBe(true); 79 | guardedObject.setGuarded(['attr1']); 80 | expect(guardedObject.isFillable('attr1')).toBe(true); 81 | guardedObject.setFillable(['*']); 82 | expect(guardedObject.isFillable('attr1')).toBe(true); 83 | guardedObject.setFillable([]); 84 | expect(guardedObject.isFillable('attr1')).toBe(false); 85 | }); 86 | }); 87 | 88 | describe('isGuarded()', () => { 89 | it('should determine whether the attribute is guarded or not', () => { 90 | expect(guardedObject.isGuarded('attr1')).toBe(false); 91 | guardedObject.setGuarded(['attr1']); 92 | expect(guardedObject.isGuarded('attr1')).toBe(false); 93 | guardedObject.setFillable(['*']); 94 | expect(guardedObject.isGuarded('attr1')).toBe(false); 95 | guardedObject.setFillable([]); 96 | expect(guardedObject.isGuarded('attr1')).toBe(true); 97 | }); 98 | }); 99 | 100 | describe('getFillableFromObject()', () => { 101 | it('should filter an object by what is fillable', () => { 102 | const attributes = { 103 | attr1: 1, 104 | attr2: 2 105 | }; 106 | // @ts-expect-error 107 | expect(guardedObject.getFillableFromObject(attributes)).toStrictEqual({ attr1: 1 }); 108 | }); 109 | 110 | it('should return all attributes if fillable includes \'*\'', () => { 111 | guardedObject.setFillable(['*']); 112 | const attributes = { 113 | attr1: 1, 114 | attr2: 2 115 | }; 116 | // @ts-expect-error 117 | expect(guardedObject.getFillableFromObject(attributes)).toStrictEqual(attributes); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/Calliope/Concerns/GuardsAttributes.ts: -------------------------------------------------------------------------------- 1 | import CastsAttributes from './CastsAttributes'; 2 | import type { Attributes, AttributeKeys } from './HasAttributes'; 3 | 4 | export default class GuardsAttributes extends CastsAttributes { 5 | /** 6 | * The attributes that are mass assignable. 7 | * 8 | * @protected 9 | * 10 | * @type {string[]} 11 | */ 12 | protected fillableAttributes = this.fillable; 13 | 14 | /** 15 | * The attributes that aren't mass assignable 16 | * 17 | * @protected 18 | * 19 | * @type {string[]} 20 | */ 21 | protected guardedAttributes = this.guarded; 22 | 23 | /** 24 | * The attributes that are mass assignable. 25 | * 26 | * @type {string[]} 27 | */ 28 | public get fillable(): string[] { 29 | return []; 30 | } 31 | 32 | /** 33 | * The attributes that are not mass assignable. 34 | * 35 | * @type {string[]} 36 | */ 37 | public get guarded(): string[] { 38 | return ['*']; 39 | } 40 | 41 | /** 42 | * Get the guarded attributes for the model. 43 | * 44 | * @return {string[]} 45 | */ 46 | public getGuarded(): string[] { 47 | return this.guardedAttributes; 48 | } 49 | 50 | /** 51 | * Get the fillable attributes for the model. 52 | * 53 | * @return {string[]} 54 | */ 55 | public getFillable(): string[] { 56 | return this.fillableAttributes; 57 | } 58 | 59 | /** 60 | * Merge new fillable attributes with existing fillable attributes on the model. 61 | * 62 | * @param {string[]} fillable 63 | * 64 | * @return {this} 65 | */ 66 | public mergeFillable(fillable: (AttributeKeys | string)[]): this; 67 | public mergeFillable(fillable: string[]): this { 68 | this.fillableAttributes = [...this.getFillable(), ...fillable] as string[]; 69 | 70 | return this; 71 | } 72 | 73 | /** 74 | * Merge new guarded attributes with existing guarded attributes on the model. 75 | * 76 | * @param {string[]} guarded 77 | * 78 | * @return {this} 79 | */ 80 | public mergeGuarded(guarded: (AttributeKeys | string)[]): this; 81 | public mergeGuarded(guarded: string[]): this { 82 | this.guardedAttributes = [...this.getGuarded(), ...guarded]; 83 | 84 | return this; 85 | } 86 | 87 | /** 88 | * Set the fillable attributes for the model. 89 | * 90 | * @param {string[]} fillable 91 | * 92 | * @return {this} 93 | */ 94 | public setFillable(fillable: (AttributeKeys | string)[]): this; 95 | public setFillable(fillable: string[]): this { 96 | this.fillableAttributes = fillable; 97 | 98 | return this; 99 | } 100 | 101 | /** 102 | * Set the guarded attributes for the model. 103 | * 104 | * @param {string[]} guarded 105 | * 106 | * @return {this} 107 | */ 108 | public setGuarded(guarded: (AttributeKeys | string)[]): this; 109 | public setGuarded(guarded: string[]): this { 110 | this.guardedAttributes = guarded; 111 | 112 | return this; 113 | } 114 | 115 | /** 116 | * Determine if the given attribute may be mass assignable. 117 | * 118 | * @param {string} key 119 | */ 120 | public isFillable(key: AttributeKeys | string): boolean; 121 | public isFillable(key: string): boolean { 122 | return this.getFillable().includes(key) || this.getFillable().includes('*'); 123 | } 124 | 125 | /** 126 | * Determine if the given attribute may not be mass assignable. 127 | * 128 | * @param {string} key 129 | */ 130 | public isGuarded(key: AttributeKeys | string): boolean; 131 | public isGuarded(key: string): boolean { 132 | // if key is defined in both guarded and fillable, then fillable takes priority. 133 | return (this.getGuarded().includes(key) || this.getGuarded().includes('*')) && !this.isFillable(key); 134 | } 135 | 136 | /** 137 | * Get the fillable attributes from the given object. 138 | * 139 | * @param {object} attributes 140 | * 141 | * @return {object} 142 | */ 143 | protected getFillableFromObject(attributes: Attributes): Partial { 144 | const fillable: Attributes = {}; 145 | if (this.getFillable().includes('*')) { 146 | return attributes; 147 | } 148 | 149 | for (const [name, value] of Object.entries(attributes)) { 150 | if (!this.isGuarded(name)) { 151 | fillable[name] = value; 152 | } 153 | } 154 | 155 | return fillable; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /tests/Services/ApiResponseHandler.test.ts: -------------------------------------------------------------------------------- 1 | import ApiResponseHandler from '../../src/Services/ApiResponseHandler'; 2 | import fetchMock from '../mock/fetch-mock'; 3 | import User from '../mock/Models/User'; 4 | import type { ApiResponse } from '../../src/Contracts/HandlesApiResponse'; 5 | import { API } from '../../src'; 6 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 7 | 8 | const handler = new ApiResponseHandler(); 9 | 10 | describe('ApiResponseHandler', () => { 11 | beforeEach(() => { 12 | fetchMock.resetMocks(); 13 | }); 14 | 15 | it('should cast to boolean if only string boolean returned', async () => { 16 | fetchMock.mockResponseOnce('true'); 17 | 18 | const parsedResponse = await handler.handle(fetch('url')); 19 | 20 | expect(parsedResponse).toBe(true); 21 | }); 22 | 23 | it('should call the handleFinally method', async () => { 24 | const mockFn = vi.fn(); 25 | handler.handleFinally = () => mockFn(); 26 | fetchMock.mockResponseOnce(User.factory().raw()); 27 | await handler.handle(fetch('url')); 28 | 29 | expect(mockFn).toHaveBeenCalled(); 30 | }); 31 | 32 | describe('errors', () => { 33 | it('should throw an error on error', async () => { 34 | const error = new Error('Rejected response'); 35 | fetchMock.mockRejectOnce(error); 36 | 37 | await expect(handler.handle(fetch('url'))).rejects.toStrictEqual(error); 38 | }); 39 | 40 | it('should throw the response if the response is a client error', async () => { 41 | fetchMock.mockResponseOnce(undefined, { 42 | status: 404, 43 | statusText: 'Not Found' 44 | }); 45 | 46 | await expect(handler.handle(fetch('url'))).rejects.toBeInstanceOf(Response); 47 | fetchMock.mockResponseOnce(undefined, { 48 | status: 404, 49 | statusText: 'Not Found' 50 | }); 51 | 52 | const resp = await handler.handle(fetch('url')).catch(r => r); 53 | expect(resp.status).toBe(404); 54 | expect(resp.statusText).toBe('Not Found'); 55 | }); 56 | 57 | it('should throw the response if the response is a server error', async () => { 58 | fetchMock.mockResponseOnce(undefined, { 59 | status: 503, 60 | statusText: 'Service Unavailable' 61 | }); 62 | 63 | await expect(handler.handle(fetch('url'))).rejects.toBeInstanceOf(Response); 64 | fetchMock.mockResponseOnce(undefined, { 65 | status: 503, 66 | statusText: 'Service Unavailable' 67 | }); 68 | 69 | const resp = await handler.handle(fetch('url')).catch(r => r); 70 | expect(resp.status).toBe(503); 71 | expect(resp.statusText).toBe('Service Unavailable'); 72 | }); 73 | 74 | it('should throw JSON error if returned data cannot be parsed', async () => { 75 | fetchMock.mockResponseOnce('{"key":"value"'); 76 | 77 | await expect(handler.handle(fetch('url'))) 78 | .rejects 79 | .toThrowErrorMatchingInlineSnapshot( 80 | '[SyntaxError: Expected \',\' or \'}\' after property value in JSON at position 14 ' + 81 | '(line 1 column 15)]' 82 | ); 83 | }); 84 | }); 85 | 86 | it.each([ 87 | [204, 'has no content'], 88 | // waiting for https://github.com/nodejs/undici/issues/197 89 | // [100, 'is an informational response'], 90 | [302, 'as a redirect response'] 91 | ])('should return undefined if the response (%s) %s', async (status) => { 92 | fetchMock.mockResponseOnce(undefined, { status }); 93 | 94 | await expect(handler.handle(fetch('url')).catch((r: unknown) => r)).resolves.toBeUndefined(); 95 | }); 96 | 97 | it('should return undefined if it\'s a successful response but has no json parsing available', async () => { 98 | await expect(handler.handle( 99 | Promise.resolve({ status: 200, statusText: 'OK', headers: new Headers })) 100 | ).resolves.toBeUndefined(); 101 | }); 102 | 103 | it.each(['HEAD', 'OPTIONS', 'TRACE', 'CONNECT'])( 104 | 'should return the response if it was called with a %s request', 105 | async (method) => { 106 | // eslint-disable-next-line @typescript-eslint/naming-convention 107 | fetchMock.mockResponseOnce(undefined, { headers: { 'Content-Length': '12345' } }); 108 | 109 | const apiResponse = (await handler.handle(new API().call( 110 | 'url', 111 | method as 'CONNECT' | 'HEAD' | 'OPTIONS' | 'TRACE' 112 | )))!; 113 | expect(apiResponse).toBeInstanceOf(Response); 114 | expect(apiResponse.headers.get('Content-Length')).toBe('12345'); 115 | }); 116 | }); 117 | 118 | -------------------------------------------------------------------------------- /docs/helpers/event-emitter.md: -------------------------------------------------------------------------------- 1 | # Event Emitter 2 | 3 | Event emitter is a singular object that allows for reacting to certain events emitted throughout your app's lifecycle. 4 | 5 | ::: tip 6 | Events are executed in the order they have been added to the emitter. 7 | ::: 8 | 9 | ## Methods 10 | 11 | #### on 12 | 13 | The `on` method sets up the listener for an event. It receives two arguments, the first being the event name to listen to, the second being the callback function. The function will receive the same arguments the event was [emitted](#emit) with. The listener will run every time on the event until it is turned [off](#off). 14 | 15 | ```ts 16 | import { EventEmitter } from '@upfrontjs/framework'; 17 | import type { MyEvents } from '../types'; 18 | 19 | const emitter = EventEmitter.getInstance(); 20 | emitter.on('myEvent', num => num); 21 | emitter.emit('myEvent', 2); 22 | ``` 23 | 24 | #### once 25 | 26 | The `once` method works the same way as the [on](#on) method except once the callback runs, it will not run again. 27 | 28 | ```ts 29 | import { EventEmitter } from '@upfrontjs/framework'; 30 | import type { MyEvents } from '../types'; 31 | 32 | const emitter = EventEmitter.getInstance(); 33 | emitter.once('myEvent', num => console.log(num)); 34 | emitter.emit('myEvent', 1); // 1 logged out to the console 35 | emitter.emit('myEvent', 1); // nothing happens 36 | ``` 37 | #### prependListener 38 | 39 | The `prependListener` method works the same way as the [on](#on) method except the listener will be executed before all the other listeners. 40 | 41 | ```ts 42 | import { EventEmitter } from '@upfrontjs/framework'; 43 | import type { MyEvents } from '../types'; 44 | 45 | const emitter = EventEmitter.getInstance(); 46 | emitter.on('myEvent', num => console.log(num)); 47 | emitter.prependListener('myEvent', num => console.log('first log: ', num)); 48 | emitter.emit('myEvent', 1); // logged out 'first log: 1', then '1' 49 | ``` 50 | 51 | #### prependOnceListener 52 | 53 | The `prependOnceListener` method works the same was as the [prependListener](#prependlistener) except the given callback only runs once like with the [once](#once) method. 54 | 55 | #### off 56 | 57 | The `off` method removes the callback from the emitter. For variation of behaviour based on arguments, check the example below. 58 | 59 | ```ts 60 | import { EventEmitter } from '@upfrontjs/framework'; 61 | import type { MyEvents } from '../types'; 62 | 63 | const emitter = EventEmitter.getInstance(); 64 | emitter.off(); // removes all listeners 65 | emitter.off('myEvent'); // removes all listners that run on the given event 66 | emitter.off('myEvent', () => console.log(1)); // removes all the callbacks that binded to the given event and matches the callback signature 67 | emitter.off(undefined, () => console.log(1)); // remove all the callbacks from all the events that match the given callback signature 68 | ``` 69 | 70 | #### emit 71 | The `emit` method triggers the registered callbacks. It optionally accepts a number of arguments to be passed to the callbacks. 72 | 73 | ```ts 74 | import { EventEmitter } from '@upfrontjs/framework'; 75 | import type { MyEvents } from '../types'; 76 | 77 | const emitter = EventEmitter.getInstance(); 78 | emitter.on('myEvent', num => console.log(num)); 79 | emitter.emit('myEvent'); // logs out 'undefined' 80 | emitter.emit('myEvent', 1); // logs out '1' 81 | ``` 82 | #### has 83 | 84 | The `has` method determines whether there are listeners registered. It optionally takes 2 arguments. First the event name to check if any listeners exists, the second a callback to match function signature against. 85 | 86 | ```ts 87 | import { EventEmitter } from '@upfrontjs/framework'; 88 | import type { MyEvents } from '../types'; 89 | 90 | const emitter = EventEmitter.getInstance(); 91 | emitter.on('myEvent', num => console.log(num)); 92 | emitter.has(); // true 93 | emitter.has('myEvent'); // true 94 | emitter.has('otherEvent'); // false 95 | emitter.has('myEvent', () => {}); // false 96 | emitter.has('myEvent', num => console.log(num)); // true 97 | emitter.has(undefined, () => {}); // false 98 | emitter.has(undefined, num => console.log(num)); // true 99 | ``` 100 | #### listenerCount 101 | 102 | The `listenerCount` determines how many listeners are currently registered. If an event name is given only the listeners for the given event are counted. 103 | 104 | ```ts 105 | import { EventEmitter } from '@upfrontjs/framework'; 106 | import type { MyEvents } from '../types'; 107 | 108 | const emitter = EventEmitter.getInstance(); 109 | emitter.on('myEvent', () => {}); 110 | emitter.listenerCount(); // 1 111 | emitter.listenerCount('myEvent'); // 1 112 | emitter.listenerCount('otherEvent'); // 0 113 | ``` 114 | #### eventNames 115 | 116 | The `eventNames` method returns all the event names that are currently listened to. 117 | 118 | ```ts 119 | import { EventEmitter } from '@upfrontjs/framework'; 120 | import type { MyEvents } from '../types'; 121 | 122 | const emitter = EventEmitter.getInstance(); 123 | emitter.eventNames(); // [] 124 | emitter.on('myEvent', () => {}); 125 | emitter.once('myEvent', () => {}); 126 | emitter.one('otherEvent', () => {}); 127 | emitter.eventNames(); // ['myEvent', 'otherEvent'] 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/services/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | API is responsible for handling the actual ajax requests sent by upfront. It is a class that implements the [ApiCaller](./readme.md#apicaller) interface with the required `call` method. 4 | It offers the following customisations if you decide to extend the service. 5 | 6 | ### `requestOptions` 7 | This is an optional property on the API class which should have the value of `Partial`. This is merged into the request if defined. 8 | 9 | ### `initRequest()` 10 | This is an optional method that returns `Partial | Promise>`. It takes 3 parameters, in order the `url` - the endpoint to send the request to; the `method` - the http method; and optionally the `data` - which is an object literal or `FormData`. 11 | 12 | ### `getParamEncodingOptions` 13 | When the API receives some data to send as a request, it utilises the [qs](https://github.com/ljharb/qs) package to encode the object into query parameters. This object is the configuration ([`qs.IStringifyOptions`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0b5bfba2994c91a099cd5bcfd984f6c4c39228e5/types/qs/index.d.ts#L20)) object for the package. The typings for this package is an optional dependency of upfront, so you may choose not to include it in your development. 14 | 15 | --- 16 | Furthermore, it was built to support sending form data. This means it accepts a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object, and it will configure the headers for you. 17 | 18 | ## RequestInit resolving 19 | 20 | The API prepares the `RequestInit` configuration in the following order: 21 | 1. `RequestInit` created with the `method` argument 22 | 2. Merge the `requestOptions` property into the `RequestInit` 23 | 3. Merge the `initRequest()` method's result into the `RequestInit` 24 | 4. Merge in any headers from the [GlobalConfig](../helpers/global-config.md) into the `RequestInit` 25 | 5. Set the `Content-Type` header to the appropriate value if not already set. 26 | 6. Update the url with any query parameter if needed. 27 | 7. Merge in the headers from the [`ApiCaller`](./readme.md#apicaller)'s `call` method's `customHeaders` argument into the `RequestInit` 28 | 8. Set the `Accept` header to `application/json` if not already set. 29 | 30 | ## Shape of the request 31 | 32 | This part applies if you're not using a custom, customising the `API` service or [customising the query string in the builder](../calliope/query-building.md#customising-the-generated-query-string). Deviation from this, like adjusting the [getParamEncodingOptions](#getparamencodingoptions) or [implementing a custom service](./readme.md#using-custom-services) will cause different results. 33 | With the default settings, your api has to be ready to parse the requests with the following format. 34 | 35 | A sample get request `User.whereKey(1).get()` will encode to the following: 36 | 37 | ```http request 38 | GET https://test-api-endpoint.com/users?wheres[0][column]=id&wheres[0][operator]=%3D&wheres[0][value]=1&wheres[0][boolean]=and 39 | Content-type: application/x-www-form-urlencoded, charset=utf-8 40 | Accept: application/json 41 | ``` 42 | 43 | Which is the equivalent of [baseEndPoint](../helpers#baseendpoint) + [endpoint](../calliope/api-calls.md#endpoint) + the following object in the get parameters: 44 | ```js 45 | { 46 | wheres: [ 47 | { column: 'id', operator: '=', value: 1, boolean: 'and' } 48 | ] 49 | } 50 | ``` 51 | 52 | On all other requests where the body is allowed the above object will be sent in the main body. 53 | 54 | ### Query types 55 | The full typings for the possible values is the following: 56 | ```ts 57 | type BooleanOperator = 'and' | 'or'; 58 | type Direction = 'asc' | 'desc'; 59 | type Operator = '!=' | '<' | '<=' | '=' | '>' | '>=' | 'between' | 'in' | 'like' | 'notBetween' | 'notIn'; 60 | type Order = { column: string; direction: Direction }; 61 | type WhereDescription = { 62 | column: string; 63 | operator: Operator; 64 | value: any; 65 | boolean: BooleanOperator; 66 | }; 67 | type QueryParams = Partial<{ 68 | wheres: WhereDescription[]; // where the row tests true these conditions 69 | columns: string[]; // select only these columns 70 | with: string[]; // return with these relations 71 | scopes: string[]; // apply these scopes 72 | relationsExists: string[]; // only return if these relations exists 73 | orders: Order[]; // return records in this order 74 | distinct: string[]; // return rows that are unique by these columns 75 | offset: number; // skip this many records 76 | limit: number; // limit the number of records to this 77 | page: number; // return the records from this page in pagination 78 | }>; 79 | ``` 80 | 81 | Furthermore: 82 | 1. The backend should be able to parse the `WhereDescription['column']` into the relation on the backend if the following format is found `'shifts.start_time'`. This example will mean that we're interested in the `start_time` column on the `shifts` relation. 83 | ::: danger 84 | This is required to implement if you're using the [belongsToMany](../calliope/relationships.md#belongstomany) method. However, if not using the `belongsToMany` and not using the [where methods](../calliope/query-building.md#where) like `User.where('relation.column', 1).get()`, you don't have to implement. 85 | ::: 86 | 2. The backend should recognise if the `withs` contains the string `'*'` it means all relations are requested. 87 | ::: danger 88 | This is required to implement if you're using the [morphTo](../calliope/relationships.md#morphto) method. However, if not using the `morphTo` method, you don't have to implement. 89 | ::: 90 | 91 | 92 | -------------------------------------------------------------------------------- /tests/Calliope/AncestryCollection.test.ts: -------------------------------------------------------------------------------- 1 | import AncestryCollection from '../../src/Calliope/AncestryCollection'; 2 | import Folder from '../mock/Models/Folder'; 3 | import ModelCollection from '../../src/Calliope/ModelCollection'; 4 | import Collection from '../../src/Support/Collection'; 5 | import { types } from '../test-helpers'; 6 | import { beforeEach, describe, expect, it } from 'vitest'; 7 | 8 | const folder1 = Folder.factory().createOne({ name: 'folder 1' }); 9 | const folder2 = Folder.factory().createOne({ name: 'folder 2', parentId: folder1.getKey() }); 10 | const folder3 = Folder.factory().createOne({ name: 'folder 3', parentId: folder2.getKey() }); 11 | 12 | describe('AncestryCollection', () => { 13 | const folderCollection = new ModelCollection([folder1, folder2, folder3]); 14 | let collection: AncestryCollection; 15 | 16 | beforeEach(() => { 17 | collection = AncestryCollection.treeOf(folderCollection); 18 | }); 19 | 20 | describe('toTree()', () => { 21 | it('should arrange the models in a tree format', () => { 22 | expect(collection).toHaveLength(1); 23 | expect(collection.first()!.is(folder1)).toBe(true); 24 | 25 | expect(collection.first()!.children).toHaveLength(1); 26 | expect(collection.first()!.children.first()!.is(folder2)).toBe(true); 27 | 28 | expect(collection.first()!.children.first()!.children).toHaveLength(1); 29 | expect(collection.first()!.children.first()!.children.first()!.is(folder3)).toBe(true); 30 | }); 31 | 32 | it('should should set the depth to the appropriate values', () => { 33 | expect(collection.first()!.depth).toBe(0); 34 | expect(collection.first()!.children.first()!.depth).toBe(1); 35 | expect(collection.first()!.children.first()!.children.first()!.depth).toBe(2); 36 | }); 37 | 38 | it('should set the depth attribute using the static depth key', () => { 39 | AncestryCollection.depthName = 'myDepth'; 40 | const folderAncestryCollection = AncestryCollection.treeOf(folderCollection); 41 | 42 | expect(folderAncestryCollection.first()!.myDepth).toBe(0); 43 | // it was synced as original 44 | expect(folderAncestryCollection.first()!.hasChanges(AncestryCollection.depthName)).toBe(false); 45 | 46 | AncestryCollection.depthName = 'depth'; 47 | }); 48 | 49 | it('should handle non-linear and not sorted values', () => { 50 | const midLevelFolder = Folder.factory().createOne({ 51 | parentId: folder1.getKey(), 52 | name: 'mid level folder', 53 | id: 4 54 | }); 55 | const topLevelFolder = Folder.factory().createOne({ 56 | name: 'top level folder', 57 | id: 5 58 | }); 59 | const newFolderCollection = new ModelCollection( 60 | [midLevelFolder, folder1, folder2, folder3, topLevelFolder] 61 | ); 62 | 63 | const tree = AncestryCollection.treeOf(newFolderCollection); 64 | expect(tree).toHaveLength(2); 65 | const children = tree.findByKey(folder1.getKey())!.children; 66 | expect(children).toHaveLength(2); 67 | expect(children).toBeInstanceOf(ModelCollection); 68 | }); 69 | }); 70 | 71 | describe('flatten()', () => { 72 | it('should return the collection in a single level array', () => { 73 | expect(collection.flatten()).toHaveLength(folderCollection.length); 74 | }); 75 | 76 | it('should return a ModelCollection', () => { 77 | expect(collection.flatten()).toBeInstanceOf(ModelCollection); 78 | }); 79 | 80 | it('should remove the depth attribute', () => { 81 | expect(collection.flatten().every(folder => !('depth' in folder))).toBe(true); 82 | expect(collection.flatten().every(folder => folder.hasChanges(AncestryCollection.depthName))).toBe(false); 83 | }); 84 | }); 85 | 86 | describe('leaves()', () => { 87 | it('should return a ModelCollection', () => { 88 | expect(collection.leaves()).toBeInstanceOf(ModelCollection); 89 | }); 90 | 91 | it('should only return models with no children', () => { 92 | let leaves = collection.leaves(); 93 | 94 | expect(leaves).toHaveLength(1); 95 | expect(leaves.first()!.is(folder3)).toBe(true); 96 | 97 | const midLevelFolder = Folder.factory().createOne({ 98 | parentId: folder1.getKey(), 99 | name: 'mid level folder', 100 | id: 4 101 | }); 102 | const newFolderCollection = new ModelCollection([folder1, folder2, folder3, midLevelFolder]); 103 | 104 | leaves = AncestryCollection.treeOf(newFolderCollection).leaves(); 105 | 106 | expect(leaves).toHaveLength(2); 107 | expect(leaves.first()!.is(folder3)).toBe(true); 108 | expect(leaves.last()!.is(midLevelFolder)).toBe(true); 109 | 110 | folderCollection.pop(); 111 | }); 112 | }); 113 | 114 | describe('isAncestryCollection()', () => { 115 | it('should assert that it\' a model collection', () => { 116 | expect(AncestryCollection.isAncestryCollection(folderCollection)).toBe(false); 117 | expect(AncestryCollection.isAncestryCollection(new Collection([1, 2, 3]))).toBe(false); 118 | 119 | types.forEach(type => { 120 | expect(AncestryCollection.isAncestryCollection(type)).toBe(false); 121 | }); 122 | 123 | expect(AncestryCollection.isAncestryCollection(collection)).toBe(true); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /docs/prologue/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hi there! I'm thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 4 | 5 | ## Issues and PRs 6 | 7 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! I'd love all and any contributions. If you have questions, I'd love to hear them, that might just mean that the documentation is lacking. 8 | 9 | I'd also love PRs. If you're thinking of a large PR, I advise opening up an issue first to talk about it though! Look at the links below if you're not sure how to open a PR. 10 | 11 | ## Which branch 12 | 13 | ~~**Patch** bug fixes should be sent to the latest stable branch. Bug fixes should never be sent to the main branch unless they fix features that exist only in the upcoming release.~~ 14 | 15 | ~~**Minor** features that are fully backward compatible with the current release may be sent to the latest stable branch (`release/{version}.x`).~~ 16 | 17 | ~~**Major** new features should always be sent to the `main` branch, which contains the upcoming release.~~ 18 | 19 | Until reaching a stable version (v1), all pull requests should start from `main` and with your changes go into `main`. After that `main` will get merged into the `release/0.x` branch. 20 | 21 | ## Best practices 22 | 23 | - The code should be self documenting. If a piece of logic might not be easy to reason by for a new contributor, consider adding a comment or two explaining the logic. 24 | - The code should be written defensively to reduce the possible errors in the consuming applications. However, a balance should be struck to avoid overhead and developer relying too much on the library. Throwing errors are also accepted where the developer will likely make a fatal mistake. 25 | 26 | ## Submitting a pull request 27 | 28 | 1. Fork and clone the repository. 29 | 2. Run `npm ci`. 30 | 3. Create a new branch: `git checkout -b my-branch-name`. 31 | 4. Make your change, add tests, and make sure the tests still pass. 32 | 5. [Commit](#commit-message-formats) and push to your fork and submit a pull request to the [relevant branch](#which-branch). 33 | 6. Pat your self on the back and wait for your pull request to be reviewed and merged. 34 | 35 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 36 | 37 | - Follow the style standards which is included in the project. Any linting errors should be shown when running `npm run lint`. 38 | - Write and update tests. 39 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 40 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 41 | - Update the documentation if applicable. 42 | 43 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocking you. 44 | 45 | ## Commit Message Formats 46 | 47 | Commit messages are integral to navigating version control, be it by a human or automated tool. To attempt to standardise the messages, upfront uses [conventional commit messages](https://www.npmjs.com/package/@commitlint/config-conventional) e.g.: 48 | - `feat: ` - commit for a feature pull request e.g.: 49 | ```git 50 | feat(collection): Add isEmpty method to the Collection class 51 | 52 | Added isEmpty method as previously discussed on 53 | https://github.com/.... 54 | ``` 55 | 56 | ```git 57 | feat(graphql): Started building the GraphQL driver 58 | 59 | - Added response parsing support 60 | - Added request compiling service 61 | ``` 62 | - `fix: ` - commit for bug fixing pull request e.g.: 63 | ```git 64 | fix(query-builder): Fixed the invalid query response handling logic 65 | 66 | Updated handler to correctly parse response and 67 | added graceful error handling 68 | Resolves upfrontjs/framework#99, upfrontjs/framework#100 69 | ``` 70 | - `chore: ` - commit for code maintenance pull request e.g.: 71 | ```git 72 | chore(dev-deps): Updated dependencies 73 | 74 | - rollup 75 | - typescript 76 | - eslint 77 | ``` 78 | - `docs: ` - commit for a branch updating the documentation e.g.: 79 | ```git 80 | docs(helpers): Clarified testing helper's description 81 | ``` 82 | 83 | If your commit is related to a discussion/issue on GitHub, please [link to it](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) in your commit message. 84 | 85 | If you need more guidance beyond the conventional format, you may use `npm run commit` which will help build a commit message. Additional help can be found at the [resources](#resources) section. 86 | 87 | ## Documentation 88 | 89 | This documentation is kept alongside the source code to keep it in sync with the code it belongs to, and to allow for updating the docs in one go with code changes. 90 | 91 | To update the docs in the context of this documentation site I advise you pull down the [upfront docs repo](https://github.com/upfrontjs/docs) and create a symbolic link between framework the docs e.g.: 92 | ```shell 93 | ln -sf /absolute/path/to/upfrontjs/framework/docs/* /absolute/path/to/upfrontjs/docs/ 94 | ``` 95 | 96 | ## Resources 97 | 98 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 99 | - [TypeScript coding guidelines](https://github.com/microsoft/TypeScript/wiki/Coding-guidelines) 100 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 101 | - [Commit message best practices](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53) 102 | - [GitHub Help](https://help.github.com) 103 | -------------------------------------------------------------------------------- /src/Calliope/AncestryCollection.ts: -------------------------------------------------------------------------------- 1 | import ModelCollection from './ModelCollection'; 2 | import type Model from './Model'; 3 | import type { MaybeArray } from '../Support/type'; 4 | 5 | type Ancestralised< 6 | T extends Model, 7 | CT extends string = 'children' 8 | > = T & { [key in CT]: ModelCollection> }; 9 | 10 | export default class AncestryCollection< 11 | T extends Model, 12 | CT extends string = 'children' 13 | > extends ModelCollection> { 14 | /** 15 | * The name of the key that will be set when arranging the items in a tree structure. 16 | */ 17 | public static depthName = 'depth'; 18 | 19 | /** 20 | * The name of the attribute that includes the related models. 21 | * 22 | * @protected 23 | */ 24 | protected childrenRelation: CT; 25 | 26 | /** 27 | * @param models - The models already arranged in an ancestry tree format. 28 | * @param childrenRelation - The key that will include descendants. 29 | * 30 | */ 31 | protected constructor( 32 | models?: MaybeArray>, 33 | childrenRelation: CT = 'children' as CT 34 | ) { 35 | super(models); 36 | this.childrenRelation = childrenRelation; 37 | } 38 | 39 | /** 40 | * Arrange the items in an ancestry tree format. 41 | * 42 | * @param models - The ModelCollection to sort. 43 | * @param parentKey - The key that identifies the parent's id. 44 | * @param childrenRelation - The key that will include descendants. 45 | * 46 | * @return {AncestryCollection} 47 | */ 48 | public static treeOf( 49 | models: ModelCollection | ST[], 50 | parentKey = 'parentId', 51 | childrenRelation: CT = 'children' as CT 52 | ): AncestryCollection { 53 | const buildModelsRecursive = ( 54 | modelItems: ST[], 55 | parent?: ST, 56 | depth = 0 57 | ): Ancestralised[] => { 58 | const modelArray: Ancestralised[] = []; 59 | 60 | modelItems.forEach(model => { 61 | // if this is a child, but we are looking for a top level 62 | if (!parent && model.getAttribute(parentKey)) { 63 | return; 64 | } 65 | 66 | // if this is a child, but this child doesn't belong to this parent 67 | if (parent && model.getAttribute(parentKey) !== parent.getKey()) { 68 | return; 69 | } 70 | 71 | model.setAttribute(this.depthName, depth) 72 | .syncOriginal(this.depthName) 73 | .setAttribute( 74 | childrenRelation, 75 | // by this filter we eventually will run out of items on the individual branches 76 | buildModelsRecursive(modelItems.filter(m => m.getKey() !== model.getKey()), model, depth + 1) 77 | ); 78 | 79 | modelArray.push(model as Ancestralised); 80 | }); 81 | 82 | return modelArray; 83 | }; 84 | 85 | return new AncestryCollection( 86 | buildModelsRecursive(Array.isArray(models) ? models : models.toArray()), 87 | childrenRelation 88 | ); 89 | } 90 | 91 | /** 92 | * Return all the models in a single level with no children set. 93 | * 94 | * @return {ModelCollection} 95 | */ 96 | public flatten(): ModelCollection { 97 | const getModelsRecursive = (models: T[]): T[] => { 98 | const modelArray: T[] = []; 99 | 100 | models.forEach(model => { 101 | const children = (model.getAttribute(this.childrenRelation) ?? []) as ModelCollection | T[]; 102 | 103 | model.setAttribute(this.childrenRelation, []); 104 | modelArray.push(model); 105 | 106 | if (children.length) { 107 | modelArray.push(...getModelsRecursive( 108 | ModelCollection.isModelCollection(children) ? children.toArray() : children 109 | )); 110 | } 111 | }); 112 | 113 | return modelArray.map( 114 | m => m.deleteAttribute((this.constructor as typeof AncestryCollection).depthName) 115 | .syncOriginal((this.constructor as typeof AncestryCollection).depthName) 116 | ); 117 | }; 118 | 119 | return new ModelCollection(getModelsRecursive(this.toArray())); 120 | } 121 | 122 | /** 123 | * All the models that do not have any children. 124 | * 125 | * @return {ModelCollection} 126 | */ 127 | public leaves(): ModelCollection { 128 | const getLeaves = (models: T[]): T[] => { 129 | const leaves: T[] = []; 130 | 131 | models.forEach(model => { 132 | const children = model.getAttribute(this.childrenRelation) as ModelCollection | T[] | undefined; 133 | 134 | if (!children?.length) { 135 | leaves.push(model); 136 | } 137 | 138 | leaves.push( 139 | ...getLeaves(ModelCollection.isModelCollection(children) ? children.toArray() : children!) 140 | ); 141 | }); 142 | 143 | return leaves; 144 | }; 145 | 146 | return new ModelCollection(getLeaves(this.toArray())); 147 | } 148 | 149 | /** 150 | * Asserts whether the given value 151 | * is an instance of AncestryCollection. 152 | * 153 | * @param value 154 | * 155 | * @return {boolean} 156 | */ 157 | public static isAncestryCollection(value: any): value is AncestryCollection { 158 | return this.isModelCollection(value) && value instanceof AncestryCollection; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /tests/Support/GlobalConfig.test.ts: -------------------------------------------------------------------------------- 1 | import GlobalConfig from '../../src/Support/GlobalConfig'; 2 | import API from '../../src/Services/API'; 3 | import type Configuration from '../../src/Contracts/Configuration'; 4 | import { beforeEach, describe, expect, it } from 'vitest'; 5 | 6 | // initial type is required so set's assertion doesn't trigger circular analysis for typescript 7 | const config: GlobalConfig = new GlobalConfig(); 8 | 9 | describe('GlobalConfig', () => { 10 | beforeEach(() => { 11 | config.unset('api'); 12 | GlobalConfig.usedAsReference = ['headers']; 13 | }); 14 | 15 | it('should have the baseEndpoint persisted from the setupTests.ts', () => { 16 | expect(config.get('baseEndPoint')).toBe('https://test-api-endpoint.com'); 17 | }); 18 | 19 | describe('.usedAsReference', () => { 20 | it('should make set and get return by reference', () => { 21 | // typeof new Headers() === 'object' 22 | config.set('headers', new Headers()); 23 | 24 | expect(config.get('headers')).toBeInstanceOf(Headers); 25 | 26 | const myObject = { key: 'value' }; 27 | 28 | config.set('myObject', myObject); 29 | 30 | myObject.key = 'updated value'; 31 | expect(config.get('myObject').key).toBe('value'); 32 | 33 | GlobalConfig.usedAsReference.push('myObject'); 34 | config.set('myObject', myObject); 35 | myObject.key = 'updated value'; 36 | expect(config.get('myObject').key).toBe('updated value'); 37 | }); 38 | 39 | it('should return all values by reference if value set to \'*\'', () => { 40 | GlobalConfig.usedAsReference = ['*']; 41 | 42 | const myArray: number[] = []; 43 | const myObject = { key: 'value' }; 44 | config.set('myObject', myObject); 45 | config.set('myArray', myArray); 46 | 47 | myArray.push(1); 48 | myObject.key = 'updated value'; 49 | 50 | expect(config.get('myObject').key).toBe('updated value'); 51 | expect(config.get('myArray')).toHaveLength(1); 52 | }); 53 | }); 54 | 55 | describe('construct()', () => { 56 | it('should be instantiated with some config values', () => { 57 | new GlobalConfig({ api: API }); 58 | 59 | expect(config.get('api')).toStrictEqual(API); 60 | }); 61 | 62 | it('should prevent changing values by reference when merging config', () => { 63 | const deepObj = { count: 1 }; 64 | const obj = { test: deepObj }; 65 | 66 | new GlobalConfig({ obj }); 67 | 68 | deepObj.count++; 69 | 70 | 71 | expect((config.get('obj') as typeof obj).test.count).toBe(1); 72 | }); 73 | }); 74 | 75 | describe('get()', () => { 76 | it('should get a specified value', () => { 77 | config.set('api', API); 78 | 79 | config.set('something', 3); 80 | 81 | config.get('something'); 82 | 83 | expect(config.get('api')).toStrictEqual(API); 84 | }); 85 | 86 | it('should return the default if key not found', () => { 87 | expect(config.get('newKey', 'default')).toBe('default'); 88 | }); 89 | 90 | it('should return falsy values if set', () => { 91 | config.set('test', false); 92 | expect(config.get('test')).toBe(false); 93 | expect(config.get('test', 'decoy value')).toBe(false); 94 | 95 | config.set('test1', null); 96 | expect(config.get('test1')).toBeNull(); 97 | expect(config.get('test1', 'decoy value')).toBeNull(); 98 | 99 | config.set('test2', undefined); 100 | expect(config.get('test2')).toBeUndefined(); 101 | expect(config.get('test2', 'decoy value')).toBeUndefined(); 102 | }); 103 | 104 | it('should prevent changing values by reference by returning clone', () => { 105 | const obj = { test: 1 }; 106 | config.set('test', obj); 107 | 108 | const copy = config.get('test'); 109 | 110 | copy.test++; 111 | 112 | expect(config.get('test').test).toBe(1); 113 | expect(obj.test).toBe(1); 114 | }); 115 | }); 116 | 117 | describe('has()', () => { 118 | it('should determine whether the value is set', function () { 119 | expect(config.has('api')).toBe(false); 120 | 121 | config.set('api', API); 122 | 123 | expect(config.has('api')).toBe(true); 124 | }); 125 | }); 126 | 127 | describe('set()', () => { 128 | it('should set a value', () => { 129 | expect(config.has('api')).toBe(false); 130 | 131 | config.set('api', API); 132 | 133 | expect(config.get('api')).toStrictEqual(API); 134 | }); 135 | 136 | it('should prevent changing value by reference', () => { 137 | const obj = { test: 1 }; 138 | config.set('test', obj); 139 | 140 | expect(config.get('test').test).toBe(1); 141 | 142 | obj.test++; 143 | 144 | expect(config.get('test').test).toBe(1); 145 | }); 146 | }); 147 | 148 | describe('unset()', () => { 149 | it('should set a value', function () { 150 | config.set('api', API); 151 | 152 | expect(config.has('api')).toBe(true); 153 | 154 | config.unset('api'); 155 | 156 | expect(config.has('api')).toBe(false); 157 | }); 158 | }); 159 | 160 | describe('reset()', () => { 161 | it('should empty the configuration', function () { 162 | config.set('api', API); 163 | 164 | expect(config.has('api')).toBe(true); 165 | 166 | config.reset(); 167 | 168 | expect(config.has('api')).toBe(false); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /docs/services/readme.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | Services are classes that gets delegated some responsibility by upfront. With services, it's easy to adjust upfront's behaviour without having to extend the [model](../calliope/readme.md) and write elaborate overrides. You may switch them out or extend them to fit your needs. To see how you change the implementations, visit the [global config page](../helpers/global-config.md#set). 4 | 5 | ## Service Interfaces 6 | Services have some interfaces that they need to implement on order to work. 7 | 8 | #### `ApiCaller` 9 | ApiCaller an object with a `call` method defined which is utilized by all the ajax requests initiated by upfront, and it is responsible for sending a request with the given data returning a `Promise`. The arguments the `call` method takes in order are: 10 | - `url` - a string 11 | - `method` - a string representing the http method 12 | - `data`*(optional)* - an object or [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) 13 | - `customHeaders`*(optional)* - an object with string keys and string or array of strings value 14 | - `queryParameters`*(optional)* - an object to send as query parameters in the url 15 | 16 | #### `HandlesApiResponse` 17 | HandlesApiResponse's task is to handle the parsing of the [ApiResponse](#apiresponse) returned by [ApiCaller](#apicaller) and deal with any errors. It defines a `handle` method which takes a `Promise` and it should return a `Promise`. 18 | 19 | ##### ApiResponse 20 | 21 | As you might have noticed in the above service interfaces they work with an object called `ApiResponse` as opposed to a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). This is because this interface is more generic and can easily be implemented if deciding to use something other than the [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) api. The only keys always available on the object are the following properties: 22 | - [status](https://developer.mozilla.org/en-US/docs/Web/API/Response/status) 23 | - [statusText](https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText) 24 | - [headers](https://developer.mozilla.org/en-US/docs/Web/API/Response/headers) 25 | 26 | ::: tip 27 | Typescript users may use [module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) to specify what their `ApiResponse` is actually look like: 28 | ```ts 29 | // shims/upfront.d.ts 30 | import type { ApiResponse as BaseApiResponse } from '@upfrontjs/framework'; 31 | 32 | declare module '@upfrontjs/framework' { 33 | interface ApiResponse extends BaseApiResponse { 34 | myKey?: string; 35 | } 36 | } 37 | ``` 38 | ::: 39 | 40 | --- 41 | 42 | Upfront provides the implementations for the above interfaces which should cover most use cases. If you don't set your own implementation, upfront will fall back to these default services. 43 | 44 | - [API](./api.md) - implements `ApiCaller` 45 | - [ApiResponseHandler](./api-response-handler.md) - implements `HandlesApiResponse` 46 | 47 | ### Using Custom Services 48 | 49 | #### Implementing interfaces 50 | If you're thinking about creating your own service, for reference you may check out the interfaces and/or the default implementation's source code. 51 | 52 | Creating a service is easy as: 53 | 54 | 55 | 56 | 57 | 58 | ```js 59 | // MyHandler.js 60 | import notification from 'notification-lib'; 61 | 62 | export default class MyHandler { 63 | handle(promise) { 64 | return promise.then(response => { 65 | if (response.status >= 300 && response.status < 400) { 66 | // etc... 67 | } 68 | // response handling 69 | }) 70 | .catch(error => notification(error.message)); 71 | } 72 | } 73 | 74 | // entry-file.js 75 | import { GlobalConfig } from '@upfrontjs/framework'; 76 | import MyHandler from './Services/MyHandler'; 77 | 78 | new GlobalConfig({ 79 | apiResponseHandler: MyHandler, 80 | }) 81 | ``` 82 | 83 | 84 | 85 | 86 | ```ts 87 | // MyHandler.ts 88 | import type { HandlesApiResponse } from '@upfrontjs/framework'; 89 | import notification from 'notification-lib'; 90 | 91 | export default class MyHandler implements HandlesApiResponse { 92 | public handle(promise: Promise): Promise { 93 | return promise 94 | .then(response => { 95 | if (response.status >= 300 && response.status <= 400) { 96 | // etc... 97 | } 98 | // response handling 99 | }) 100 | .catch((error) => notification(error.message)) 101 | } 102 | } 103 | 104 | // entry-file.ts 105 | import { GlobalConfig } from '@upfrontjs/framework'; 106 | import MyHandler from './Services/MyHandler'; 107 | 108 | new GlobalConfig({ 109 | apiResponseHandler: MyHandler, 110 | }) 111 | ``` 112 | 113 | 114 | 115 | #### Extending Services 116 | If you just want to extend a service to add some functionality like adding [initRequest()](./api.md#initrequest) to the [API](./api.md), that can be achieved like so: 117 | 118 | 119 | 120 | 121 | ```js 122 | // MyHandler.js 123 | import { ApiResponseHandler } from '@upfrontjs/framework'; 124 | 125 | export default class MyHandler extends ApiResponseHandler { 126 | handleFinally() { 127 | // any operations after the request 128 | } 129 | } 130 | 131 | // entry file.js 132 | import { GlobalConfig } from '@upfrontjs/framework'; 133 | import MyHandler from './Services/MyHandler'; 134 | 135 | new GlobalConfig({ 136 | apiResponseHandler: MyHandler, 137 | }) 138 | ``` 139 | 140 | 141 | 142 | 143 | ```ts 144 | // MyHandler.ts 145 | import { ApiResponseHandler } from '@upfrontjs/framework'; 146 | 147 | export default class MyHandler extends ApiResponseHandler { 148 | public handleFinally(): void { 149 | // any operations after the request 150 | } 151 | } 152 | 153 | // entry file.ts 154 | import { GlobalConfig } from '@upfrontjs/framework'; 155 | import MyHandler from './Services/MyHandler'; 156 | 157 | new GlobalConfig({ 158 | apiResponseHandler: MyHandler, 159 | }) 160 | ``` 161 | 162 | 163 | 164 | You can find examples of testing custom services in the [Testing section](../testing/readme.md#testing-service-implementations). 165 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upfrontjs/framework", 3 | "version": "0.19.0", 4 | "description": "Data handling framework complementary to backend model systems.", 5 | "main": "index.min.cjs", 6 | "type": "module", 7 | "module": "index.es.min.js", 8 | "exports": { 9 | ".": { 10 | "import": "./index.es.min.js", 11 | "types": "./types/index.d.ts", 12 | "require": "./index.min.cjs", 13 | "default": "./index.min.js" 14 | }, 15 | "./string": { 16 | "import": "./string.es.min.js", 17 | "types": "./types/string.d.ts/", 18 | "require": "./string.es.min.cjs" 19 | }, 20 | "./array": { 21 | "import": "./array.es.min.js", 22 | "types": "./types/array.d.ts/", 23 | "require": "./array.es.min.cjs" 24 | } 25 | }, 26 | "types": "./types/index.d.ts", 27 | "files": [ 28 | "Support", 29 | "string*.js", 30 | "array*.js", 31 | "index*.js", 32 | "*.js.map", 33 | "src", 34 | "types" 35 | ], 36 | "author": "Nandor Kraszlan", 37 | "license": "MIT", 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/upfrontjs/framework.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/upfrontjs/framework/issues" 44 | }, 45 | "homepage": "https://upfrontjs.com/", 46 | "directories": { 47 | "lib": "./src", 48 | "doc": "./docs", 49 | "test": "./tests" 50 | }, 51 | "keywords": [ 52 | "model", 53 | "data handling", 54 | "object oriented", 55 | "active record", 56 | "orm", 57 | "front end", 58 | "browser", 59 | "api", 60 | "rest", 61 | "json", 62 | "framework", 63 | "factory", 64 | "collection", 65 | "ancestry tree", 66 | "helpers", 67 | "string", 68 | "array", 69 | "typescript", 70 | "tested", 71 | "relations", 72 | "attributes", 73 | "query", 74 | "casting", 75 | "guarding", 76 | "timestamps", 77 | "soft deletes", 78 | "esm", 79 | "pagination", 80 | "config", 81 | "in memory store", 82 | "events", 83 | "event bus", 84 | "event emitter", 85 | "eloquent", 86 | "upfront" 87 | ], 88 | "scripts": { 89 | "test": "vitest run -c ./tests/vitest.config.ts", 90 | "test:coverage": "npm run test -- --coverage", 91 | "test:types": "tsc --noEmit", 92 | "lint": "eslint . --cache --fix --ext .ts", 93 | "emit-declarations": "tsc --project ./build.tsconfig.json --declaration --declarationMap --declarationDir ./types --emitDeclarationOnly", 94 | "build": "rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript && npm run emit-declarations", 95 | "docs:api": "[ ! -d './types' ] && npm run emit-declarations || echo './types folder exists' && npx typedoc", 96 | "prepare": "husky install", 97 | "commit": "commit" 98 | }, 99 | "dependencies": { 100 | "lodash.clonedeep": "^4.5.0", 101 | "lodash.isequal": "^4.5.0", 102 | "lodash.merge": "^4.6.2", 103 | "lodash.orderby": "^4.6.0", 104 | "lodash.snakecase": "^4.1.1", 105 | "lodash.uniq": "^4.5.0", 106 | "pluralize": "^8.0.0", 107 | "qs": "^6.9.4", 108 | "uuid": "^13.0.0" 109 | }, 110 | "devDependencies": { 111 | "@commitlint/config-conventional": "^19.1.0", 112 | "@commitlint/prompt-cli": "^19.2.0", 113 | "@commitlint/types": "^19.0.3", 114 | "@edge-runtime/vm": "^5.0.0", 115 | "@eslint/js": "^9.27.0", 116 | "@rollup/plugin-terser": "^0.4.0", 117 | "@rollup/plugin-typescript": "^12.1.1", 118 | "@semantic-release/git": "^10.0.0", 119 | "@stylistic/eslint-plugin": "^5.4.0", 120 | "@tsconfig/strictest": "^2.0.5", 121 | "@types/lodash.clonedeep": "^4.5.7", 122 | "@types/lodash.isequal": "^4.5.6", 123 | "@types/lodash.merge": "^4.6.7", 124 | "@types/lodash.orderby": "^4.6.7", 125 | "@types/lodash.snakecase": "^4.1.7", 126 | "@types/lodash.uniq": "^4.5.7", 127 | "@types/pluralize": "^0.0.33", 128 | "@types/qs": "^6.9.5", 129 | "@types/semantic-release": "^20.0.1", 130 | "@typescript-eslint/eslint-plugin": "^8.14.0", 131 | "@typescript-eslint/parser": "^8.14.0", 132 | "@vitest/coverage-v8": "^3.1.1", 133 | "@vitest/eslint-plugin": "^1.1.10", 134 | "commitlint": "^19.2.1", 135 | "conventional-changelog-conventionalcommits": "^9.1.0", 136 | "eslint": "^9.27.0", 137 | "eslint-plugin-import": "^2.22.1", 138 | "glob": "^11.0.0", 139 | "globals": "^16.2.0", 140 | "husky": "^9.0.11", 141 | "jsdom": "^27.0.0", 142 | "lint-staged": "^16.2.0", 143 | "rollup": "^4.14.1", 144 | "rollup-plugin-output-size": "^2.0.0", 145 | "semantic-release": "^24.2.0", 146 | "ts-node": "^10.9.2", 147 | "tslib": "^2.2.0", 148 | "typedoc": "^0.28.2", 149 | "typescript": "^5.7.2", 150 | "typescript-eslint": "^8.32.1", 151 | "vitest": "^3.1.1" 152 | }, 153 | "peerDependencies": { 154 | "@types/lodash.clonedeep": "^4.5.7", 155 | "@types/lodash.isequal": "^4.5.6", 156 | "@types/lodash.merge": "^4.6.7", 157 | "@types/lodash.orderby": "^4.6.7", 158 | "@types/lodash.snakecase": "^4.1.7", 159 | "@types/lodash.uniq": "^4.5.7", 160 | "@types/pluralize": "^0.0.33", 161 | "@types/qs": "^6.9.5" 162 | }, 163 | "peerDependenciesMeta": { 164 | "@types/qs": { 165 | "optional": true 166 | }, 167 | "@types/lodash.clonedeep": { 168 | "optional": true 169 | }, 170 | "@types/lodash.isequal": { 171 | "optional": true 172 | }, 173 | "@types/lodash.merge": { 174 | "optional": true 175 | }, 176 | "@types/lodash.orderby": { 177 | "optional": true 178 | }, 179 | "@types/lodash.snakecase": { 180 | "optional": true 181 | }, 182 | "@types/lodash.uniq": { 183 | "optional": true 184 | }, 185 | "@types/pluralize": { 186 | "optional": true 187 | } 188 | }, 189 | "lint-staged": { 190 | "*.ts": "eslint --cache --fix" 191 | }, 192 | "funding": [ 193 | { 194 | "type": "github", 195 | "url": "https://github.com/sponsors/nandi95" 196 | } 197 | ], 198 | "engines": { 199 | "node": ">=18.20.0" 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /docs/prologue/project-policies.md: -------------------------------------------------------------------------------- 1 | # Project Policies 2 | 3 | ## Versioning policy 4 | 5 | Upfront follows [semantic versioning](https://semver.org) meaning minor and patch releases will not contain breaking changes. 6 | 7 | **However, during the initial development [(0.x)](https://github.com/upfrontjs/framework/tree/release/0.x) minor and patch releases may contain breaking changes due to the unstable nature of the version.** 8 | 9 | For more information on the changes, consult the [releases page](https://github.com/upfrontjs/framework/releases) on GitHub. 10 | 11 | ## Changelog 12 | 13 | Currently, The changes can be tracked by following the [releases page](https://github.com/upfrontjs/framework/releases) on GitHub. 14 | 15 | ## Contributor Covenant Code of Conduct 16 | 17 | ### Our Pledge 18 | 19 | We as members, contributors, and leaders pledge to make participation in our 20 | community a harassment-free experience for everyone, regardless of age, body 21 | size, visible or invisible disability, ethnicity, sex characteristics, gender 22 | identity and expression, level of experience, education, socio-economic status, 23 | nationality, personal appearance, race, caste, color, religion, or sexual 24 | identity and orientation. 25 | 26 | We pledge to act and interact in ways that contribute to an open, welcoming, 27 | diverse, inclusive, and healthy community. 28 | 29 | ### Our Standards 30 | 31 | Examples of behavior that contributes to a positive environment for our 32 | community include: 33 | 34 | * Demonstrating empathy and kindness toward other people 35 | * Being respectful of differing opinions, viewpoints, and experiences 36 | * Giving and gracefully accepting constructive feedback 37 | * Accepting responsibility and apologizing to those affected by our mistakes, 38 | and learning from the experience 39 | * Focusing on what is best not just for us as individuals, but for the overall 40 | community 41 | 42 | Examples of unacceptable behavior include: 43 | 44 | * The use of sexualized language or imagery, and sexual attention or advances of 45 | any kind 46 | * Trolling, insulting or derogatory comments, and personal or political attacks 47 | * Public or private harassment 48 | * Publishing others' private information, such as a physical or email address, 49 | without their explicit permission 50 | * Other conduct which could reasonably be considered inappropriate in a 51 | professional setting 52 | 53 | ### Enforcement Responsibilities 54 | 55 | Community leaders are responsible for clarifying and enforcing our standards of 56 | acceptable behavior and will take appropriate and fair corrective action in 57 | response to any behavior that they deem inappropriate, threatening, offensive, 58 | or harmful. 59 | 60 | Community leaders have the right and responsibility to remove, edit, or reject 61 | comments, commits, code, wiki edits, issues, and other contributions that are 62 | not aligned to this Code of Conduct, and will communicate reasons for moderation 63 | decisions when appropriate. 64 | 65 | ### Scope 66 | 67 | This Code of Conduct applies within all community spaces, and also applies when 68 | an individual is officially representing the community in public spaces. 69 | Examples of representing our community include using an official e-mail address, 70 | posting via an official social media account, or acting as an appointed 71 | representative at an online or offline event. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported to the community leaders responsible for enforcement at 77 | [nandor.kraszlan@gmail.com](mailto:nandor.kraszlan@gmail.com). 78 | All complaints will be reviewed and investigated promptly and fairly. 79 | 80 | All community leaders are obligated to respect the privacy and security of the 81 | reporter of any incident. 82 | 83 | ### Enforcement Guidelines 84 | 85 | Community leaders will follow these Community Impact Guidelines in determining 86 | the consequences for any action they deem in violation of this Code of Conduct: 87 | 88 | #### 1. Correction 89 | 90 | **Community Impact**: Use of inappropriate language or other behavior deemed 91 | unprofessional or unwelcome in the community. 92 | 93 | **Consequence**: A private, written warning from community leaders, providing 94 | clarity around the nature of the violation and an explanation of why the 95 | behavior was inappropriate. A public apology may be requested. 96 | 97 | #### 2. Warning 98 | 99 | **Community Impact**: A violation through a single incident or series of 100 | actions. 101 | 102 | **Consequence**: A warning with consequences for continued behavior. No 103 | interaction with the people involved, including unsolicited interaction with 104 | those enforcing the Code of Conduct, for a specified period of time. This 105 | includes avoiding interactions in community spaces as well as external channels 106 | like social media. Violating these terms may lead to a temporary or permanent 107 | ban. 108 | 109 | #### 3. Temporary Ban 110 | 111 | **Community Impact**: A serious violation of community standards, including 112 | sustained inappropriate behavior. 113 | 114 | **Consequence**: A temporary ban from any sort of interaction or public 115 | communication with the community for a specified period of time. No public or 116 | private interaction with the people involved, including unsolicited interaction 117 | with those enforcing the Code of Conduct, is allowed during this period. 118 | Violating these terms may lead to a permanent ban. 119 | 120 | #### 4. Permanent Ban 121 | 122 | **Community Impact**: Demonstrating a pattern of violation of community 123 | standards, including sustained inappropriate behavior, harassment of an 124 | individual, or aggression toward or disparagement of classes of individuals. 125 | 126 | **Consequence**: A permanent ban from any sort of public interaction within the 127 | community. 128 | 129 | ### Attribution 130 | 131 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 132 | version 2.1, available at 133 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 134 | 135 | Community Impact Guidelines were inspired by 136 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 137 | 138 | For answers to common questions about this code of conduct, see the FAQ at 139 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 140 | [https://www.contributor-covenant.org/translations][translations]. 141 | 142 | [homepage]: https://www.contributor-covenant.org 143 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 144 | [Mozilla CoC]: https://github.com/mozilla/diversity 145 | [FAQ]: https://www.contributor-covenant.org/faq 146 | [translations]: https://www.contributor-covenant.org/translations 147 | -------------------------------------------------------------------------------- /docs/helpers/global-config.md: -------------------------------------------------------------------------------- 1 | # Global config 2 | 3 | GlobalConfig is an in-memory store for all your globally needed configuration values. 4 | 5 | ::: danger 6 | Wherever you import your config, the configuration previously set will be present in the imported GlobalConfig. 7 | ::: 8 | 9 | The config serves as container for some [pre-defined keys](#configuration) that you may set that upfront can later use, and to be used in your script to the same extent. 10 | 11 | To use, just instantiate a `new GlobalConfig`, and you're ready to interact with your existing configuration by the [available methods](#methods). 12 | 13 | ::: tip 14 | For typescript users it might be advisable to export your configuration from a single place to take advantage from the type hinting your own configuration provides. 15 | ```ts 16 | import { GlobalConfig } from '@upfrontjs/framework'; 17 | import type { MyConfiguration } from './types' 18 | 19 | const config: GlobalConfig = new GlobalConfig({}); 20 | export { config }; 21 | ``` 22 | ::: 23 | 24 | [[toc]] 25 | 26 | ## Properties 27 | 28 | #### usedAsReference 29 | 30 | 31 | Given the [set](#set) and [get](#get) methods are cloning all values that are not of type `function`; `usedAsReference` static property has been added on the `GlobalConfig` class as an escape hatch. All property names set in this array will be returned as is without cloning. If `'*'` is present in this array, all values will be returned as is. 32 | The default value is `['headers']` as no subtype of `HeadersInit` is of type `function`. 33 | ## Methods 34 | 35 | #### constructor 36 | 37 | The class constructor takes a [configuration](#configuration) object which gets deep merged into the existing configuration if any. 38 | 39 | ::: warning 40 | To avoid triggering circular analysis in typescript when using the [set](#set) method, when using the constructor you should set an initial type. 41 | ```ts 42 | import { GlobalConfig } from '@upfrontjs/framework'; 43 | import type { Configuration } from '@upfrontjs/framework'; 44 | 45 | const config: GlobalConfig = new GlobalConfig(); 46 | 47 | config.set('key', 'value'); 48 | ``` 49 | vs 50 | ```ts 51 | import { GlobalConfig } from '@upfrontjs/framework'; 52 | import type { Configuration } from '@upfrontjs/framework'; 53 | 54 | const config = new GlobalConfig(); 55 | 56 | config.set('key', 'value'); // TS2775: Assertions require every name in the call target to be declared with an explicit type annotation. 57 | ``` 58 | 59 | ::: 60 | 61 | #### set 62 | 63 | The set method sets the given value in the config. 64 | ```js 65 | config.set('key', 'value'); 66 | 67 | config.has('key'); // true 68 | ``` 69 | ::: warning 70 | Values will be cloned subject to [usedAsReference](#usedasreference). 71 | ::: 72 | 73 | #### get 74 | 75 | The get method returns the requested value. If the key not found it returns the second optional parameter, which is the default value. 76 | ```js 77 | config.set('key', 'value'); 78 | 79 | config.get('key'); // 'value' 80 | config.get('nonExistentKey'); // undefined 81 | config.get('nonExistentKey', 1); // 1 82 | ``` 83 | ::: warning 84 | Values will be cloned subject to [usedAsReference](#usedasreference). 85 | ::: 86 | 87 | #### has 88 | 89 | The get method determines whether the given key is set in the config. 90 | 91 | ```js 92 | config.has('key'); // false 93 | 94 | config.set('key', 'value'); 95 | 96 | config.has('key'); // true 97 | ``` 98 | 99 | #### unset 100 | 101 | The unset method removes the given key from the config. 102 | 103 | ```js 104 | config.set('key', 'value'); 105 | config.unset('key'); 106 | 107 | config.has('key'); // false 108 | ``` 109 | 110 | #### reset 111 | 112 | The reset method removes all the values from the config. 113 | ```js 114 | config.set('key', 'value'); 115 | config.reset(); 116 | 117 | config.has('key'); // false 118 | ``` 119 | 120 | ## Configuration 121 | 122 | Configuration is an interface describing some predefined keys, all of which are optional. If a configuration is given to the GlobalConfig, and any of the pre-defined keys are present, they must match the type set in the Configuration. 123 | The following keys are present: 124 | 125 | #### api 126 | 127 | This value if set, will be used in the model [on requests](../calliope/api-calls.md). 128 | It must implement the [ApiCaller](../services/readme.md#apicaller) interface. 129 | 130 | #### apiResponseHandler 131 | 132 | This value if set, will be used in the model [on requests](../calliope/api-calls.md). 133 | It must implement the [HandlesApiResponse](../services/readme.md#handlesapiresponse) interface. 134 | 135 | #### datetime 136 | 137 | This value if set, will be used by to [cast values](../calliope/attributes.md#casting) to the date-time library of your choice, if casting is configured on the model. This has to be either a function which will be called with the value `dateTime(attribute)` or a class with will be constructed with the value e.g.: `new DateTime(attribute)` 138 | 139 | #### headers 140 | 141 | This value if set will be merged into the request headers by the [API](../services/api.md) service class if that service is used. This value has to match the type of `HeadersInit`. 142 | 143 | #### baseEndPoint 144 | This is a `string` that the [model's endpoint](../calliope/api-calls.md#endpoint) will be prefixed by on requests. Example value would be: `'https://my-awesome-api.com'`. 145 | 146 | #### randomDataGenerator 147 | 148 | This value if set, it will be available for consuming in your [Factories](../testing/readme.md#factories) under the member key [random](../testing/readme.md#random). 149 | 150 | #### requestMiddleware 151 | 152 | The requestMiddleware is an object that implements the `RequestMiddleware` interface. This means it has a `handle` method which takes the following arguments: 153 | - `url` (the target url of the request) 154 | - `method` (the request method used) 155 | - `data` (an optional form data or object that to be sent to the server) 156 | - `customHeaders` (an optional object that includes the custom headers passed to the [call](../calliope/api-calls.md#call) method) 157 | - `queryParameters` (an optional object that may include the result of the [query builder](../calliope/query-building.md)) 158 | 159 | This `handle` method should return an object that may have the following properties `data`, `customHeaders`, `queryParameters`. These properties should be matching the initial types from the argument or be the value `undefined` (to remove the value). The method may or may not return a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). 160 | 161 | This middleware allows tapping into the request for transforming it before it's passed along to the [ApiCaller](../services/readme.md#apicaller). 162 | -------------------------------------------------------------------------------- /docs/calliope/api-calls.md: -------------------------------------------------------------------------------- 1 | # Api Calls 2 | 3 | The model has the capability to interact with your api. The exact behaviour depends on the [ApiCaller](../services/readme.md#apicaller) implementation used. To facilitate some higher level methods like [touch](./timestamps.md#touch) the model provides methods equivalent to the http methods. 4 | 5 | ## Properties 6 | 7 | #### endpoint 8 | 9 | The `endpoint` property is a getter which should return a string that is the or part of the address which the model can interact with. If not setting this value, the endpoint will be guessed for you. This value will be appended to the [baseEndPoint](../helpers/global-config.md#baseendpoint) from the [GlobalConfig](../helpers/global-config.md) if the `baseEndPoint` has been set. 10 | 11 | #### loading 12 | 13 | The `loading` property indicates whether there is an ongoing request on the model you're currently interacting with. 14 | 15 | #### serverAttributeCasing 16 | 17 | The `serverAttributeCasing` is a getter which similarly to [attributeCasing](./attributes.md#attributecasing) casts the request keys recursively to the given casing on outgoing requests. The valid values are `'snake'` or `'camel'` with `'snake'` being the default value. 18 | 19 | #### _lastSyncedAt 20 | 21 | The `_lastSyncedAt` or `_last_synced_at` (naming subject to [attributeCasing](./attributes.md#attributecasing)) attribute is a getter attribute that is set only when the model data has been fetched, [saved](./readme.md#save) or [refreshed](./readme.md#refresh). Its type subject to the [datetime](./attributes.md#datetime) setting, with the value of when was the last time the data has been loaded from the backend. 22 | 23 | ## Methods 24 | 25 | ::: tip 26 | All request methods on success will call the [resetEndpoint](#resetendpoint) and will reset all the [query parameters](./query-building.md). 27 | ::: 28 | 29 | #### get 30 | 31 | 32 | The `get` method initiates a new `GET` request. It optionally accepts an object which are passed to the [ApiCaller](../services/readme.md#apicaller) to transform into a query string. The method is also available statically. It returns a [Model](./readme.md) or [ModelCollection](./model-collection.md). 33 | 34 | ```js 35 | import User from '@Models/User'; 36 | 37 | const user = new User; 38 | user.get(); 39 | User.get(); 40 | ``` 41 | 42 | #### post 43 | 44 | 45 | The `post` method initiates a new `POST` request. It returns a [Model](./readme.md) if the endpoint returns data otherwise returns itself. 46 | 47 | ```js 48 | import User from '@Models/User'; 49 | 50 | const user = new User; 51 | user.post({ attribute: 1 }); 52 | ``` 53 | 54 | #### put 55 | 56 | 57 | The `put` method initiates a new `PUT` request. It returns a [Model](./readme.md) if the endpoint returns data otherwise returns itself. 58 | 59 | ```js 60 | import User from '@Models/User'; 61 | 62 | const user = new User; 63 | user.put({ attribute: 1 }); 64 | ``` 65 | 66 | #### patch 67 | 68 | 69 | The `patch` method initiates a new `PATCH` request. It returns a [Model](./readme.md) if the endpoint returns data otherwise returns itself. 70 | 71 | ```js 72 | import User from '@Models/User'; 73 | 74 | const user = new User; 75 | user.patch({ attribute: 1 }); 76 | ``` 77 | 78 | #### delete 79 | 80 | 81 | The `delete` method initiates a new `DELETE` request. It returns a [Model](./readme.md) if the endpoint returns data otherwise returns itself. 82 | 83 | ```js 84 | import User from '@Models/User'; 85 | 86 | const user = new User; 87 | user.delete({ attribute: 1 }); 88 | ``` 89 | 90 | #### call 91 | 92 | 93 | 94 | The `call` method is what powers the rest of the api calls on the model. It takes one argument and two optional arguments. The first argument is the method name which is one of `'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' | 'HEAD'`. The second is the data to send along with the request. and the third is any custom headers in an object format to be sent along. After the request the [resetEndpoint](#resetendpoint) will be called and all [query parameters](./query-building.md) will be reset. 95 | 96 | ```ts 97 | import User from '@Models/User'; 98 | 99 | const user = new User(); 100 | 101 | await user.call('GET', { query: 'value' }); // GET your-api.com/users?query=value 102 | ``` 103 | 104 | ### Endpoint manipulation 105 | 106 | There are couple utilities to change the endpoint for the next request. 107 | 108 | #### setEndpoint 109 | 110 | The `setEndpoint` method replaces the endpoint for the next request. 111 | ```js 112 | import User from '@Models/User'; 113 | 114 | const user = new User; 115 | user.getEndpoint(); // 'users' 116 | user.setEndpoint('/something').getEndpoint(); // '/something' 117 | ``` 118 | #### getEndpoint 119 | 120 | The `getEndpoint` method returns the current endpoint. 121 | ```js 122 | import User from '@Models/User'; 123 | 124 | const user = new User; 125 | user.getEndpoint(); // 'users' 126 | ``` 127 | #### resetEndpoint 128 | 129 | The `resetEndpoint` method resets the endpoint to the original [endpoint](#endpoint). If endpoint is not set, it will try to guess it based on the [model name](./readme.md#getname). 130 | ```js 131 | import User from '@Models/User'; 132 | 133 | const user = new User; 134 | user.setEndpoint('/something').getEndpoint(); // '/something' 135 | user.resetEndpoint().getEndpoint(); // 'users' 136 | ``` 137 | 138 | #### appendToEndpoint 139 | 140 | The `appendToEndpoint` methods appends the given string to the current endpoint. 141 | 142 | ```js 143 | import User from '@Models/User'; 144 | 145 | const user = new User; 146 | user.getEndpoint(); // 'users' 147 | user.appendToEndpoint('/something').getEndpoint(); // 'users/something' 148 | ``` 149 | 150 | ### Miscellaneous 151 | 152 | #### setLastSyncedAt 153 | 154 | 155 | The `setLastSyncedAt` method sets the [_lastSyncedAt](#_lastsyncedat) attribute to the current [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date). It optionally also accepts an argument for the value to be set as. The set value is subject to the [date-time casting](./attributes.md#datetime). Furthermore, it takes timestamps into consideration when enabled. 156 | 157 | ::: warning 158 | This method should only really be used when mocking the model to look like it exists. Some possible use-cases are: 159 | - Testing a model. 160 | - Hydrating a model with known to existing record. 161 | ::: 162 | 163 | ```js 164 | import User from '@Models/User'; 165 | 166 | const user = User.make({ id: 1 }); 167 | user.exists; // false 168 | user.setLastSyncedAt().exists; // true 169 | ``` 170 | 171 | #### setModelEndpoint 172 | 173 | The `setModelEndpoint` sets the endpoint on the model to the resource endpoint by appending the primary key. 174 | 175 | ```js 176 | import User from '@Models/User'; 177 | 178 | const user = User.make({ id: 1 }); 179 | user.getEndpoint(); // '/users' 180 | user.setModelEndpoint().getEndpoint(); // '/users/1' 181 | ``` 182 | --------------------------------------------------------------------------------