0.1.2 • Published 1 year ago

@andyrmitchell/endpoint-schemas v0.1.2

Weekly downloads
-
License
MIT
Repository
-
Last release
1 year ago

Guard your endpoints and generate a TypeClient client for consuming them

Goals

  • Share all the types a client would need to know about, with no maintenance overhead
  • Validate the data transferred from client to endpoint (and vice versa)

Installing

npm i @andyrmitchell/endpoint-schemas

How to use it

1. Add an endpoint.ts to each endpoint directory

E.g. in Supabase, add endpoint.ts to each function directory, such as /supabase/functions/function-name

2. Specify what is a valid request and response looks like

In endpoint.ts, specify a schema in Zod.

The exported const must be called EndpointSchemas (no defaults), and must be in this format:

type EndpointSchemasType = {
    [K: 'GET' | 'POST' | 'PUT' | 'DELETE']: {
        request: z.ZodTypeAny,
        response: z.ZodTypeAny
    };
};

For example:

import z from "zod";

export const EndpointSchemas = {
    'POST': {
        request: z.object({
            bundle_id: z.number()
        }),
        response: z.object({
            success: z.boolean(),
            products: z.array(z.number())
        })
    }
};

3. Validate your actual endpoint (optional)

E.g. in Supabase, in /supabase/functions/function-name/index.ts:

import { EndpointSchemas } from "./endpoint.ts";
import {isRequest, endpointResponse} from '@andyrmitchell/endpoint-schemas'

serve(async (req) => {

    // Validate the input against your schema
    const input = await req.json();
    if( !isRequest(EndpointSchemas, 'POST', input) ) {
        return new Response(JSON.stringify({error: 'Invalid body'}), {headers: {status: 400}});
    }

    // Do processing

    // Use endpointResponse to type check your response immediately against EndpointSchemas
    return endpointResponse(EndpointSchemas, 'POST', 200, {
        success: true, 
        products: [1,2,3]
    }, {});
})

4. Generate TypeScript for any client of your API / serverless functions

  • In Terminal, go to your package root
  • Make sure you've installed this package (npm i @andyrmitchell/endpoint-schemas -D). The -D is optional.
  • npx make-endpoint-schemas-client
    • It will ask you where your functions are kept, find every endpoint.ts within that root (i.e. every endpoint), and output a .ts file in the destination directory that you choose, called EndpointMap.ts.
    • Recommendation: Make this part of your build/deploy process

### 5. In your client, consume fully typed responses from your endpoints

You'll import the EndpointMap.ts you generated, and use its helper functions to fetch a response. That response will be fully typed, and validated.

Example: fetch an endpoint

import {fetchEdge} from './path/to/EndpointMap.ts'

const resource = await fetchEdge('endpoint1::POST', {bundle_id: 1}, {'root_url': 'https://api.yourserver.com', 'bearer_token': 'auth123'});
// This is all type checked, and will autocomplete in your IDE. (Notice it matches the server's endpoint.ts)
if( resource.data?.success ) {
    resource.data.products;
}

Example: fetch an endpoint manually, and cast its type

If you don't want to use the fetchEdge helper, and prefer a regular fetch:

import {EndpointTypesMap} from './path/to/EndpointMap.ts';

const response = await fetch("http://example.com/endpoint1");

// Manually cast it. Pay attention to match up the endpoint name ("endpoint1") method used ("POST"), and to specify "response". 
const result = response.json() as unknown as EndpointTypesMap['endpoint1::POST']['response'];

Example: invoke a Supabase function

As above, but use invokeEdge instead of fetchEdge

Limitations

Imported modules cannot use 'default' and risk namespace collision

If two end points import two modules, that both export a 'run' item, potentially bad things happen:

  • At the very least, the output file will have two 'run' declarations, causing a conflict
  • If they have two different purposes, it'll be hard to decide which to use.

Current Solution

  • Manually try to make sure endpoint.ts doesn't repeat names anywhere in its imports. Good luck!

TODO Future Solution

  • The dream is that some code will be able to sensibly roll up imports into one TypeScript file
    • Obviously Webpack/esbuild does this... but only to output Javascript. No good, as we need the types.
      • They do support plugins, which might be a pathway
      • tsconfig also exports a type declaration file alongside it. We'd lose the schemas, but maybe they're fine in pure Javascript. WORTH INVESTIGATING.
        • We could change our approach, to only output the Zod Schemas (pure JS), then use z.infer on the client end to turn them back into types.
    • I'm sure Deno must do this, as its TypeScript native (no need to convert) but you might still want to bundle into one file for performance.
      • Deno vendor: downloads remote imports to a local ./vendor folder
      • Deno compile: rolls things up, but to an executable
    • Microsoft's API Extractor comes close, but doesn't seem to like Deno (needs tsconfig and package.json, etc.). Also it's technically just for .d.ts files.
    • Use something like ncc (or Deno emit, or vercel/pkg) but with tsconfig's declaration: true. It should generate type outputs. We wouldn't get the schemas though.
    • It must be a solved problem...
  • Failing that, we must namespace things.
    • Change the way it works, so it recursively imports immediately upon reaching every endpoint.ts file (its too late to disambiguate after they've merged mulitple endpoint.ts).
    • That recursive import should find the tokens in the export, give them a prefix, and update the importing file too.
      • This is likely to be very brittle with different non-word boundaries.
        • It's probably a game of whackamole to find edge cases. It might be easier to convert export script to Javascript, as I'm more comfortable with that.

Roadmap

See ROADMAP.MD

Solve the limitation by using esbuild instead

Loosen the requirement to be in a directory

  • make it possible to optionally state/override the endpoint name as a const in endpoint.ts (the generator code would then extract this)
0.1.2

1 year ago

0.1.1

1 year ago

0.1.0

1 year ago