messages: restructure response message building

refs INTEROP-7087
flag=none

why:
* the previous commit's way of building stateless messages quickly got
unwieldy, passing around many attributes just
to use them to build a response
* because closures are Good!

test plan:
* specs still pass
* sending a postMessage to canvas still always prompts a response,
whether that is a success or an error
* and only one response, not two

Change-Id: I9c334bc90f0a4e5f32bdfc7d5f4012f4524ababb
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/274078
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Sean Scally <sean.scally@instructure.com>
QA-Review: Tucker Mcknight <tmcknight@instructure.com>
Product-Review: Xander Moffatt <xmoffatt@instructure.com>
This commit is contained in:
Xander Moffatt 2021-09-21 15:58:46 -06:00
parent f5f1f22c94
commit 35eb5fb3fb
6 changed files with 141 additions and 168 deletions

View File

@ -16,28 +16,27 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
sendResponse,
sendErrorResponse,
sendGenericErrorResponse,
sendBadRequestResponse,
sendWrongOriginResponse,
sendUnsupportedSubjectResponse,
sendSuccess
} from '../response_messages'
import buildResponseMessages from '../response_messages'
describe('response_messages', () => {
const postMessageMock = jest.fn()
let params
let builder
beforeEach(() => {
resetBuilder()
postMessageMock.mockRestore()
})
function resetBuilder(overrides = {}) {
params = {
targetWindow: {postMessage: postMessageMock},
origin: 'http://tool.test',
subject: 'subject'
subject: 'subject',
...overrides
}
postMessageMock.mockRestore()
})
builder = buildResponseMessages(params)
}
function expectPostMessageContents(contents, origin = expect.any(String)) {
expect(postMessageMock).toHaveBeenCalledWith(expect.objectContaining(contents), origin)
@ -45,7 +44,7 @@ describe('response_messages', () => {
describe('sendResponse', () => {
it('appends .response to the subject', () => {
sendResponse(params)
builder.sendResponse()
expectPostMessageContents({subject: 'subject.response'})
})
@ -53,18 +52,18 @@ describe('response_messages', () => {
const message_id = 'message_id'
beforeEach(() => {
params.message_id = message_id
resetBuilder({message_id})
})
it('includes message_id in response', () => {
sendResponse(params)
builder.sendResponse()
expectPostMessageContents({message_id})
})
})
describe('when targetWindow does not exist', () => {
beforeEach(() => {
delete params.targetWindow
resetBuilder({targetWindow: null})
jest.spyOn(console, 'error').mockImplementation()
})
@ -74,25 +73,22 @@ describe('response_messages', () => {
})
it('logs an error to the console', () => {
sendResponse(params)
builder.sendResponse()
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalled()
})
})
it('sends response to the given origin', () => {
sendResponse(params)
builder.sendResponse()
expectPostMessageContents({}, 'http://tool.test')
})
describe('when contents parameter is passed', () => {
const contents = {hello: 'world'}
beforeEach(() => {
params.contents = contents
})
it('merges contents into response', () => {
sendResponse(params)
builder.sendResponse(contents)
expectPostMessageContents(contents)
})
})
@ -100,27 +96,18 @@ describe('response_messages', () => {
describe('sendSuccess', () => {
beforeEach(() => {
params.message_id = 'message_id'
resetBuilder({message_id: 'message_id'})
})
it('only sends subject and message_id', () => {
sendSuccess(params)
builder.sendSuccess()
expect(Object.keys(postMessageMock.mock.calls[0][0])).toEqual(['subject', 'message_id'])
})
})
function expectCodeAndMessageInError({subject, code, message}) {
beforeEach(() => {
if (code) {
params.code = code
}
if (message) {
params.message = message
}
})
it('includes code and message in error', () => {
subject(params)
subject(builder)
const response = {}
if (code) {
response.code = code
@ -132,40 +119,46 @@ describe('response_messages', () => {
})
}
describe('sendErrorResponse', () => {
describe('sendError', () => {
const code = 'error_code'
const message = 'error message'
expectCodeAndMessageInError({
subject: sendErrorResponse,
code: 'error_code',
message: 'error message'
subject: builder => builder.sendError(code, message),
code,
message
})
})
describe('sendGenericErrorResponse', () => {
describe('sendGenericError', () => {
const message = 'generic error message'
expectCodeAndMessageInError({
subject: sendGenericErrorResponse,
code: 'error',
message: 'generic error message'
subject: builder => builder.sendGenericError(message),
code: 'error'
})
})
describe('sendBadRequestResponse', () => {
describe('sendBadRequestError', () => {
const message = 'bad request error message'
expectCodeAndMessageInError({
subject: sendBadRequestResponse,
subject: builder => builder.sendBadRequestError(message),
code: 'bad_request',
message: 'error message'
message
})
})
describe('sendWrongOriginResponse', () => {
describe('sendWrongOriginError', () => {
expectCodeAndMessageInError({
subject: sendWrongOriginResponse,
subject: builder => builder.sendWrongOriginError(),
code: 'wrong_origin'
})
})
describe('sendUnsupportedSubjectResponse', () => {
describe('sendUnsupportedSubjectError', () => {
expectCodeAndMessageInError({
subject: sendUnsupportedSubjectResponse,
subject: builder => builder.sendUnsupportedSubjectError(),
code: 'unsupported_subject'
})
})

View File

@ -18,17 +18,12 @@
/* eslint no-console: 0 */
import {findDomForWindow} from './util'
import {
NAVIGATION_MESSAGE as MENTIONS_NAVIGATION_MESSAGE,
INPUT_CHANGE_MESSAGE as MENTIONS_INPUT_CHANGE_MESSAGE,
SELECTION_MESSAGE as MENTIONS_SELECTION_MESSAGE
} from '../../rce/plugins/canvas_mentions/constants'
import {
sendGenericErrorResponse,
sendSuccess,
sendUnsupportedSubjectResponse
} from './response_messages'
import buildResponseMessages from './response_messages'
// page-global storage for data relevant to LTI postMessage events
const ltiState = {}
@ -71,19 +66,19 @@ async function ltiMessageHandler(e) {
// look at messageType for backwards compatibility
const subject = message.subject || message.messageType
const message_id = message.message_id
const responseMessages = buildResponseMessages({
targetWindow: e.source,
origin: e.origin,
subject,
message_id: message.message_id
})
if (SUBJECT_IGNORE_LIST.includes(subject)) {
// These messages are handled elsewhere
return false
} else if (!SUBJECT_ALLOW_LIST.includes(subject)) {
// Enforce subject allowlist -- unknown type
sendUnsupportedSubjectResponse({
targetWindow: e.source,
origin: e.origin,
subject,
message_id
})
responseMessages.sendUnsupportedSubjectError()
return false
}
@ -91,22 +86,16 @@ async function ltiMessageHandler(e) {
const handlerModule = await import(`./subjects/${subject}.js`)
const hasSentResponse = handlerModule.default({
message,
iframe: findDomForWindow(e.source),
event: e
event: e,
responseMessages
})
if (!hasSentResponse) {
sendSuccess({targetWindow: e.source, origin: e.origin, subject, message_id})
responseMessages.sendSuccess()
}
return true
} catch (error) {
console.error(`Error loading or executing message handler for "${subject}"`, error)
sendGenericErrorResponse({
targetWindow: e.source,
origin: e.origin,
subject,
message_id,
message: error.message
})
responseMessages.sendGenericError(error.message)
return false
}
}

View File

@ -21,66 +21,57 @@ const UNSUPPORTED_SUBJECT_ERROR_CODE = 'unsupported_subject'
const WRONG_ORIGIN_ERROR_CODE = 'wrong_origin'
const BAD_REQUEST_ERROR_CODE = 'bad_request'
function sendResponse({targetWindow, origin, subject, message_id, contents}) {
const message = {subject: `${subject}.response`}
if (message_id) {
message.message_id = message_id
const buildResponseMessages = ({targetWindow, origin, subject, message_id}) => {
const sendResponse = (contents = {}) => {
const message = {subject: `${subject}.response`}
if (message_id) {
message.message_id = message_id
}
if (targetWindow) {
targetWindow.postMessage({...message, ...contents}, origin)
} else {
// eslint-disable-next-line no-console
console.error('Error sending response postMessage: target window does not exist')
}
}
if (targetWindow) {
targetWindow.postMessage({...message, ...contents}, origin)
} else {
// eslint-disable-next-line no-console
console.error('Error sending response postMessage: target window does not exist')
const sendSuccess = () => {
sendResponse({})
}
const sendError = (code, message) => {
const error = {code}
if (message) {
error.message = message
}
sendResponse({error})
}
const sendGenericError = message => {
sendError(GENERIC_ERROR_CODE, message)
}
const sendBadRequestError = message => {
sendError(BAD_REQUEST_ERROR_CODE, message)
}
const sendWrongOriginError = () => {
sendError(WRONG_ORIGIN_ERROR_CODE)
}
const sendUnsupportedSubjectError = () => {
sendError(UNSUPPORTED_SUBJECT_ERROR_CODE)
}
return {
sendResponse,
sendSuccess,
sendError,
sendGenericError,
sendBadRequestError,
sendWrongOriginError,
sendUnsupportedSubjectError
}
}
function sendSuccess({targetWindow, origin, subject, message_id}) {
sendResponse({targetWindow, origin, subject, message_id, contents: {}})
}
function sendErrorResponse({targetWindow, origin, subject, message_id, code, message}) {
const error = {code}
if (message) {
error.message = message
}
sendResponse({targetWindow, origin, subject, message_id, contents: {error}})
}
function sendGenericErrorResponse({targetWindow, origin, subject, message_id, message}) {
sendErrorResponse({targetWindow, origin, subject, message_id, message, code: GENERIC_ERROR_CODE})
}
function sendBadRequestResponse({targetWindow, origin, subject, message_id, message}) {
sendErrorResponse({
targetWindow,
origin,
subject,
message_id,
message,
code: BAD_REQUEST_ERROR_CODE
})
}
function sendUnsupportedSubjectResponse({targetWindow, origin, subject, message_id}) {
sendErrorResponse({
targetWindow,
origin,
subject,
message_id,
code: UNSUPPORTED_SUBJECT_ERROR_CODE
})
}
function sendWrongOriginResponse({targetWindow, origin, subject, message_id}) {
sendErrorResponse({targetWindow, origin, subject, message_id, code: WRONG_ORIGIN_ERROR_CODE})
}
export {
sendResponse,
sendSuccess,
sendErrorResponse,
sendGenericErrorResponse,
sendBadRequestResponse,
sendUnsupportedSubjectResponse,
sendWrongOriginResponse
}
export default buildResponseMessages

View File

@ -16,26 +16,24 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export default function enableScrollEvents({iframe}) {
if (iframe) {
let timeout
window.addEventListener(
'scroll',
() => {
// requesting animation frames effectively debounces the scroll messages being sent
if (timeout) {
window.cancelAnimationFrame(timeout)
}
export default function enableScrollEvents({responseMessages}) {
let timeout
window.addEventListener(
'scroll',
() => {
// requesting animation frames effectively debounces the scroll messages being sent
if (timeout) {
window.cancelAnimationFrame(timeout)
}
timeout = window.requestAnimationFrame(() => {
const msg = JSON.stringify({
subject: 'lti.scroll',
scrollY: window.scrollY
})
iframe.contentWindow.postMessage(msg, '*')
timeout = window.requestAnimationFrame(() => {
responseMessages.sendResponse({
subject: 'lti.scroll',
scrollY: window.scrollY
})
},
false
)
}
})
},
false
)
return true
}

View File

@ -18,15 +18,13 @@
import $ from 'jquery'
export default function fetchWindowSize({message, iframe}) {
if (iframe) {
message.height = window.innerHeight
message.width = window.innerWidth
message.offset = $('.tool_content_wrapper').offset()
message.footer = $('#fixed_bottom').height() || 0
message.scrollY = window.scrollY
const strMessage = JSON.stringify(message)
iframe.contentWindow.postMessage(strMessage, '*')
}
export default function fetchWindowSize({responseMessages}) {
responseMessages.sendResponse({
height: window.innerHeight,
width: window.innerWidth,
offset: $('.tool_content_wrapper').offset(),
footer: $('#fixed_bottom').height() || 0,
scrollY: window.scrollY
})
return true
}

View File

@ -17,8 +17,9 @@
*/
import ToolLaunchResizer from '../tool_launch_resizer'
import {findDomForWindow} from '../util'
export default function frameResize({message, iframe, event}) {
export default function frameResize({message, event}) {
const toolResizer = new ToolLaunchResizer()
let height = message.height
if (height <= 0) height = 1
@ -29,12 +30,15 @@ export default function frameResize({message, iframe, event}) {
// If content.length is 0 then jquery didn't the tool wrapper.
if (container.length > 0) {
toolResizer.resize_tool_content_wrapper(height, container)
} else if (iframe) {
} else {
// Attempt to find an embedded iframe that matches the event source.
if (typeof height === 'number') {
height += 'px'
const iframe = findDomForWindow(event.source)
if (iframe) {
if (typeof height === 'number') {
height += 'px'
}
iframe.height = height
iframe.style.height = height
}
iframe.height = height
iframe.style.height = height
}
}