From 8d895c16ad5e69f03149e2851e4395d58036db2f Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 26 Dec 2023 09:49:04 +0530 Subject: [PATCH] feat: add @inputError tag and change how other edge tags work The @error tag is used to read generic error message The @inputError tag should be used to read validation error messages --- src/plugins/edge.ts | 68 ++++++++++- src/session.ts | 55 ++++++--- src/values_store.ts | 14 +-- tests/session.spec.ts | 278 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 379 insertions(+), 36 deletions(-) diff --git a/src/plugins/edge.ts b/src/plugins/edge.ts index f4ed69c..157be41 100644 --- a/src/plugins/edge.ts +++ b/src/plugins/edge.ts @@ -43,7 +43,7 @@ export const edgePluginSession: PluginFn = (edge) => { * Define a local variable */ buffer.writeExpression( - `let message = state.flashMessages.get(${key})`, + `let $message = state.flashMessages.get(${key})`, token.filename, token.loc.start.line ) @@ -53,7 +53,65 @@ export const edgePluginSession: PluginFn = (edge) => { * the existence of the "message" variable */ parser.stack.defineScope() - parser.stack.defineVariable('message') + parser.stack.defineVariable('$message') + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Clear the scope of the local variables before we + * close the if statement + */ + parser.stack.clearScope() + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) + + edge.registerTag({ + tagName: 'inputError', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const key = parser.utils.stringify(expression) + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (!!state.flashMessages.get('inputErrorsBag', {})[${key}]) {`, + token.filename, + token.loc.start.line + ) + + /** + * Define a local variable + */ + buffer.writeExpression( + `let $messages = state.flashMessages.get('inputErrorsBag', {})[${key}]`, + token.filename, + token.loc.start.line + ) + + /** + * Create a local variables scope and tell the parser about + * the existence of the "messages" variable + */ + parser.stack.defineScope() + parser.stack.defineVariable('$messages') /** * Process component children using the parser @@ -92,7 +150,7 @@ export const edgePluginSession: PluginFn = (edge) => { * Write an if statement */ buffer.writeStatement( - `if (!!state.flashMessages.get('errors', {})[${key}]) {`, + `if (state.flashMessages.has(['errorsBag', ${key}])) {`, token.filename, token.loc.start.line ) @@ -101,7 +159,7 @@ export const edgePluginSession: PluginFn = (edge) => { * Define a local variable */ buffer.writeExpression( - `let messages = state.flashMessages.get('errors', {})[${key}]`, + `let $message = state.flashMessages.get(['errorsBag', ${key}])`, token.filename, token.loc.start.line ) @@ -111,7 +169,7 @@ export const edgePluginSession: PluginFn = (edge) => { * the existence of the "messages" variable */ parser.stack.defineScope() - parser.stack.defineVariable('messages') + parser.stack.defineVariable('$message') /** * Process component children using the parser diff --git a/src/session.ts b/src/session.ts index 3b53581..7749354 100644 --- a/src/session.ts +++ b/src/session.ts @@ -293,9 +293,36 @@ export class Session { return this.#getValuesStore('write').clear() } + /** + * Add a key-value pair to flash messages + */ + flash(key: string, value: AllowedSessionValues): void + flash(keyValue: SessionData): void + flash(key: string | SessionData, value?: AllowedSessionValues): void { + if (typeof key === 'string') { + if (value) { + this.#getFlashStore('write').set(key, value) + } + } else { + this.#getFlashStore('write').merge(key) + } + } + + /** + * Flash errors to the errorsBag. You can read these + * errors via the "@error" tag. + * + * Appends new messages to the existing collection. + */ + flashErrors(errorsCollection: Record) { + this.flash({ errorsBag: errorsCollection }) + } + /** * Flash validation error messages. Make sure the error - * is an instance of VineJS ValidationException + * is an instance of VineJS ValidationException. + * + * Overrides existing inputErrors */ flashValidationErrors(error: HttpError) { const errorsBag = error.messages.reduce((result: Record, message: any) => { @@ -308,22 +335,18 @@ export class Session { }, {}) this.flashExcept(['_csrf', '_method']) - this.flash('errors', errorsBag) - } - /** - * Add a key-value pair to flash messages - */ - flash(key: string, value: AllowedSessionValues): void - flash(keyValue: SessionData): void - flash(key: string | SessionData, value?: AllowedSessionValues): void { - if (typeof key === 'string') { - if (value) { - this.#getFlashStore('write').set(key, value) - } - } else { - this.#getFlashStore('write').merge(key) - } + /** + * Adding to inputErrorsBag for "@inputError" tag + * to read validation errors + */ + this.flash('inputErrorsBag', errorsBag) + + /** + * For legacy support and not to break apps using + * the older version of @adonisjs/session package + */ + this.flash('errors', errorsBag) } /** diff --git a/src/values_store.ts b/src/values_store.ts index ac014d7..a65bf71 100644 --- a/src/values_store.ts +++ b/src/values_store.ts @@ -34,7 +34,7 @@ export class ReadOnlyValuesStore { /** * Get value for a given key */ - get(key: string, defaultValue?: any): any { + get(key: string | string[], defaultValue?: any): any { const value = lodash.get(this.values, key) if (defaultValue !== undefined && (value === null || value === undefined)) { return defaultValue @@ -47,7 +47,7 @@ export class ReadOnlyValuesStore { * A boolean to know if value exists. Extra guards to check * arrays for it's length as well. */ - has(key: string, checkForArraysLength: boolean = true): boolean { + has(key: string | string[], checkForArraysLength: boolean = true): boolean { const value = this.get(key) if (!Array.isArray(value)) { return !!value @@ -110,7 +110,7 @@ export class ValuesStore extends ReadOnlyValuesStore { /** * Set key/value pair */ - set(key: string, value: AllowedSessionValues): void { + set(key: string | string[], value: AllowedSessionValues): void { this.#modified = true lodash.set(this.values, key, value) } @@ -118,7 +118,7 @@ export class ValuesStore extends ReadOnlyValuesStore { /** * Remove key */ - unset(key: string): void { + unset(key: string | string[]): void { this.#modified = true lodash.unset(this.values, key) } @@ -127,7 +127,7 @@ export class ValuesStore extends ReadOnlyValuesStore { * Pull value from the store. It is same as calling * store.get and then store.unset */ - pull(key: string, defaultValue?: any): any { + pull(key: string | string[], defaultValue?: any): any { return ((value): any => { this.unset(key) return value @@ -138,7 +138,7 @@ export class ValuesStore extends ReadOnlyValuesStore { * Increment number. The method raises an error when * nderlying value is not a number */ - increment(key: string, steps: number = 1): void { + increment(key: string | string[], steps: number = 1): void { const value = this.get(key, 0) if (typeof value !== 'number') { throw new RuntimeException(`Cannot increment "${key}". Existing value is not a number`) @@ -151,7 +151,7 @@ export class ValuesStore extends ReadOnlyValuesStore { * Increment number. The method raises an error when * nderlying value is not a number */ - decrement(key: string, steps: number = 1): void { + decrement(key: string | string[], steps: number = 1): void { const value = this.get(key, 0) if (typeof value !== 'number') { throw new RuntimeException(`Cannot decrement "${key}". Existing value is not a number`) diff --git a/tests/session.spec.ts b/tests/session.spec.ts index 76b5899..b5fae14 100644 --- a/tests/session.spec.ts +++ b/tests/session.spec.ts @@ -634,6 +634,13 @@ test.group('Session | Flash', (group) => { await new SessionProvider(app).boot() }) + group.each.setup(() => { + return () => { + edge.removeTemplate('flash_no_errors_messages') + edge.removeTemplate('flash_errors_messages') + } + }) + test('flash data using the session store', async ({ assert }) => { let sessionId: string | undefined @@ -966,6 +973,94 @@ test.group('Session | Flash', (group) => { email: ['Invalid email'], username: ['Invalid username', 'Username is required'], }, + inputErrorsBag: { + email: ['Invalid email'], + username: ['Invalid username', 'Username is required'], + }, + }, + }) + }) + + test("multiple calls to flashValidationErrors should keep the last one's", async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + const errorReporter = new SimpleErrorReporter() + const errorReporter1 = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + errorReporter.report('Invalid email', 'email', fieldContext.create('email', ''), {}) + + errorReporter1.report('Invalid name', 'alpha', fieldContext.create('name', ''), {}) + + session.flashValidationErrors(errorReporter.createError()) + session.flashValidationErrors(errorReporter1.createError()) + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + errors: { + name: ['Invalid name'], + }, + inputErrorsBag: { + name: ['Invalid name'], + }, + }, + }) + }) + + test('flash collection of errors', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flashErrors({ + E_AUTHORIZATION_FAILED: 'Cannot access route', + }) + session.flashErrors({ + E_ACCESS_DENIED: 'Cannot access resource', + }) + + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + errorsBag: { + E_AUTHORIZATION_FAILED: 'Cannot access route', + E_ACCESS_DENIED: 'Cannot access resource', + }, }, }) }) @@ -1016,10 +1111,10 @@ test.group('Session | Flash', (group) => { edge.registerTemplate('flash_messages_via_tag', { template: `@flashMessage('status') -

{{ message }}

+

{{ $message }}

@end @flashMessage('success') -

{{ message }}

+

{{ $message }}

@else

No success message

@end @@ -1063,11 +1158,11 @@ test.group('Session | Flash', (group) => { ) }) - test('use error tag when there are no error message', async ({ assert }) => { + test('use inputError tag when there are no error message', async ({ assert }) => { edge.registerTemplate('flash_no_errors_messages', { template: ` - @error('username') - @each(message in messages) + @inputError('username') + @each(message in $messages)

{{ message }}

@end @else @@ -1097,13 +1192,13 @@ test.group('Session | Flash', (group) => { ) }) - test('access flash error messages using the @error tag', async ({ assert }) => { + test('access input error messages using the @inputError tag', async ({ assert }) => { let sessionId: string | undefined edge.registerTemplate('flash_errors_messages', { template: ` - @error('username') - @each(message in messages) + @inputError('username') + @each(message in $messages)

{{ message }}

@end @else @@ -1157,4 +1252,171 @@ test.group('Session | Flash', (group) => { ['', '

Invalid username

', '

Username is required

', ''] ) }) + + test('define @inputError key as a variable', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @inputError(field) + @each(message in $messages) +

{{ message }}

+ @end + @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_errors_messages', { field: 'username' })) + await session.commit() + response.finish() + } else { + const errorReporter = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + + session.flashValidationErrors(errorReporter.createError()) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['', '

Invalid username

', '

Username is required

', ''] + ) + }) + + test('access error messages using the @error tag', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @error('E_ACCESS_DENIED') +

{{ $message }}

+ @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_errors_messages')) + await session.commit() + response.finish() + } else { + session.flashErrors({ + E_ACCESS_DENIED: 'Access denied', + }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

Access denied

', ''] + ) + }) + + test('define @error key from a variable', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @error(errorCode) +

{{ $message }}

+ @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send( + await ctx.view.render('flash_errors_messages', { errorCode: 'E_ACCESS_DENIED' }) + ) + await session.commit() + response.finish() + } else { + session.flashErrors({ + E_ACCESS_DENIED: 'Access denied', + }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

Access denied

', ''] + ) + }) })