Skip to content

Commit

Permalink
Initial
Browse files Browse the repository at this point in the history
  • Loading branch information
paulkr committed Jan 15, 2025
0 parents commit 6d71b2d
Show file tree
Hide file tree
Showing 18 changed files with 9,951 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PICA_API_KEY=
PICA_CONNECTION_KEY=
30 changes: 30 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Publish Package to NPM

on:
release:
types: [created]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org/'
- name: Install dependencies
run: |
if [ -f package-lock.json ]; then
npm ci
else
npm install
fi
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.env
.DS_Store
dist
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# @picahq/unified

The Pica Unified SDK library for Node.js provides convenient access to the Pica UnifiedAPI from applications written in server-side JavaScript.

## Install

Using npm:

```jsx
npm i @picahq/unified
```

Using yarn:

```jsx
yarn add @picahq/unified
```

## Configuration

To use the library you must provide an API key and Connection key. Both are located in the Pica dashboard.

```jsx
import { Pica } from "@picahq/unified";

const pica = new Pica("sk_live_1234");

const response = await pica
.customers("live::xero::acme-inc")
.get("cus_OT3CLnirqcpjvw");

console.log(response);
```

## Testing

Configure the `.env` file based on the `.env.sample` provided with an Pica Secret Key, Connection Key and Model to test.

## Full Documentation

