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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
.vscode
.turbo
.DS_Store
/node_modules
node_modules
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ We encourage developers to share production-ready solutions and help businesses
- Code linting and testing ⚙️
- CI/CD 🤖

## Examples
- [Stripe](https://stripe.com/) payments and subscriptions 🤑

## Quick Start

```shell
Expand Down
120 changes: 54 additions & 66 deletions docs/api-reference/api-action-validator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,98 +4,86 @@ title: "API action validator"

## Overview

**API action validator** — is an array of functions (think middlewares) that is used to make sure that data sent by client is valid.
**Validation** in Ship is handled automatically by providing a `schema` to `createEndpoint`. When `schema` is present, the `validate` middleware auto-applies and merges `body + files + query + params` into `ctx.validatedData`.

For additional validation logic (e.g. checking uniqueness), add custom middleware functions to the `middlewares` array.

<Note>
This repo uses **Zod 4**. Use `z.email()`, `z.url()`, `z.uuid()` instead of Zod 3's `z.string().email()`. Search existing schemas for patterns.
</Note>

## Examples

### Basic validation with `createEndpoint`

```typescript
import { z } from 'zod';

import { AppKoaContext, Next } from 'types';
import { EMAIL_REGEX, PASSWORD_REGEX } from 'app-constants';

import { userService } from 'resources/user';

import { validateMiddleware } from 'middlewares';
import createEndpoint from 'routes/createEndpoint';
import isPublic from 'middlewares/isPublic';

const schema = z.object({
firstName: z.string().min(1, 'Please enter fist name.').max(100),
firstName: z.string().min(1, 'Please enter first name.').max(100),
lastName: z.string().min(1, 'Please enter last name.').max(100),
email: z.string().regex(EMAIL_REGEX, 'Email format is incorrect.'),
password: z.string().regex(PASSWORD_REGEX, 'The password format is incorrect'),
email: z.email('Email format is incorrect.'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});

type ValidatedData = z.infer<typeof schema>;

async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
const { email } = ctx.validatedData;

const isUserExists = await userService.exists({ email });
export default createEndpoint({
method: 'post',
path: '/sign-up',
schema,
middlewares: [isPublic],

ctx.assertClientError(!isUserExists, {
email: 'User with this email is already registered',
});

await next();
}

async function handler(ctx: AppKoaContext<ValidatedData>) {
// ...action code
}

export default (router: AppRouter) => {
router.post('/sign-up', validateMiddleware(schema), validator, handler);
};
async handler(ctx) {
const { firstName, lastName, email, password } = ctx.validatedData;
// ...action code
},
});
```

To pass data from the `validator` to the `handler`, utilize the `ctx.validatedData` object:
### Custom validation middleware

``` typescript
import { z } from 'zod';
To add extra validation logic beyond the Zod schema, add a custom middleware to the `middlewares` array:

import { AppKoaContext, AppRouter, Next, User } from 'types';
import { EMAIL_REGEX, PASSWORD_REGEX } from 'app-constants';
```typescript
import { z } from 'zod';

import createEndpoint from 'routes/createEndpoint';
import isPublic from 'middlewares/isPublic';
import { userService } from 'resources/user';

import { validateMiddleware } from 'middlewares';
import { securityUtil } from 'utils';

const schema = z.object({
email: z.string().regex(EMAIL_REGEX, 'Email format is incorrect.'),
password: z.string().regex(PASSWORD_REGEX, 'The password format is incorrect'),
email: z.email('Email format is incorrect.'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});

interface ValidatedData extends z.infer<typeof schema> {
user: User;
}

async function validator(ctx: AppKoaContext<ValidatedData>, next: Next) {
const { email, password } = ctx.validatedData;

const user = await userService.findOne({ email });

ctx.assertClientError(user && user.passwordHash, {
credentials: 'The email or password you have entered is invalid',
});

const isPasswordMatch = await securityUtil.compareTextWithHash(password, user.passwordHash);
const checkUserExists = async (ctx, next) => {
const { email } = ctx.validatedData;
const exists = await userService.exists({ email });

ctx.assertClientError(isPasswordMatch, {
credentials: 'The email or password you have entered is invalid',
});
if (exists) {
ctx.throwClientError({ email: 'User with this email is already registered' });
return;
}

ctx.validatedData.user = user;
await next();
}
};

async function handler(ctx: AppKoaContext<ValidatedData>) {
const { user } = ctx.validatedData;
export default createEndpoint({
method: 'post',
path: '/sign-up',
schema,
middlewares: [isPublic, checkUserExists],

// ...action code
}
async handler(ctx) {
const { email, password } = ctx.validatedData;
// ...action code
},
});
```

export default (router: AppRouter) => {
router.post('/sign-in', validateMiddleware(schema), validator, handler);
};
<Warning>
**Avoid `ctx.assertError()`** in handlers — it's a TypeScript assertion function that causes TS2775 without explicit type annotations on `ctx`. Use `ctx.throwError()` + `return` instead.
</Warning>
```
40 changes: 21 additions & 19 deletions docs/api-reference/api-action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,37 @@ title: "API action"

## Overview

**API action** — is HTTP handler that perform database updates and other logic required by the business logic.
Actions should reside in the `/actions` folder within resource.
Usually action is a single file that has meaningful name, e.x. `list`, `get-by-id`, `update-email`.
**API action** (endpoint) — is an HTTP handler that performs database updates and other logic required by the business logic.
Endpoints reside in the `/endpoints` folder within a resource.
Each endpoint is a single file with a meaningful name, e.x. `list`, `create`, `update`, `remove`.

If action has a lot of logic and require multiple files it needs to be placed into the folder with name of the action and action need to exposed using module pattern (index.ts file).
Each endpoint file must **default-export** a `createEndpoint({...})` call. Routes are auto-discovered — no manual registration needed.

Direct database updates of the current resource entity are allowed within action.
If `schema` is provided, the `validate` middleware auto-applies. Validated data is available on `ctx.validatedData`.

## Examples

```typescript
import Router from '@koa/router';
import { z } from 'zod';

import { AppKoaContext } from 'types';
import createEndpoint from 'routes/createEndpoint';
import { companyService } from 'resources/companies';

import { validateMiddleware } from 'middlewares';
const schema = z.object({
userId: z.string(),
});

type GetCompanies = {
userId: string;
};
export default createEndpoint({
method: 'get',
path: '/',
schema,

async function handler(ctx: AppKoaContext<GetCompanies>) {
const { userId } = ctx.validatedData; // validatedData is returned by API validator
async handler(ctx) {
const { userId } = ctx.validatedData;

ctx.body = {}; // action result sent to the client
}
const companies = await companyService.find({ userId });

export default (router: Router) => {
// see Rest API validator
router.get('/companies', validateMiddleware(schema), handler);
};
return companies; // returned value becomes ctx.body
},
});
```
2 changes: 1 addition & 1 deletion docs/api-reference/api-limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: "To keep things simple and maintainable we enforce some limitations
## Resource updates

### Rule
**Every** entity update should stay within a resource folder. Direct database updates are allowed in data services, handlers and actions.
**Every** entity update should stay within a resource folder. Direct database updates are allowed in data services, handlers and endpoints.

### Explanation
This restriction makes sure that entity updates are not exposed outside the resource. This enables the discoverability of all updates and simplifies resource changes.
Expand Down
35 changes: 26 additions & 9 deletions docs/api-reference/data-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,40 @@ title: "Data service"

**Data Service** — is a layer that has two functions: database updates and domain functions. Database updates encapsulate the logic of updating and reading data in a database (also known as Repository Pattern in DDD). Domain functions use database updates to perform domain changes (e.x. `changeUserEmail`, `updateCredentials`, etc). For simplicity, we break the single responsibility pattern here. Data Service is usually named as `entity.service` (e.x. `user.service`).


## Examples

```typescript
import _ from 'lodash';
import db from 'db';
import constants from 'app.constants';
import { DATABASE_DOCUMENTS } from 'app-constants';
import schema from './user.schema';
import { User } from './user.types';

const service = db.createService<User>('users', { schema });
const service = db.createService(DATABASE_DOCUMENTS.USERS, {
schemaValidator: (obj) => schema.parseAsync(obj),
});

async function createInvitationToUser(email: string, companyId: string): Promise<User> {
async function createInvitationToUser(email: string, companyId: string) {
// the logic
}

export default Object.assign(service, {
createInvitationToUser,
export default Object.assign(service, {
createInvitationToUser,
});
```
```

## Service API Quick Reference

`db.createService` returns a `@paralect/node-mongo` Service. Key methods:
- `find(filter, { page, perPage }, { sort })` → `{ results, pagesCount, count }`
- `findOne(filter)`, `insertOne(doc)`, `updateOne(filter, updateFn)`, `deleteSoft(filter)`
- `exists(filter)`, `distinct(field, filter)`, `countDocuments(filter)`
- `atomic.updateOne(filter, update)` — raw MongoDB update (bypass schema validator)
- `createIndex(keys, options?)` — call at module level in the service file

### Soft Deletes

`service.deleteSoft(filter)` sets `deletedOn` timestamp instead of removing.
All `find`/`findOne` queries auto-exclude `deletedOn !== null`.

<Note>
The collection name must be registered in `packages/app-constants/src/api.constants.ts` → `DATABASE_DOCUMENTS`.
</Note>
2 changes: 1 addition & 1 deletion docs/api-reference/event-handler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ title: "Event handler"

## Overview

**Event handler** — is a simple function that receives event as an argument and performs required logic. All event handlers should be stored in the /handlers folder within resource. Handler name should include event name e.x. `user.created.handler.ts`. That helps find all places were event is used. Direct database updates of the current resource entity are allowed within handler.
**Event handler** — is a simple function that receives event as an argument and performs required logic. Event handlers should be stored in a `<name>.handler.ts` file at the resource root (e.g. `resources/users/users.handler.ts`). The handler file must be imported as a side-effect in the resource's `index.ts` barrel file (e.g. `import './users.handler'`).


## Examples
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ There are three types of events:
};
```

- entity.removed event (e.x. user.removed).
- entity.deleted event (e.x. user.deleted). Ship uses soft deletes (`deleteSoft`) which set a `deletedOn` timestamp.
```typescript
{
_id: string,
createdOn: Date,
type: 'user.removed',
type: 'user.deleted',
userId: string,
companyId: string,
data: {
Expand Down
48 changes: 27 additions & 21 deletions docs/api-reference/middlewares.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,26 @@ The `rateLimitMiddleware` function accepts an options object with the following
### Example

```typescript
import Router from '@koa/router';

import { rateLimitMiddleware, validateMiddleware } from 'middlewares';

async function handler(ctx: AppKoaContext) {
// Your handler logic here
ctx.body = { success: true };
}

export default (router: Router) => {
router.post(
'/send-email',
import createEndpoint from 'routes/createEndpoint';
import { rateLimitMiddleware } from 'middlewares';

export default createEndpoint({
method: 'post',
path: '/send-email',
schema,
middlewares: [
rateLimitMiddleware({
limitDuration: 300, // 5 minutes
requestsPerDuration: 5, // 5 requests per 5 minutes
errorMessage: 'Too many emails sent. Please try again later.',
}),
validateMiddleware(schema),
handler,
);
};
],

async handler(ctx) {
// Your handler logic here
return { success: true };
},
});
```

### Common Use Cases
Expand All @@ -78,11 +77,11 @@ If validation fails, it automatically throws a `400` error with detailed field-l
### Example

```typescript
import Router from '@koa/router';
import { z } from 'zod';

import createEndpoint from 'routes/createEndpoint';

import { AppKoaContext } from 'types';
import { validateMiddleware } from 'middlewares';

// Define your schema
const schema = z.object({
Expand All @@ -102,11 +101,18 @@ async function handler(ctx: AppKoaContext<CreateUserData>) {
ctx.body = { email, firstName, lastName, age };
}

export default (router: Router) => {
router.post('/users', validateMiddleware(schema), handler);
};
export default createEndpoint({
method: 'post',
path: '/users',
schema,
handler,
});
```

<Note>
When a `schema` is provided to `createEndpoint`, validation is applied automatically — there is no need to manually call `validateMiddleware`.
</Note>

### Error Response Format

When validation fails, the middleware returns a structured error response with field-specific error messages:
Expand Down
Loading
Loading