0.4.1 • Published 6 days ago

integro v0.4.1

Weekly downloads
-
License
MIT
Repository
github
Last release
6 days ago

Integro

npm version npm bundle size npm bundle size GitHub Actions Workflow Status types license

API integration with E2E integrity. Node API service with automatic client-side type safety.

Warning

Integro is under active development and breaking changes may occur at any time. For this reason, we recommend pinning the exact version of integro in your package.json file.

Installation

Install integro in your server app and client app (optional on the client side, see Re-exporting createClient).

npm install integro

Getting started

Server-side

First, create your server-side app using plain object style. No need to worry about routes.

// app.ts

export const app = {
  greetings: {
    sayHi: (name: string) => `Hi, ${name}!`,
    sayBye: (name: string) => `Bye, ${name}!`
  }
};

export type App = typeof App;

Start up the server with one line:

// server.ts

import { createController } from 'integro';
import { createServer } from 'node:http';
import { app } from './app';

createServer(createController(app)).listen(8000);

Client-side

Create the client-side api proxy object. When using in a browser, createClient must be imported from 'integro/client'. If your client is in node or bun, then createClient may be imported from either 'integro' or 'integro/client'.

// api.ts

import { createClient } from 'integro/client';
import type { App } from '@repo/api';

export const api = createClient<App>('http://localhost:8000');

Use your fully type-safe api object.

// sayHi.ts

import { api } from './api.ts';

const res = await api.greetings.sayHi('Gerald'); // 'Hi, Gerald!'

Re-exporting createClient

Wouldn't be easier if your client app didn't need to depend on integro? Of course! Let's make it happen. Simply re-export a typed version of integro's createClient function:

// Server repo

import { createClient } from 'integro/client';
import type { app } from './app';

export const createApiClient = createClient<typeof app>;
// Client repo

import type { createApiClient } from '@repo/api';

export const api = createApiClient('http://localhost:8000');

Type safety

Integro provides end-to-end type safety out of the box. That's the whole point!

An integro app object must contain only functions and nested app objects, which must contain only functions and nested app objects, and so on.

Example:

// app.ts

export const app = {
  version: () => '1.0.0',
  artists: {
    list: () => orm.artist.getAll(),
    create: (artist: Artist) => orm.artist.create(artist),
    get: (id: string) => orm.artist.get(id),
    delete: (id: string) => orm.artist.delete(id),
    update: (id: string, artist: Artist) => orm.artist.update(id, artist),
    photos: {
      list: (artistId: string) => orm.artist.get(artistId).photo.getAll(),
      get: (id: string) => orm.photo.get(id),
    },
  },
};

On the client side, methods are fully typed and converted to async functions. Type safety example

Server app type

Typing your app object is optional, but it can help find and fix issues early. There are two options for this:

import { IntegroApp } from 'integro';

export const app = {
  /* ... */
} satisfies IntegroApp;

... or ...

import { defineApp } from 'integro';

export const app = defineApp({
  /* ... */
});

Lazy loading

If your server app is large, it may be expensive to initialize it all at once. In this case, you can use integro's unwrap() helper function along with dynamic imports.

// artists.ts
export const artists = {
  list: () => orm.artist.getAll(),
  create: (artist: Artist) => orm.artist.create(artist),
  get: (id: string) => orm.artist.get(id),
  delete: (id: string) => orm.artist.delete(id),
  update: (id: string, artist: Artist) => orm.artist.update(id, artist),
}
// photos.ts
export const photos = {
  list: (artistId: string) => orm.artist.get(artistId).photo.getAll(),
  get: (id: string) => orm.photo.get(id),
}
// app.ts
import { unwrap } from 'integro';
import { artists } from 'artists';

export const app = {
  version: () => '1.0.0',
  artists, // regular, non-lazy import
  photos: unwrap(() => import('./photos').then(module => module.photos)), // lazy import
}

The client object is typed as if using regular object nesting:

Type safety with lazy loading example

Authentication and authorization

createClient accepts a requestInit option for overriding most Response properties, including headers.

// Client

import { createClient } from 'integro/client';
import type { App } from '@repo/api';
import { getCurrentAuthToken } from './auth';