Please refer to the official Pica [Documentation](https://docs.picaos.com) and [API Reference](https://docs.picaos.com/reference) for more information and Node.js usage examples.
57 changes: 57 additions & 0 deletions generator/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import Handlebars from 'handlebars';
import { camelCase } from "camel-case";

const GET_TYPES_URL = "https://development-api.picaos.com/v1/public/schemas/types/typescript";
const GET_COMMON_MODELS_URL = "https://development-api.picaos.com/v1/public/sdk/common-models?limit=500";

const generateSDK = async () => {
try {
console.log('🚀 Fetching types from Pica API...');

const getTypesRequest = await axios.get(GET_TYPES_URL);
const types = getTypesRequest.data;

console.log('✅ Types fetched successfully. Updating models.ts file...');

const modelsFilePath = path.join(__dirname, '..', 'src', 'types', 'models.ts');
const modelsFileContent = `// This file is auto-generated. Do not edit it manually.\n\n${types}`;

await fs.writeFile(modelsFilePath, modelsFileContent, 'utf8');

console.log('📝 models.ts file has been updated successfully.');

console.log('🔍 Fetching resources from Pica API...');

const getCommonModelsRequest = await axios.get(GET_COMMON_MODELS_URL);
const commonModels = getCommonModelsRequest.data?.rows || [];
const primaryCommonModelNames = commonModels
.filter((model: any) => model.primary)
.map((model: any) => model.name);

const resources = primaryCommonModelNames.map((resourceName: string) => ({
lowerCase: resourceName.toLowerCase(),
camelCase: camelCase(resourceName),
pascalCase: resourceName
}));

console.log('✅ Primary common models fetched successfully. Generating index.ts file...');

const templatePath = path.join(__dirname, './templates/index.handlebars');
const templateContent = await fs.readFile(templatePath, 'utf8');

const template = Handlebars.compile(templateContent);
const indexContent = template({ resources });

const indexFilePath = path.join(__dirname, '..', 'src', 'index.ts');
await fs.writeFile(indexFilePath, indexContent, 'utf8');

console.log('🎉 index.ts file has been generated successfully.');
} catch (error) {
console.error('❌ An error occurred while generating the SDK:', error);
}
};

generateSDK();
193 changes: 193 additions & 0 deletions generator/templates/index.handlebars
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import axios, { AxiosInstance, Method } from 'axios';
import { UnifiedOptions, Response, ListFilter, ListResponse, Count, DeleteOptions, HttpStatusCode, ResponseError } from './types/';
import { convertFilterToQueryParams } from './utils';

export * from './types/generic';
export * from './types/models';
export * from './paginate';

import {
{{#each resources}}
{{this.pascalCase}}{{#unless @last}}, {{/unless}}
{{/each}}
} from './types/';

export interface UnifiedApi<Type> {
create(object: Type, options?: UnifiedOptions | undefined | null): Promise<Response<Type>>;
list(filter?: ListFilter | undefined | null, options?: UnifiedOptions | undefined | null): Promise<ListResponse<Type>>;
get(id: string, options?: UnifiedOptions | undefined | null): Promise<Response<Type>>;
update(id: string, object: Type, options?: UnifiedOptions | undefined | null): Promise<Response<Type>>;
count(options?: UnifiedOptions | undefined | null): Promise<Response<Count>>;
delete(id: string, deleteOptions?: DeleteOptions | undefined | null, options?: UnifiedOptions | undefined | null): Promise<Response<Type>>;
}

interface PassthroughAPI<Type> {
call(options: {
method: Method;
path: string;
data?: any;
headers?: Record<string, string>;
queryParams?: Record<string, string>;
}): Promise<Response<Type>>;
}

export class Resource {
protected axiosInstance: AxiosInstance;
protected connectionKey: string;
protected resourceName: string;

constructor(axiosInstance: AxiosInstance, connectionKey: string, resourceName: string) {
this.axiosInstance = axiosInstance;
this.connectionKey = connectionKey;
this.resourceName = resourceName;
}

protected getRequestHeaders(options?: UnifiedOptions): Record<string, string> {
const headers: Record<string, string> = {};
const excludedKeys = ['common', 'delete', 'get', 'head', 'post', 'put', 'patch'];

for (const [key, value] of Object.entries(this.axiosInstance.defaults.headers)) {
if (!excludedKeys.includes(key) && typeof value === 'string') {
headers[key] = value;
}
}

headers['x-pica-connection-key'] = this.connectionKey;
Object.assign(headers, options?.passthroughHeaders);

return headers;
}

protected async makeRequestSingle<R>(
method: string,
url: string,
data?: any,
options?: UnifiedOptions,
queryParams?: Record<string, string>,
statusCode?: number
): Promise<Response<R>> {
try {
const response = await this.axiosInstance.request({
method: method as Method,
url,
data,
headers: this.getRequestHeaders(options),
params: { ...queryParams, ...options?.passthroughQuery }
});

const output = {
...(url.startsWith('/passthrough') ? { passthrough: response?.data } : response?.data),
headers: response.headers as Record<string, string>,
statusCode: statusCode || response.status
};

return output;
} catch (error: any) {
throw error.response?.data as ResponseError;
}
}

protected async makeRequestList<R>(
method: string,
url: string,
data?: any,
options?: UnifiedOptions,
queryParams?: Record<string, string>,
statusCode?: number
): Promise<ListResponse<R>> {
try {
const response = await this.axiosInstance.request({
method: method as Method,
url,
data,
headers: this.getRequestHeaders(options),
params: { ...queryParams, ...options?.passthroughQuery }
});

const output = {
...response?.data,
headers: response.headers as Record<string, string>,
statusCode: statusCode || response.status
};

return output;
} catch (error: any) {
throw error.response?.data as ResponseError;
}
}
}

export class UnifiedResourceImpl<T> extends Resource implements UnifiedApi<T> {
async create(object: T, options?: UnifiedOptions): Promise<Response<T>> {
return this.makeRequestSingle<T>('POST', `/unified/${this.resourceName}`, object, options, undefined, HttpStatusCode.Created);
}

async upsert(object: T, options?: UnifiedOptions): Promise<Response<T>> {
return this.makeRequestSingle<T>('PUT', `/unified/${this.resourceName}`, object, options, undefined, HttpStatusCode.OK);
}

async list(filter?: ListFilter, options?: UnifiedOptions): Promise<ListResponse<T>> {
const queryParams = convertFilterToQueryParams(filter);
return this.makeRequestList<T>('GET', `/unified/${this.resourceName}`, undefined, options, queryParams, HttpStatusCode.OK);
}

async get(id: string, options?: UnifiedOptions): Promise<Response<T>> {
return this.makeRequestSingle<T>('GET', `/unified/${this.resourceName}/${id}`, undefined, options, undefined, HttpStatusCode.OK);
}

async update(id: string, object: T, options?: UnifiedOptions): Promise<Response<T>> {
return this.makeRequestSingle<T>('PATCH', `/unified/${this.resourceName}/${id}`, object, options, undefined, HttpStatusCode.NoContent);
}

async count(options?: UnifiedOptions): Promise<Response<Count>> {
return this.makeRequestSingle<Count>('GET', `/unified/${this.resourceName}/count`, undefined, options, undefined, HttpStatusCode.OK);
}

async delete(id: string, deleteOptions?: DeleteOptions, options?: UnifiedOptions): Promise<Response<T>> {
return this.makeRequestSingle<T>('DELETE', `/unified/${this.resourceName}/${id}`, undefined, options, {
...deleteOptions,
}, HttpStatusCode.NoContent);
}
}

class PassthroughResourceImpl<T> extends Resource implements PassthroughAPI<T> {
async call<T>(options: {
method: Method;
path: string;
data?: any;
headers?: Record<string, string>;
queryParams?: Record<string, string>;
}): Promise<Response<T>> {
const { method, path, data, headers, queryParams } = options;

return this.makeRequestSingle<T>(method, `/passthrough/${path}`, data, headers, queryParams);
}
}

export interface PicaConfig {
serverUrl: string
}

export class Pica {
public axiosInstance: AxiosInstance;

constructor(private apiKey: string, options?: PicaConfig | undefined | null) {
this.axiosInstance = axios.create({
baseURL: options?.serverUrl || 'https://api.picaos.com/v1',
headers: {
'x-pica-secret': this.apiKey,
'Content-Type': 'application/json',
},
});
}

passthrough<T>(connectionKey: string): PassthroughAPI<T> {
return new PassthroughResourceImpl(this.axiosInstance, connectionKey, 'passthrough');
}

{{#each resources}}
{{this.camelCase}}(connectionKey: string) {
return new UnifiedResourceImpl<{{this.pascalCase}}>(this.axiosInstance, connectionKey, '{{this.lowerCase}}');
}
{{/each}}
}
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testMatch: ['**/tests/**/*.spec.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
};
Loading

0 comments on commit 6d71b2d

Please sign in to comment.