diff --git a/api/package.json b/api/package.json index c49bf3b99ff..d275586105f 100644 --- a/api/package.json +++ b/api/package.json @@ -5,8 +5,8 @@ }, "dependencies": { "@fastify/cookie": "^8.3.0", - "@fastify/middie": "8.3", "@fastify/csrf-protection": "6.3.0", + "@fastify/middie": "8.3", "@fastify/session": "^10.1.1", "@fastify/swagger": "^8.3.1", "@fastify/swagger-ui": "^1.5.0", @@ -17,7 +17,8 @@ "fastify": "4.17.0", "fastify-auth0-verify": "^1.0.0", "fastify-plugin": "^4.3.0", - "nodemon": "2.0.22" + "nodemon": "2.0.22", + "query-string": "^7.1.3" }, "description": "The freeCodeCamp.org open-source codebase and curriculum", "devDependencies": { diff --git a/api/src/plugins/redirect-with-message.test.ts b/api/src/plugins/redirect-with-message.test.ts new file mode 100644 index 00000000000..5a9e52f54b7 --- /dev/null +++ b/api/src/plugins/redirect-with-message.test.ts @@ -0,0 +1,101 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import qs from 'query-string'; + +import redirectWithMessage from './redirect-with-message'; + +async function setupServer() { + const fastify = Fastify(); + await fastify.register(redirectWithMessage); + return fastify; +} + +const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +describe('redirectWithMessage plugin', () => { + it('should decorate reply object with redirectWithMessage method', async () => { + expect.assertions(3); + + const fastify = await setupServer(); + + fastify.get('/', (_req, reply) => { + expect(reply).toHaveProperty('redirectWithMessage'); + expect(reply.redirectWithMessage).toBeInstanceOf(Function); + return { foo: 'bar' }; + }); + + const res = await fastify.inject({ + method: 'GET', + url: '/' + }); + + expect(res.statusCode).toEqual(200); + }); + + describe('redirectWithMessage method', () => { + let fastify: FastifyInstance; + beforeEach(async () => { + fastify = await setupServer(); + }); + + it('should redirect to the first argument', async () => { + fastify.get('/', (_req, reply) => { + return reply.redirectWithMessage('/target', { + type: 'info', + content: 'foo' + }); + }); + const res = await fastify.inject({ + method: 'GET', + url: '/' + }); + + expect(res.headers.location).toMatch(/^\/target/); + expect(res.statusCode).toEqual(302); + }); + + it('should convert the second argument into a query string', async () => { + fastify.get('/', (_req, reply) => { + return reply.redirectWithMessage('/target', { + type: 'info', + content: 'foo' + }); + }); + const res = await fastify.inject({ + method: 'GET', + url: '/' + }); + + expect(res.headers.location).toMatch(/^\/target\?messages=info/); + }); + + it('should encode the message twice when creating the query string', async () => { + const expectedMessage = { danger: ['foo bar'] }; + + fastify.get('/', (_req, reply) => { + return reply.redirectWithMessage('/target', { + type: 'danger', + content: 'foo bar' + }); + }); + + const res = await fastify.inject({ + method: 'GET', + url: '/' + }); + if (!isString(res.headers.location)) + throw new Error('Location is not a string'); + const { search } = new URL(res.headers.location, 'http://localhost'); + + // The query string itself is encoded: + const { messages } = qs.parse(search, { arrayFormat: 'index' }); + if (!isString(messages)) throw new Error('Messages is not a string'); + + // As is the message embedded in it: + expect(qs.parse(messages, { arrayFormat: 'index' })).toEqual( + expectedMessage + ); + }); + }); +}); diff --git a/api/src/plugins/redirect-with-message.ts b/api/src/plugins/redirect-with-message.ts new file mode 100644 index 00000000000..3fc0ca21242 --- /dev/null +++ b/api/src/plugins/redirect-with-message.ts @@ -0,0 +1,54 @@ +import { FastifyPluginCallback, type FastifyReply } from 'fastify'; +import fp from 'fastify-plugin'; +// TODO: (POST MVP)use node's querystring and just JSON stringify the message. +// No need for query-string on either side. + +import qs from 'query-string'; + +declare module 'fastify' { + interface FastifyReply { + redirectWithMessage: typeof redirectWithMessage; + } +} + +type Message = { + type: 'info' | 'danger' | 'success' | 'errors'; + content: string; +}; + +type MessageQuery = { + info?: string[]; + danger?: string[]; + success?: string[]; + errors?: string[]; +}; + +// The client expects a message like { info: ['foo'] }, { danger: ['bar'] } etc. +const prepareMessage = (message: Message): MessageQuery => ({ + [message.type]: [message.content] +}); + +function redirectWithMessage( + this: FastifyReply, + url: string, + message: Message +) { + return this.redirect( + `${url}?${qs.stringify( + { + messages: qs.stringify(prepareMessage(message), { + arrayFormat: 'index' + }) + }, + { arrayFormat: 'index' } + )}` + ); +} + +const plugin: FastifyPluginCallback = (fastify, _options, done) => { + fastify.decorateReply('redirectWithMessage', redirectWithMessage); + + done(); +}; + +export default fp(plugin); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1fb5ff4d43..52f1c1f1a1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: nodemon: specifier: 2.0.22 version: 2.0.22 + query-string: + specifier: ^7.1.3 + version: 7.1.3 devDependencies: '@fastify/type-provider-typebox': specifier: 3.2.0