{"record":{"id":"c7bf0378838df0a6","repo":"instructure/canvas-lms","slug":"e-message-line-items-controller","errorCode":null,"errorMessage":"e.message","messagePattern":"e\\.message","errorType":"http","errorClass":null,"httpStatus":400,"severity":"error","filePath":"app/controllers/lti/ims/line_items_controller.rb","lineNumber":98,"sourceCode":"\n      ACTION_SCOPE_MATCHERS = {\n        create: all_of(TokenScopes::LTI_AGS_LINE_ITEM_SCOPE),\n        update: all_of(TokenScopes::LTI_AGS_LINE_ITEM_SCOPE),\n        destroy: all_of(TokenScopes::LTI_AGS_LINE_ITEM_SCOPE),\n        show: any_of(TokenScopes::LTI_AGS_LINE_ITEM_SCOPE, TokenScopes::LTI_AGS_LINE_ITEM_READ_ONLY_SCOPE),\n        index: any_of(TokenScopes::LTI_AGS_LINE_ITEM_SCOPE, TokenScopes::LTI_AGS_LINE_ITEM_READ_ONLY_SCOPE)\n      }.with_indifferent_access.freeze\n\n      MIME_TYPE = \"application/vnd.ims.lis.v2.lineitem+json\"\n      CONTAINER_MIME_TYPE = \"application/vnd.ims.lis.v2.lineitemcontainer+json\"\n\n      rescue_from ActionController::BadRequest do |e|\n        unless Rails.env.production?\n          logger.error(e.message)\n          Lti::Errors::ErrorLogger.log_error(e)\n        end\n        render json: { error: e.message }, status: :bad_request\n      end\n\n      # @API Create a Line Item\n      # Create a new Line Item\n      #\n      # @argument scoreMaximum [Required, Float]\n      #   The maximum score for the line item. Scores created for the Line Item may exceed this value.\n      #\n      # @argument label [Required, String]\n      #   The label for the Line Item. If no resourceLinkId is specified this value will also be used\n      #   as the name of the placeholder assignment.\n      #\n      # @argument resourceId [String]\n      #   A Tool Provider specified id for the Line Item. Multiple line items may\n      #   share the same resourceId within a given context.\n      #\n      # @argument tag [String]\n      #    A value used to qualify a line Item beyond its ids. Line Items may be queried\n      #    by this value in the List endpoint. Multiple line items can share the same tag","sourceCodeStart":80,"sourceCodeEnd":116,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/controllers/lti/ims/line_items_controller.rb#L80-L116","documentation":"The LTI AGS Line Items controller rescues ActionController::BadRequest and renders {\"error\": e.message} with HTTP 400. ActionController::BadRequest is raised when Rails fails to parse or validate the request itself — most often a malformed JSON body that the wrapped_params parse cannot decode, or a query/body parameter that fails coercion. Because the message is the raw exception message, the JSON payload can be a verbose parser error rather than a clean AGS error code.","triggerScenarios":"POST/PUT to /api/lti/courses/:course_id/line_items with a request body that is not valid JSON (truncated body, wrong Content-Type, single quotes, trailing commas), or required AGS fields (e.g. scoreMaximum, label) absent so strong-parameters/wrapped param handling raises BadRequest.","commonSituations":"Tool platform serializing the AGS line-item payload with wrong Content-Type (form-urlencoded instead of application/json); sending an empty body; proxy or gateway truncating the body; client library version emitting a body shape the endpoint's strong params no longer accept after a Canvas upgrade.","solutions":["Inspect the rendered e.message in the 400 response (or server logs outside production, where it is also logged) — it usually contains the JSON parser offset or missing parameter name.","Ensure the request sets Content-Type: application/json and the body is well-formed JSON containing scoreMaximum and label per AGS spec.","Validate/JSON.stringify the payload in the tool client before sending, and reject NaN/undefined scoreMaximum values.","If a Canvas upgrade changed accepted params, diff against the AGS spec and update the tool's request body."],"exampleFix":"// before\nfetch(lineItemsUrl, {\n  method: 'POST',\n  body: JSON.stringify({ scoreMaximum: '100' }) // no Content-Type header\n});\n// after\nfetch(lineItemsUrl, {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n  body: JSON.stringify({ scoreMaximum: 100, label: 'Quiz 1', resourceId: 'quiz-1' })\n});","handlingStrategy":"validation","validationCode":"function validateLineItemPayload(payload) {\n  if (typeof payload.scoreMaximum !== 'number' || Number.isNaN(payload.scoreMaximum)) throw new Error('scoreMaximum must be a finite number');\n  if (typeof payload.label !== 'string' || payload.label.length === 0) throw new Error('label is required');\n  JSON.stringify(payload); // throws on cycles/non-serializable values\n  return payload;\n}","typeGuard":"function isLineItem(v) {\n  return typeof v === 'object' && v !== null && typeof v.scoreMaximum === 'number' && Number.isFinite(v.scoreMaximum) && typeof v.label === 'string';\n}","tryCatchPattern":"try {\n  const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });\n  if (res.status === 400) {\n    const msg = (await res.json()).error;\n    console.warn('AGS rejected request:', msg); // raw parser/param message\n  }\n} catch (e) {\n  handleNetworkError(e);\n}","preventionTips":["Always send Content-Type: application/json for AGS line item calls.","Run the body through JSON.parse(JSON.stringify(payload)) in tests to catch malformed serialization early.","Send AGS requests through one small client wrapper that validates required fields (scoreMaximum, label) before the call.","After Canvas upgrades, re-run AGS integration tests — accepted strong-params may have changed."],"tags":["lti","ags","http-400","json","request-validation"],"backgroundTag":"json-parse-error","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}