Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Usage feedback & questions #8

Open
hmil opened this issue Aug 1, 2018 · 2 comments
Open

Usage feedback & questions #8

hmil opened this issue Aug 1, 2018 · 2 comments

Comments

@hmil
Copy link

hmil commented Aug 1, 2018

Hi, I like the idea behind this module and I think it is a step in the right direction. Now when using it, I got some frustration which makes me think there is room for improvement. Below are some of the issues I faced, I would like to hear your opinion on these:

Path parameters

There seems to be an issue when a path contains parameters (such as /users/:id), the typings become all confused in that case. I believe it is impossible to get proper type definitions for such URLs using your approach because TypeScript doesn't support string literal concatenation.

Free form API definition

When defining an API, one has to write a free form interface. There is no safeguard there, no autocompletion (was it "params"? "parameters"? or maybe "query?", you have to check the doc), and no type checking of the definition itself. This makes the experience of writing the API definition a little uncomfortable.

No semantic encapsulation

I understand your approach of defining the types based on the route to keep the layer thin. However, I feel there's a missed opportunity for a better encapsulation. IMO the route is an implementation detail just like the type of the query, response and the query parameters. I see no valid reason to promote the route as the primary identifier of an API endpoint within the TS source.

I mean that instead of writing this:

export interface MySocialAPI {
  '/users': {
    // Route name (wihout prefix, if you have one)
    GET: {
      // Any valid HTTP method
      query: {
        // Query string params (e.g. /me?includeProfilePics=true)
        includeProfilePics?: boolean
      }
      response: User[] // JSON response
    }
  }
}

You could have:

export interface MySocialAPI {
  'getAllUsers': {
    method: 'GET',
    path: '/users',
    query: {
        // Query string params (e.g. /me?includeProfilePics=true)
        includeProfilePics?: boolean
    }
    response: User[] // JSON response
  }
}

Then, at usage:

// This leaks an implementation detail:
const res = await api.get('/users');
// This carries semantic meaning:
const res = await api.getAllUsers();

Note that the second approach could help solve the issue with path parameters and even help navigating from the client to the server with a "Find all references" from an IDE.

Requests payload validation

This is arguably outside of the scope of this module, but the golden rule of server side development being "Don't trust user input", it is favorable to have some sort of payload validation mechanism.

I haven't put a lot of thought into this yet, but how would Restyped play with existing validation libraries?


You must have gained some valuable insights when working on this module and that is why I would love to hear your opinion on these issues.

@rawrmaan
Copy link
Owner

rawrmaan commented Aug 2, 2018

Hi @hmil, thanks for the comments! Here are my thoughts on the following:

Path parameters

You're right that TS doesn't support string literal concatenation. In these cases, you do the following:

  1. Define the route as a literal with the variable inline, just as you would in express, e.g. `/users/:id/posts'
  2. Explicitly declare the route when you do your API call: `await api.get<'/users/:id/posts'>('/users/1/posts')

This will allow you to reap the benefits of type checking with fully dynamic routes. I hope TS adds pattern matching someday so the explicit route declaration can go away.

Free form API definition

This is a good point. In the past it was hard to make a good, helpful "base type" (see RestypedIndexedBase in spec/index.d.ts), but with newer versions of TS it might be possible to make an extendable base that will make development easier. I'll look into this.

No semantic encapsulation

This is outside the scope of RESTyped because it implies the addition of runtime code to support looking up these named "routes". Kinda reminds me of GraphQL. It would be super easy to write your own wrapper around your RESTyped API calls if you want this kind of functionality.

Requests payload validation

Again, RESTyped adds no runtime code. You can use restyped-axios, restyped-express-async, restyped-hapi, etc. with any validation library you want.

@hmil
Copy link
Author

hmil commented Nov 4, 2018

Hey @rawrmaan ,

I ended up implementing the ideas above. The result is similar to restyped, but the goal differs in the following points:

  • Lightweight, but not weightless: I dropped the requirement of not having any runtime code in order to enable the other goals
  • Discoverability: High focus is put on having everything detected by intelliSense. In particular, an API builder seems easier to understand for intelliSense than raw object hashes like restyped uses.
  • Focus on usability: In addition to the API builder, a clever use of template litterals allowed to get around the limitation with path parameters, and give the definition an easy-to-read DSL look even though it is still pure TypeScript.
  • Type-checking at the runtime boundary: With runtypes as a first-class citizen, Rest.ts can automatically validate the user data against the type schema so that "impossible bugs" can't happen down the data path.
  • Named routes: Routes are labelled and referred to by that label, rather than their HTTP path. This removes some HTTP noise from the user code and hides some details like PATH parameters. In that sense, Rest.ts is almost an RPC framework because the transport is abstracted away (in part), whereas restyped aims to simply annotate REST like TypeScript annotates JS.

You can check out the WIP in this repository and I would love to get your feedback. There are currently some issues when you use it without restyped, but overall it is working.

Of course, this module isn't competing with restyped. Rather, it is a different take on a similar problem, given different constraints. Hopefully this effort will help get more people concerned about API type safety in general (you can't say no to everything when you have a wide range of solutions to chose from)!


Below is a side-by-side comparison of both frameworks:

Restyped:

export interface MySocialAPI {
  '/users': {
    // Route name (without prefix, if you have one)
    GET: {
      // Any valid HTTP method
      query: {
        // Query string params (e.g. /me?includeProfilePics=true)
        includeProfilePics?: boolean
      }
      response: User[] // JSON response
    }
  }

  '/user/:id/send-message': {
    POST: {
      params: {
        // Inline route params
        id: string
      }
      body: {
        // JSON request body
        message: string
      }
      response: {
        // JSON response
        success: boolean
      }
    }
  }
}

// Usage with express
const apiRouter = express.Router()
app.use('/api', apiRouter)
const router = RestypedRouter<MySocialAPI>(apiRouter)
router.post('/users/:id/send-message', async req => {
   // ...
})

// Usage with axios
const api = axios.create<MySocialAPI>({
  baseURL: 'https://fooddelivery.com/api/'
})
const res = await api.post('/user/2/send-message', {
  message: 'Hello type sanity!'
});

Rest.ts

export const MySocialAPI = defineAPI({
  getUsers: GET '/users'
    .query({
      includeProfilePics: rt.Boolean, // Or just 'false' or 'true' if not using runtypes
    })
    .response(rt.Array(User))

  postMessage: POST `/user/${'id'}/send-message`
    .body({
      message: rt.String
    })
    .response({
      success: rt.Boolean
    })
});

// Usage with express
const router = createRouter(MySocialAPI, {
  postMessage: async req => {
    // ...
  }
});
app.use(router);

// Usage with axios
const api = createConsumer(MySocialAPI, axios.create({
  baseURL: 'https://fooddelivery.com/api/'
}));
const res = await api.postMessage({
  params: {
    id: '2'
  },
  body: {
    message: 'Hello type sanity!'
  }
});

Let me know what you think!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants