diff --git a/nextjs/next.config.mjs b/nextjs/next.config.mjs index 7c7e451..fb98a23 100644 --- a/nextjs/next.config.mjs +++ b/nextjs/next.config.mjs @@ -1,19 +1,7 @@ /** @type {import('next').NextConfig} */ -const token = process.env.NEXT_PUBLIC_CANVAS_TOKEN; -if (!token) { - throw new Error("CANVAS_TOKEN not in environment"); -} const nextConfig = { - async rewrites() { - return [ - { - source: "/api/canvas/:rest*", - destination: "https://snow.instructure.com/api/v1/:rest*", - }, - ]; - }, }; export default nextConfig; diff --git a/nextjs/src/app/api/canvas/[...rest]/route.ts b/nextjs/src/app/api/canvas/[...rest]/route.ts new file mode 100644 index 0000000..63ce029 --- /dev/null +++ b/nextjs/src/app/api/canvas/[...rest]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { axiosClient } from "@/services/axiosUtils"; + +export async function GET( + req: NextRequest, + { params }: { params: { rest: string[] } } +) { + const { rest } = params; + const path = rest.join("/"); + + try { + const newUrl = `https://snow.instructure.com/api/v1/${path}`; + const response = await axiosClient.get(newUrl, { + headers: { + // Include other headers from the incoming request if needed: + // 'Content-Type': req.headers.get('content-type') || 'application/json', + "Content-Type": "application/json", + }, + }); + + return NextResponse.json(response.data); + } catch (error: any) { + return new NextResponse( + JSON.stringify({ error: error.message || "Request failed" }), + { status: error.response?.status || 500 } + ); + } +} diff --git a/nextjs/src/services/axiosUtils.ts b/nextjs/src/services/axiosUtils.ts index b6abd37..da7bfb4 100644 --- a/nextjs/src/services/axiosUtils.ts +++ b/nextjs/src/services/axiosUtils.ts @@ -2,38 +2,30 @@ import { isServer } from "@tanstack/react-query"; import axios, { AxiosInstance, AxiosError, AxiosHeaders } from "axios"; import toast from "react-hot-toast"; -const token = process.env.NEXT_PUBLIC_CANVAS_TOKEN; -if (!token) { - throw new Error("NEXT_PUBLIC_CANVAS_TOKEN not in environment"); -} +const canvasBaseUrl = "https://snow.instructure.com/api/v1/"; export const axiosClient: AxiosInstance = axios.create(); if (!isServer) { axiosClient.interceptors.request.use((config) => { - if ( - config.url && - config.url.startsWith("https://snow.instructure.com/api/v1/") - ) { - const newUrl = config.url.replace( - "https://snow.instructure.com/api/v1/", - "/api/canvas/" - ); + if (config.url && config.url.startsWith(canvasBaseUrl)) { + const newUrl = config.url.replace(canvasBaseUrl, "/api/canvas/"); config.url = newUrl; } return config; }); -} - -axiosClient.interceptors.request.use((config) => { - if ( - config.url && - config.url.startsWith("https://snow.instructure.com/api/v1/") - ) { - config.headers.set("Authorization", `Bearer ${token}`); +} else { + const token = process.env.CANVAS_TOKEN; + if (!token) { + throw new Error("CANVAS_TOKEN not in environment"); } - return config; -}); + axiosClient.interceptors.request.use((config) => { + if (config.url && config.url.startsWith(canvasBaseUrl)) { + config.headers.set("Authorization", `Bearer ${token}`); + } + return config; + }); +} axiosClient.interceptors.response.use( (response) => response, diff --git a/nextjs/src/services/canvas/canvasPageService.ts b/nextjs/src/services/canvas/canvasPageService.ts index 5939a46..8dd0920 100644 --- a/nextjs/src/services/canvas/canvasPageService.ts +++ b/nextjs/src/services/canvas/canvasPageService.ts @@ -1,7 +1,6 @@ import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { canvasServiceUtils } from "./canvasServiceUtils"; -import { webRequestor } from "./webRequestor"; const baseCanvasUrl = "https://snow.instructure.com/api/v1"; diff --git a/nextjs/src/services/canvas/canvasService.ts b/nextjs/src/services/canvas/canvasService.ts index 4c53447..81cc922 100644 --- a/nextjs/src/services/canvas/canvasService.ts +++ b/nextjs/src/services/canvas/canvasService.ts @@ -1,10 +1,10 @@ import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; import { canvasServiceUtils } from "./canvasServiceUtils"; -import { webRequestor } from "./webRequestor"; import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; import { CanvasModuleItem } from "@/models/canvas/modules/canvasModuleItems"; import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; import { CanvasEnrollmentModel } from "@/models/canvas/enrollments/canvasEnrollmentModel"; +import { axiosClient } from "../axiosUtils"; const getTerms = async () => { const url = `accounts/10/terms`; @@ -28,7 +28,7 @@ export const canvasService = { async getCourse(courseId: number): Promise { const url = `courses/${courseId}`; - const { data, response } = await webRequestor.get(url); + const { data } = await axiosClient.get(url); return data; }, @@ -62,8 +62,7 @@ export const canvasService = { const body = { module_item: { title: item.title, position: item.position }, }; - const { data, response } = - await webRequestor.putWithDeserialize(url, body); + const { data } = await axiosClient.put(url, body); if (!data) throw new Error("Something went wrong updating module item"); }, @@ -78,10 +77,7 @@ export const canvasService = { console.log(`Creating new module item ${title}`); const url = `courses/${canvasCourseId}/modules/${canvasModuleId}/items`; const body = { module_item: { title, type, content_id: contentId } }; - const response = await webRequestor.post(url, body); - - if (!response.ok) - throw new Error("Something went wrong creating module item"); + const response = await axiosClient.post(url, body); }, async createPageModuleItem( @@ -95,18 +91,13 @@ export const canvasService = { const body = { module_item: { title, type: "Page", page_url: canvasPage.url }, }; - const { data, response } = - await webRequestor.postWithDeserialize(url, body); - - if (!response.ok) - throw new Error("Something went wrong creating page module item"); + await axiosClient.post(url, body); }, async getEnrolledStudents(canvasCourseId: number) { console.log(`Getting students for course ${canvasCourseId}`); const url = `courses/${canvasCourseId}/enrollments?enrollment_type=student`; - const { data, response } = - await webRequestor.getMany(url); + const { data } = await axiosClient.get(url); if (!data) throw new Error( diff --git a/nextjs/src/services/canvas/canvasServiceUtils.ts b/nextjs/src/services/canvas/canvasServiceUtils.ts index c54adf6..d418009 100644 --- a/nextjs/src/services/canvas/canvasServiceUtils.ts +++ b/nextjs/src/services/canvas/canvasServiceUtils.ts @@ -1,7 +1,7 @@ // services/canvasServiceUtils.ts import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios"; -import { webRequestor } from "./webRequestor"; +import { axiosClient } from "../axiosUtils"; const getNextUrl = ( headers: AxiosResponseHeaders | RawAxiosResponseHeaders @@ -28,19 +28,20 @@ export const canvasServiceUtils = { const url = new URL(request.url); url.searchParams.set("per_page", "100"); - const { data: firstData, response: firstResponse } = - await webRequestor.get(url.toString()); + const { data: firstData, headers: firstHeaders } = await axiosClient.get( + url.toString() + ); var returnData: T[] = firstData ? [firstData] : []; - var nextUrl = getNextUrl(firstResponse.headers); + var nextUrl = getNextUrl(firstHeaders); while (nextUrl) { requestCount += 1; - const { data, response } = await webRequestor.get(nextUrl); + const { data, headers } = await axiosClient.get(nextUrl); if (data) { returnData = [...returnData, data]; } - nextUrl = getNextUrl(response.headers); + nextUrl = getNextUrl(headers); } if (requestCount > 1) { diff --git a/nextjs/src/services/canvas/canvasWebRequestor.ts b/nextjs/src/services/canvas/canvasWebRequestor.ts new file mode 100644 index 0000000..5fa7d83 --- /dev/null +++ b/nextjs/src/services/canvas/canvasWebRequestor.ts @@ -0,0 +1,66 @@ +import { AxiosResponse } from "axios"; +import { axiosClient } from "../axiosUtils"; + +type FetchOptions = Omit; + +const rateLimitRetryCount = 6; +const rateLimitSleepInterval = 1000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const isRateLimited = async ( + response: AxiosResponse +): Promise => { + const content = await response.data; + return ( + response.status === 403 && + content.includes("403 Forbidden (Rate Limit Exceeded)") + ); +}; + +const rateLimitAwarePost = async (url: string, body: any, retryCount = 0) => { + const response = await axiosClient.post(url, body); + + if (await isRateLimited(response)) { + if (retryCount < rateLimitRetryCount) { + console.info( + `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` + ); + await sleep(rateLimitSleepInterval); + return await rateLimitAwarePost(url, body, retryCount + 1); + } + } + + return response; +}; + +export const rateLimitAwareDelete = async ( + url: string, + retryCount = 0 +): Promise => { + try { + const response = await axiosClient.delete(url); + + if (await isRateLimited(response)) { + console.info("After delete response in rate limited"); + await sleep(rateLimitSleepInterval); + return await rateLimitAwareDelete(url, retryCount + 1); + } + } catch (e) { + const error = e as Error & { response?: Response }; + if (error.response?.status === 403) { + if (retryCount < rateLimitRetryCount) { + console.info( + `Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` + ); + await sleep(rateLimitSleepInterval); + return await rateLimitAwareDelete(url, retryCount + 1); + } else { + console.info( + `Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it` + ); + } + } + throw e; + } +}; diff --git a/nextjs/src/services/canvas/webRequestor.ts b/nextjs/src/services/canvas/webRequestor.ts deleted file mode 100644 index e6b7f82..0000000 --- a/nextjs/src/services/canvas/webRequestor.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { axiosClient } from "../axiosUtils"; - -type FetchOptions = Omit; - -const rateLimitRetryCount = 6; -const rateLimitSleepInterval = 1000; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -const isRateLimited = async (response: Response): Promise => { - const content = await response.text(); - return ( - response.status === 403 && - content.includes("403 Forbidden (Rate Limit Exceeded)") - ); -}; - -const deserialize = async (response: Response): Promise => { - if (!response.ok) { - console.error(`Error with response to ${response.url} ${response.status}`); - throw new Error( - `Error with response to ${response.url} ${response.status}` - ); - } - try { - return (await response.json()) as T; - } catch (e) { - console.error(`An error occurred during deserialization: ${e}`); - throw e; - } -}; - -const rateLimitAwarePost = async ( - url: string, - body: any, - retryCount = 0 -): Promise => { - const response = await fetch(url, { - method: "POST", - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json", - }, - }); - - if (await isRateLimited(response)) { - if (retryCount < rateLimitRetryCount) { - console.info( - `Hit rate limit on post, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` - ); - await sleep(rateLimitSleepInterval); - return await rateLimitAwarePost(url, body, retryCount + 1); - } - } - - if (!response.ok) { - const content = await response.text(); - console.error(`Error with response, response content: ${content}`); - throw new Error( - `Error post response, retrycount: ${retryCount}, ratelimited: ${await isRateLimited( - response - )}, code: ${response.status}, response content: ${content}` - ); - } - return response; -}; - -const recursiveDelete = async ( - url: string, - options: FetchOptions, - retryCount = 0 -): Promise => { - try { - const response = await fetch(url, { - ...options, - method: "DELETE", - headers: { - }, - }); - - if (await isRateLimited(response)) { - console.info("After delete response in rate limited"); - await sleep(rateLimitSleepInterval); - return await recursiveDelete(url, options, retryCount + 1); - } - - return response; - } catch (e) { - const error = e as Error & { response?: Response }; - if (error.response?.status === 403) { - if (retryCount < rateLimitRetryCount) { - console.info( - `Hit rate limit in delete, retry count is ${retryCount} / ${rateLimitRetryCount}, retrying` - ); - await sleep(rateLimitSleepInterval); - return await recursiveDelete(url, options, retryCount + 1); - } else { - console.info( - `Hit rate limit in delete, ${rateLimitRetryCount} retries did not fix it` - ); - } - } - throw e; - } -}; -export const webRequestor = { - getMany: async (url: string, options: FetchOptions = {}) => { - const response = await axiosClient.get(url); - return { data: response.data, response }; - }, - - get: async (url: string) => { - const response = await axiosClient.get(url); - return { data: response.data, response }; - }, - - post: async (url: string, body: any) => { - return await rateLimitAwarePost(url, body); - }, - - postWithDeserialize: async (url: string, body: any) => { - const response = await rateLimitAwarePost(url, body); - return { data: await deserialize(response), response }; - }, - - put: async (url: string, body: any = {}) => { - const response = await fetch(url, { - method: "PUT", - body: JSON.stringify(body), - headers: { - "Content-Type": "application/json", - }, - }); - return response; - }, - - putWithDeserialize: async (url: string, body: any = {}) => { - const response = await fetch(url, { - body: JSON.stringify(body), - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - }); - return { data: await deserialize(response), response }; - }, - - delete: async ( - url: string, - options: FetchOptions = {} - ): Promise => { - return await recursiveDelete(url, options); - }, -};