export const api = createClient<App>('http://localhost:8000', {
  requestInit: () => ({
    headers: {
      'Authorization': getCurrentAuthToken()
    }
  })
});

Fine-grained authentication/authorization guard

In addition to usage for lazy loading, the unwrap function can also be used to inspect the request object.

import { unwrap } from 'integro';

export const app = {
  admin: unwrap(request => {
    const token = request.headers.get('Authorization');

    if (!isAdmin(token)) {
      throw new Error('User is not authenticated');
    }

    return {
      getUser: (id: string) => orm.users.getById(id),
      listUsers: () => orm.users.getAll(),
    };
  }),
};

Response headers

The respondWith helper allows you to customize the response headers.

Here is an example of rudimentary login/logout endpoints:

import cookie from 'cookie';
import { respondWith } from 'integro';

export const app = {
  auth: {
    login: (username: string, password: string) => {
      if (username && password) {
        const headers = new Headers();

        if (!isValid(username, password)) throw new Error('No match!');

        headers.set('Set-Cookie', cookie.serialize('session', username));

        return respondWith(undefined, { headers });
      } else {
        throw new Error('Not authenticated');
      }
    },
    logout: () => {
      const headers = new Headers();

      headers.set('Set-Cookie', cookie.serialize('session', '', { expires: new Date(0) }));

      return respondWith(undefined, { headers });
    },
  },
};

Cross-Origin Resource Sharing (CORS)

createClient accepts header properties to allow for CORS, while the server can be configured according to your chosen framework.

// Server

createServer((req, res) => {
  res.setHeader('access-control-allow-credentials', 'true');
  res.setHeader('access-control-allow-headers', 'Content-Type');
  res.setHeader('access-control-allow-origin', 'http://localhost:5173');
  res.setHeader('access-control-max-age', '2592000');

  if (new URL(req.url ?? '', 'https://localhost').pathname === '/api') {
    return createController(app)(req, res);
  }

  res.end();
}).listen(8000);
// Client

import { createClient } from 'integro/client';
import type { App } from "@repo/my-server";

export const api = createClient<App>("http://localhost:8000", {
  requestInit: {
    headers: {
      credentials: 'include'
    }
  }
});

Server-side validation

Type safety can help you avoid most problems at development and build time, but what about protecting the server against unforeseeable bugs or bad actors?

Server-side validation is currently not included out of the box, but there are many great options already available to add validation with little effort.

With Prisma

Prisma includes validation, so you can simply export many prisma methods directly:

import { prisma } from './prisma';

export const app = {
  artist: {
    findFirst: prisma.artist.findFirst,
    findMany: prisma.artist.findMany,
  },
};

With Typia

Typia includes an assertParameters() guard which validates the input parameters to a given function based only on TS types:

import typia from 'typia';

export const app = {
  repeatString: assertParameters(
    (text: string, times: number) => Array(times).fill(text).join(', ')
  )
};

With Zod

import z from 'zod';

export const app = {
  repeatString: z.function().args(z.string(), z.number()).implement(
    // text and times types are inferred via zod
    (text, times) => Array(times).fill(text).join(', ')
  ),
};

Framework agnostic

Integro's createController() works with servers that accept handlers in the form (request: IncomingMessage, response: ServerResponse) => void (such as node:http, express) as well as the Fetch API style (request: Request) => Response (such as bun).

Node's built-in http package

Any route:

import { createController } from 'integro';
import { createServer } from 'node:http';
import { app } from './app';

// Any route
createServer(createController(app)).listen(8000);

Specific route:

import { createController } from 'integro';
import { createServer } from 'node:http';
import { app } from './app';

// Specific route
createServer((req, res) => {
  if (new URL(req.url ?? '', 'https://localhost').pathname === '/api') {
    return handle(req, res);
  }

  res.end();
}).listen(8000);

Bun's built-in serve function

Any route:

import { serve } from 'bun';
import { createController } from 'integro';
import { app } from './app.js';

serve({
  port: 8000,
  fetch: createController(app)
});

Specific route:

