ts-rest / ts-rest

RPC-like client, contract, and server implementation for a pure REST API
https://ts-rest.com
MIT License
2.11k stars 91 forks source link

POST example does not work #588

Closed pct-cclausen closed 2 months ago

pct-cclausen commented 2 months ago

Describe the bug

A contract that posts some data does not work, gets a server error. I am following this example: https://ts-rest.com/docs/quickstart#server-implementation

See this minimal example using express.

How to reproduce

Copy paste the example code into a workspace with ts-rest and run it via jest

Expected behavior

The test should pass, see the code

Code reproduction


import { InitClientArgs, InitClientReturn, initClient, initContract, tsRestFetchApi } from '@ts-rest/core';
import { z } from 'zod';
import express from 'express';
import { createExpressEndpoints, initServer } from '@ts-rest/express';
import bodyParser from 'body-parser';

const contract = initContract().router({
  updateFoobar: {
    method: 'POST',
    path: '/updateFoobar',
    responses: {
      200: z.string(),
    },
    body: z.string(),
  },
});

type ContractClient = InitClientReturn<typeof contract, InitClientArgs>;

const router = initServer().router(contract, {
  updateFoobar: async (req) => {
    console.log(req.body); // never gets called
    return {
      body: 'HI ' + req.body,
      status: 200,
    };
  },
});

describe('ts-rest POSTs', () => {
  it('works?', async () => {
    const client: ContractClient = initClient(contract, {
      baseUrl: 'http://localhost:9999',
      baseHeaders: {},
      api: async (args) => {
        console.log('request A', args); // this prints among other things this:
        /**
        *
       path: 'http://localhost:9999/updateFoobar',
      method: 'POST',
      credentials: undefined,
      headers: { 'content-type': 'application/json' },
      body: '"WORLD"',
      rawBody: 'WORLD',
      rawQuery: undefined,
      contentType: 'application/json',

        */
        const resp = await tsRestFetchApi(args);
        console.log('request R', resp);
        return resp;
      },
    });

    const app = express();
    app.use(bodyParser.urlencoded({ extended: false }));
    app.use(bodyParser.json());

    createExpressEndpoints(contract, router, app);

    const server = app.listen(9999, 'localhost');

    try {
      const result = await client.updateFoobar({
        body: 'WORLD',
      });

      // Fails like this:
      // - HI WORLD
      // + <!DOCTYPE html>
      // + <html lang="en">
      // + <head>
      // + <meta charset="utf-8">
      // + <title>Error</title>
      // + </head>
      // + <body>
      // + <pre>SyntaxError: Unexpected token &#39;&quot;&#39;, &quot;#&quot; is not valid JSON<br> &nbsp; &nbsp;at JSON.parse (&lt;anonymous&gt;)<br> &nbsp; &nbsp;at createStrictSyntaxError (/workspace/node_modules/body-parser/lib/types/json.js:160:10)<br> &nbsp; &nbsp;at parse (/workspace/node_modules/body-parser/lib/types/json.js:83:15)<br> &nbsp; &nbsp;at /workspace/node_modules/body-parser/lib/read.js:128:18<br> &nbsp; &nbsp;at AsyncResource.runInAsyncScope (node:async_hooks:206:9)<br> &nbsp; &nbsp;at invokeCallback (/workspace/node_modules/raw-body/index.js:231:16)<br> &nbsp; &nbsp;at done (/workspace/node_modules/raw-body/index.js:220:7)<br> &nbsp; &nbsp;at IncomingMessage.onEnd (/workspace/node_modules/raw-body/index.js:280:7)<br> &nbsp; &nbsp;at IncomingMessage.emit (node:events:518:28)<br> &nbsp; &nbsp;at IncomingMessage.emit (node:domain:488:12)<br> &nbsp; &nbsp;at endReadableNT (node:internal/streams/readable:1696:12)<br> &nbsp; &nbsp;at processTicksAndRejections (node:internal/process/task_queues:82:21)</pre>
      // + </body>
      // + </html>
      // +

      // escaped error message:
/**
 Unexpected token '"', "#" is not valid JSON<br>    at JSON.parse (<anonymous>)<br>    at createStrictSyntaxError (/workspace/node_modules/body-parser/lib/types/json.js:160:10)<br>    at parse (/workspace/node_modules/body-parser/lib/types/json.js:83:15)<br>    at /workspace/node_modules/body-parser/lib/read.js:128:18<br>    at AsyncResource.runInAsyncScope (node:async_hooks:206:9)<br>    at invokeCallback (/workspace/node_modules/raw-body/index.js:231:16)<br>    at done (/workspace/node_modules/raw-body/index.js:220:7)<br>    at IncomingMessage.onEnd (/workspace/node_modules/raw-body/index.js:280:7)<br>    at IncomingMessage.emit (node:events:518:28)<br>    at IncomingMessage.emit (node:domain:488:12)<br>    at endReadableNT (node:internal/streams/readable:1696:12)<br>    at processTicksAndRejections (node:internal/process/task_queues:82:21)
*/

      expect(result.body).toBe('HI WORLD');
    } finally {
      server.close();
    }
  });
});

ts-rest version

I am using ts-rest 3.44.1 and zod 3.22.4 with typescript 5.2.2 Also tried ts-rest 3.28.0 with typescript 5.0.2

pct-cclausen commented 2 months ago

I've figured it out, carefully preparing examples typically leads to understanding problems and solving issue :)

The body needs to be an object or array or else body-parser gets unhappy due to this code in body-parser:

    if (strict) {
      var first = firstchar(body)

      if (first !== '{' && first !== '[') {
        debug('strict violation')
        throw createStrictSyntaxError(body, first)
      }
    }

So either disable strict mode:

    app.use(
      bodyParser.json({
        strict: false,
      })
    );

or make sure the body is an object, not just a string