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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/test-build-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Test, Build, and Publish

on:
workflow_dispatch:
push:

permissions:
contents: write
issues: write
pull-requests: write

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run verify
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Audit signatures
run: npm audit signatures
- name: Build library
run: npm run build
- uses: actions/upload-artifact@v4
with:
name: lib
path: ./lib
publish:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
README.md
LICENSE
sparse-checkout-cone-mode: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: actions/download-artifact@v4
with:
name: lib
path: ./lib
- name: Publish library
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
npx semantic-release
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
lib/
coverage/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v22.12.0
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2024 Ryan Huellen
Copyright (c) 2024 TS Metadata

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
118 changes: 118 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# JSON:API ORM

`@tsmetadata/json-api-orm` provides a NoSQL object-relational mapping for JSON:API resource objects decorated with [@tsmetadata/json-api](https://github.com/tsmetadata/json-api).

- [🌱 Install](#-install)
- [🤖 Supported Drivers](#-supported-drivers)
- [📋 Feature Set](#-feature-set)
- [⚙️ Usage](#️-usage)
- [❓ FAQ](#-faq)

## 🌱 Install
```bash
npm install @tsmetadata/json-api-orm@latest
```

## 🤖 Supported Drivers
- DynamoDB (via. [AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/))
- To authenticate, please configure the global AWS SDK object or use environment variables.
- Table names match, by default, the resource type.

## 📋 Feature Set
- [✨ Actions](#actions)
- [Get](#get)
- [Put](#put)
- [Include](#include)

## ⚙️ Usage
### Actions
#### Get
The `get(cls: new (..._: any[]) => any), id: string)` action will get the resource with the given class and id from the underlying database.

```typescript
import { Resource, Id, Attribute } from '@tsmetadata/json-api';
import { get } from '@tsmetadata/json-api-orm';

@Resource('users')
class User {
@Id()
customerId: string;

@Attribute()
active: boolean;
}

const user1 = await get(User, 1);

if(user1 !== undefined) {
console.log(user1.active)
}
```

#### Put
The `put(classInstance: object)` action will put (create or update) the resource from the given class instance.

```typescript
import { Resource, Id, Attribute } from '@tsmetadata/json-api';
import { put } from '@tsmetadata/json-api-orm';

@Resource('users')
class User {
@Id()
customerId: string;

@Attribute()
active: boolean;
}

const user = new User();
user.id = '1';
user.active = false;

await put(user);
```

#### Include
The `include(classInstance: object, relationshipKey: string, cls: new (..._: any[]) => any)` will get the full resource(s) for the given relationship.

```typescript
import { Resource, Id, Attribute, Relationship, type JSONAPIResourceLinkage } from '@tsmetadata/json-api';
import { include } from '@tsmetadata/json-api-orm';

@Resource('users')
class User {
@Id()
customerId: string;

@Attribute()
active: boolean;

@Relationship('author')
posts: Post[] | JSONAPIResourceLinkage;
}

@Resource('posts')
class Post {
@Id()
postId: string;

@Attribute()
description: string;

@Relationship('posts')
author: User | JSONAPIResourceLinkage;
}

const user = await get(User, '1');

/*
user.posts is an array of `JSONAPIResourceIdentifierObject`. To turn this into an array of
`Post`, we can do the following:
*/
await include(User, 'posts', Post);
```

## ❓ FAQ

### Q: Where can I learn more about JSON:API metadata decorators?
A: We have a standard library complete with serializers, deserializers, and all object types [here](https://github.com/tsmetadata/json-api).
43 changes: 43 additions & 0 deletions __tests__/actions/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Chance } from 'chance';
import { config } from '../../src/actions/config';
import { expectORMConfiguration } from '../../src/utils/expectOrmConfiguration';

import type { ORMConfiguration } from '../../src/types/ormConfiguration';

jest.mock('../../src/utils/expectOrmConfiguration');
const expectORMConfigurationMocked = jest.mocked(expectORMConfiguration);

describe('`config`', () => {
let chance: Chance.Chance;

beforeEach(() => {
chance = new Chance();
});

it('should merge the partial configuration with the existing configuration', () => {
const existingConfiguration = {
a: chance.string(),
b: {
c: chance.string(),
},
d: chance.string(),
} as unknown as ORMConfiguration;

expectORMConfigurationMocked.mockReturnValue(existingConfiguration);

const partialConfiguration = {
b: {
c: chance.string(),
e: chance.string(),
},
d: chance.string(),
} as unknown as ORMConfiguration;

config(partialConfiguration);

expect(globalThis._JSONAPIORM).toEqual({
...existingConfiguration,
...partialConfiguration,
});
});
});
52 changes: 52 additions & 0 deletions __tests__/actions/get.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Chance } from 'chance';
import { get } from '../../src/actions/get';
import { expectORMConfiguration } from '../../src/utils/expectOrmConfiguration';

import type { ORMConfiguration } from '../../src/types/ormConfiguration';

jest.mock('../../src/utils/expectOrmConfiguration');
const expectORMConfigurationMocked = jest.mocked(expectORMConfiguration);

import { get as dynamoDbGet } from '../../src/drivers/dynamodb/get';
jest.mock('../../src/drivers/dynamodb/get');
const dynamoDbGetMocked = jest.mocked(dynamoDbGet);

describe('`get`', () => {
let chance: Chance.Chance;

beforeEach(() => {
chance = new Chance();
});

it('should forward the get to the DynamoDB driver if the configured database engine is `DYNAMODB`', async () => {
const cls = class {
a: string = chance.string();
};
const id = chance.string();

expectORMConfigurationMocked.mockReturnValue({
engine: 'DYNAMODB',
});

const expectedResult = chance.string();

dynamoDbGetMocked.mockResolvedValue(expectedResult);

const result = await get(cls, id);

expect(dynamoDbGetMocked).toHaveBeenCalledWith(cls, id);
expect(result).toBe(expectedResult);
});

it('should throw an error if no driver is found for the configured engine', () => {
const engine = chance.string();

expectORMConfigurationMocked.mockReturnValue({
engine,
} as unknown as ORMConfiguration);

expect(() => get(class {}, 'blahblah')).rejects.toThrow(
`No \`get\` driver found for database engine \`${engine}\`, please check your JSON:API ORM configuration.`,
);
});
});
60 changes: 60 additions & 0 deletions __tests__/actions/include.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Chance from 'chance';
import { include } from '../../src/actions/include';
import { expectORMConfiguration } from '../../src/utils/expectOrmConfiguration';

import type { ORMConfiguration } from '../../src/types/ormConfiguration';

jest.mock('../../src/utils/expectOrmConfiguration');
const expectORMConfigurationMocked = jest.mocked(expectORMConfiguration);

import { include as dynamoDbInclude } from '../../src/drivers/dynamodb/include';
jest.mock('../../src/drivers/dynamodb/include');
const dynamoDbIncludeMocked = jest.mocked(dynamoDbInclude);

describe('`include`', () => {
let chance: Chance.Chance;

beforeEach(() => {
chance = new Chance();
});

it('should forward the include to the DynamoDB driver if the configured database engine is `DYNAMODB`', async () => {
const relationshipKey = chance.string();

const classInstance = {
[relationshipKey]: chance.string(),
};

const cls = class {
a: string = chance.string();
};

expectORMConfigurationMocked.mockReturnValue({
engine: 'DYNAMODB',
});

await include(classInstance, relationshipKey, cls);

expect(dynamoDbIncludeMocked).toHaveBeenCalledWith(
classInstance,
relationshipKey,
cls,
);
});

it('should throw an error if no driver is found for the configured engine', () => {
const classInstance = {
a: 'b',
};

const engine = chance.string();

expectORMConfigurationMocked.mockReturnValue({
engine,
} as unknown as ORMConfiguration);

expect(() => include(classInstance, 'a', class {})).rejects.toThrow(
`No \`include\` driver found for database engine \`${engine}\`, please check your JSON:API ORM configuration.`,
);
});
});
Loading
Loading