import { serve } from 'bun';
import { createController } from 'integro';
import { app } from './app.js';

serve({
  port: 8000,
  fetch: (req) => {
    if (new URL(req.url).pathname === '/api') {
      return createController(app)(req);
    }

    return Response.error();
  }
});

Express

Any route:

import { app } from './app';
import { createController } from 'integro';
import express from 'express';

express()
  .use(createController(app))
  .listen(8000);

Specific route:

import { app } from './app';
import { createController } from 'integro';
import express from 'express';

const handler = createController(app);

express()
  .options('/api', (req, res, next) => {
    handler(req, res);
    next();
  })
  .post('/api', (req, res, next) => {
    handler(req, res);
    next();
  })
  .listen(8000);

Next.js

With app router:

// src/api/route.ts

import { createController } from 'integro';
import { app } from './app';

export const POST = createController(app);

Client-side recipies

You can retrieve the path name of an integro client method by using the well-known symbol Symbol.toStringTag. This is useful for cases where you need to dynamically get the path at runtime.

const path = api.artists.list[Symbol.toStringTag];
console.log(path); // => 'artists.list'

Here are a couple examples where Symbol.toStringTag proves useful for using integro with React querying hooks:

With Vercel's SWR

// api.ts

import type { createApiClient } from '@repo/api';
import { AnyClientMethod } from 'integro/client';
import useSWR from 'swr';

export const api = createApiClient("http://localhost:8000/api", {
  requestInit: { credentials: 'include' }
});

export const useIntegroSWR = <Fn extends AnyClientMethod>(fn: Fn, ...args: Parameters<Fn>) =>
  useSWR<Awaited<ReturnType<Fn>>>([fn[Symbol.toStringTag], ...args], () => fn(...args));


// Artists.tsx
import { api, useIntegroSWR } from '../api';

export const Artists = () => {
  const { data } = useIntegroSWR(api.artists.list);

  return (
    <div>
      {data?.map((artist) => (
        <p key={artist.name}>
          {artist.name}: {artist.instruments?.join(",")}
        </p>
      ))}
    </div>
  );
};


// Artist.tsx
import { api, useIntegroSWR } from '../api';

export const Artist = () => {
  const { data } = useIntegroSWR(api.artists.get, 'monk');

  return (
    <p>
      {artist.name}: {artist.instruments?.join(",")}
    </p>
  );
};

With Tanstack Query

// api.ts

import type { createApiClient } from '@repo/api';
import { useQuery } from '@tanstack/react-query';
import { AnyClientMethod } from 'integro/client';

export const api = createApiClient("http://localhost:8000/api", {
  requestInit: { credentials: 'include' }
});

export const useIntegroQuery = <Fn extends AnyClientMethod>(fn: Fn, ...args: Parameters<Fn>) =>
  useQuery<Awaited<ReturnType<Fn>>>({
    queryKey: [fn[Symbol.toStringTag], ...args],
    queryFn: () => fn(...args)
  });


// Artists.tsx
import { api, useIntegroQuery } from '../api';

export const Artists = () => {
  const { data } = useIntegroQuery(api.artists.list);

  return (
    <div>
      {data?.map((artist) => (
        <p key={artist.name}>
          {artist.name}: {artist.instruments?.join(",")}
        </p>
      ))}
    </div>
  );
};


// Artist.tsx
import { api, useIntegroQuery } from '../api';

export const Artist = () => {
  const { data } = useIntegroQuery(api.artists.get, 'monk');

  return (
    <p>
      {artist.name}: {artist.instruments?.join(",")}
    </p>
  );
};

License

MIT

0.4.1

6 days ago

0.4.0

8 days ago

0.3.3

11 days ago

0.3.2

13 days ago

0.3.1

13 days ago

0.3.0

14 days ago

0.2.7

15 days ago

0.2.8

15 days ago

0.2.6

16 days ago

0.2.3

16 days ago

0.2.5

16 days ago

0.2.4

16 days ago

0.2.2

19 days ago

0.2.1

20 days ago

0.1.2

20 days ago

0.2.0

20 days ago

0.1.1

4 months ago

0.1.0

4 months ago