Compare commits

...
20 changed files with 312 additions and 419 deletions
+6 -38
View File
@@ -11,7 +11,6 @@
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
@@ -2388,19 +2387,9 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2658,6 +2647,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -2818,6 +2808,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -2958,6 +2949,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3160,30 +3152,11 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -3448,6 +3421,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4440,12 +4414,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1 -2
View File
@@ -2,7 +2,7 @@
"name": "backend",
"private": true,
"scripts": {
"build": "tsc",
"build": "tsc -p .",
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
},
"dependencies": {
@@ -11,7 +11,6 @@
"@sentry/profiling-node": "^10.43.0",
"@types/lodash-es": "^4.17.12",
"@types/pg": "^8.18.0",
"axios": "^1.9.0",
"bullmq": "^5.53.2",
"compression": "^1.8.0",
"cors": "^2.8.5",
+18 -10
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
import { Router } from 'express'
import { Release } from '@oodikone/shared/types'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isDev, isTest } from '../config'
const router = Router()
@@ -39,16 +39,24 @@ router.get<never, Release[]>('/', async (_, res) => {
return res.status(200).json(fakeRelease)
}
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
const { data } = await Fetchios.get<Record<string, any>[]>(
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
{}
)
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
if (data) {
const releasesFromAPI: Release[] = data.map(release => ({
description: release.body,
time: release.published_at,
title: release.name,
version: release.tag_name,
}))
changelog.data = releasesFromAPI
res.status(200).json(releasesFromAPI)
}
res.status(500).send()
})
export default router
@@ -46,7 +46,7 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
// the students have enrolled to their courses in last 8 months
// (acual logic a bit different, see Importer)
const { data: teacherRightsToStudents, error } = importerClient
? await tryCatch<{ data: string[] }>(importerClient.post('/teacher-rights/', { teacherId, studentNumbers }))
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
: { data: null, error: null }
if (error) {
+1 -1
View File
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
programmes: refreshProgrammesByList,
}
if (typeof typeToJob[type] !== 'function') {
return res.status(400).json('Bad job type')
return res.status(400).json('Updater does not recognize this job type: ' + type)
}
const response = await typeToJob[type](list)
if (response) {
+4 -5
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { isProduction, pateToken } from '../config'
import { FormattedUser } from '../types'
import logger from '../util/logger'
const pateClient = axios.create({
baseURL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
const pateClient = Fetchios.create({
baseUrl: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/pate/',
params: {
token: pateToken,
},
@@ -24,7 +23,7 @@ const baseSettings = {
const sendEmail = async (options = {}) => {
if (!isProduction) return logger.error('Email sending is disabled in development mode.')
if (!pateToken) return logger.error('Email sending failed because pate token is missing.')
return await pateClient.post('/', options)
return await pateClient.post('', options, {})
}
export const sendFeedback = async ({ feedbackContent, user }: { feedbackContent: string; user: FormattedUser }) => {
+28 -42
View File
@@ -1,44 +1,45 @@
const axios = require('axios')
import { Fetchios } from '@oodikone/shared/util/fetchios'
const { sisUrl, sisGrapqlAppAccount, sisGrapqlAppKey } = require('../config')
const { ApplicationError } = require('../util/customErrors')
const getSisuAccessToken = async eppn => {
export const getSisuAccessToken = async eppn => {
const oriAppAuthPath = `${sisUrl}/ori/application-auth`
let sisuResponse
try {
sisuResponse = await axios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
} catch (error) {
const { data: sisuResponse, error } = await Fetchios.post(
oriAppAuthPath,
{
username: sisGrapqlAppAccount,
key: sisGrapqlAppKey,
executingEppn: eppn,
},
{ 'content-type': 'application/json' }
)
if (error) {
throw new ApplicationError(
'Could not find the user in Sisu, either with the eppn of the user or the new person.',
404
)
}
return sisuResponse.data.authToken
return sisuResponse.authToken
}
const decodeJwtTokenPayloadToObject = token => {
export const decodeJwtTokenPayloadToObject = token => {
const payload = token.split('.')[1]
const paddedPayload = `${payload}==`
const bufferObj = Buffer.from(paddedPayload, 'base64')
return JSON.parse(bufferObj.toString('utf-8'))
}
const getSisuAuthData = async eppn => {
export const getSisuAuthData = async eppn => {
const accessToken = await getSisuAccessToken(eppn)
const tokenData = decodeJwtTokenPayloadToObject(accessToken)
return { accessToken, tokenData }
}
const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
export const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
private_person(id: $subjectUserId) {
id
first_name:firstNames
@@ -52,32 +53,17 @@ const personSearchQuery = `query privatePerson($subjectUserId: ID!) {
}
}`
const getGraphqlData = async (accessToken, queryObject) => {
const graphQlUrl = `${sisUrl}/api`
const authHeader = `Application ${accessToken}`
const config = {
url: graphQlUrl,
method: 'post',
headers: {
Authorization: authHeader,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
data: queryObject,
}
let graphQlResponse
export const getGraphqlData = async (accessToken, queryObject) => {
try {
graphQlResponse = await axios(config)
const graphQlResponse = await Fetchios.post(`${sisUrl}/api`, queryObject, {
headers: {
Authorization: `Application ${accessToken}`,
'Accept-Charset': 'UTF-8',
'Content-Type': 'application/json',
},
})
return graphQlResponse.data.data.private_person
} catch (error) {
throw new ApplicationError('Graphql request failed for unclear reasons.', 500)
}
return graphQlResponse.data.data.private_person
}
module.exports = {
getSisuAccessToken,
decodeJwtTokenPayloadToObject,
getSisuAuthData,
personSearchQuery,
getGraphqlData,
}
@@ -1,46 +1,48 @@
import axios from 'axios'
import { Op } from 'sequelize'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { SECRET_TOKEN, SIS_UPDATER_URL } from '../config'
import { StudyplanModel } from '../models'
const client = axios.create({ baseURL: SIS_UPDATER_URL })
const params = { params: { token: SECRET_TOKEN } }
const client = Fetchios.create({
baseUrl: SIS_UPDATER_URL,
params: { token: SECRET_TOKEN ?? '' },
})
export const abortUpdate = async () => {
const response = await client.get('/v1/abort', params)
const response = await client.get<{ message: string }>('/v1/abort', {})
return response.data?.message
}
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
const data = { coursecodes }
const response = await client.post('/v1/courses', data, params)
const response = await client.post('/v1/courses', data, {})
return response.data
}
export const updateSISMetadata = async () => {
const response = await client.get('/v1/meta', params)
const response = await client.get('/v1/meta', {})
return response.data
}
export const updateSISProgrammes = async () => {
const response = await client.get('/v1/programmes', params)
const response = await client.get('/v1/programmes', {})
return response.data
}
export const updateSISRedisCache = async () => {
const response = await client.get('/v1/rediscache', params)
const response = await client.get('/v1/rediscache', {})
return response.data
}
export const updateSISStudents = async () => {
const response = await client.get('/v1/students', params)
const response = await client.get('/v1/students', {})
return response.data
}
export const updateSISStudentsByStudentNumber = async (studentnumbers: string[]) => {
const data = { studentnumbers }
const response = await client.post('/v1/students', data, params)
const response = await client.post('/v1/students', data, {})
return response.data
}
@@ -54,6 +56,6 @@ export const updateSISStudyPlans = async (days: number) => {
limit: 1000,
})
const studentnumbers = result.map(r => r.studentnumber)
const response = await client.post('v1/studyplans', { studentnumbers }, params)
const response = await client.post('v1/studyplans', { studentnumbers }, {})
return response.data
}
@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/node'
import { AxiosError } from 'axios'
import { uniq } from 'lodash-es'
import type { StudyGuidanceGroup, Tags, TagsByGroupId, GroupsWithTags } from '@oodikone/shared/types/studyGuidanceGroup'
@@ -12,22 +11,23 @@ const importerClient = getImporterClient()
const getGroupsFromImporter = async (sisPersonId: string): Promise<StudyGuidanceGroup[]> => {
if (!importerClient || !sisPersonId) return []
try {
const response: { data: Record<string, StudyGuidanceGroup> } = await importerClient.get(
`/person-groups/person/${sisPersonId}`
)
const { data, error } = await importerClient.get<Record<string, StudyGuidanceGroup>>(
`/person-groups/person/${sisPersonId}`,
{}
)
return response?.data ? Object.values(response.data) : []
} catch (error) {
if (error) {
logger.error('Could not fetch study guidance groups!')
if (error instanceof AxiosError) {
if (error instanceof Error) {
logger.error(error.stack)
logger.error(JSON.stringify(error.response?.data))
logger.error(JSON.stringify(error.message))
Sentry.captureException(error)
}
return []
}
return []
return data ? Object.values(data) : []
}
export const getAllGroupsAndStudents = async (sisPersonId: string) => {
+3 -4
View File
@@ -1,11 +1,10 @@
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, importerUrl } from '../config'
import logger from './logger'
const importerClient = axios.create({
const importerClient = Fetchios.create({
headers: { token: importerToken },
baseURL: importerUrl,
baseUrl: importerUrl,
timeout: 10000,
})
+22 -17
View File
@@ -1,12 +1,12 @@
import * as Sentry from '@sentry/node'
import axios from 'axios'
import { Fetchios } from '@oodikone/shared/util/fetchios'
import { importerToken, isStaging, jamiUrl } from '../config'
import { IamAccess } from '../types'
import logger from './logger'
const jamiClient = axios.create({
baseURL: jamiUrl,
const jamiClient = Fetchios.create({
baseUrl: jamiUrl,
params: {
token: importerToken,
},
@@ -24,29 +24,34 @@ export const getUserIamAccess = async (
): Promise<UserIamAccess> => {
if (iamGroups.length === 0) return {}
try {
const { data } = await jamiClient.post('/', {
const { data, error } = await jamiClient.post<Pick<UserIamAccess, 'specialGroup'>>(
'',
{
userId: sisPersonId,
getSisuAccess: !isStaging && !!sisPersonId,
iamGroups,
})
},
{}
)
if (error === null) {
const { specialGroup, ...resetOfIamAccess } = data
return { iamAccess: resetOfIamAccess, specialGroup }
} catch (error) {
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
if (attempt > 3) {
logger.error('[Jami] error: ', error)
Sentry.captureException(error)
return {}
}
return getUserIamAccess(sisPersonId, iamGroups, attempt + 1)
}
export const getUserIams = async (userId: string) => {
try {
const { data } = await jamiClient.get(`/${userId}`)
const { data } = await jamiClient.get<any>(userId, {})
return data.iamGroups as string[]
} catch (error: any) {
if (error.response.status !== 404) {
@@ -65,12 +70,12 @@ type Access = {
}
export const getAllUserAccess = async (userIds: string[]) => {
const { data } = await jamiClient.post('access-and-special-groups', { userIds })
const { data } = await jamiClient.post('access-and-special-groups', { userIds }, {})
return data as Access[]
}
jamiClient
.get('/ping', { timeout: 4000 })
.get('ping', { timeout: 4000 })
.then(() => {
logger.info('JAMI connected')
})
+2 -1
View File
@@ -17,7 +17,8 @@
"sourceMap": true,
"sourceRoot": "/",
"inlineSources": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["index.ts", "src", "tests"],
"exclude": ["../frontend"]
-259
View File
@@ -17,7 +17,6 @@
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"lodash-es": "^4.17.23",
@@ -2257,23 +2256,6 @@
"vite": "^4 || ^5 || ^6 || ^7"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@@ -2299,19 +2281,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2380,18 +2349,6 @@
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2484,15 +2441,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -2525,20 +2473,6 @@
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -2548,51 +2482,6 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -2687,42 +2576,6 @@
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2747,82 +2600,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -3144,15 +2921,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -3748,27 +3516,6 @@
],
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3948,12 +3695,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
-1
View File
@@ -20,7 +20,6 @@
"@sentry/browser": "^10.43.0",
"@tanstack/react-table": "^8.21.3",
"@types/lodash-es": "^4.17.12",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"highcharts": "^11.4.8",
"lodash-es": "^4.17.23",
+32 -15
View File
@@ -1,12 +1,12 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import axios, { AxiosProgressEvent } from 'axios'
import { showAsUserKey } from '@/common'
import { apiBasePath, isDev } from '@/conf'
import { formatToArray } from '@oodikone/shared/util'
import { Fetchios } from '@oodikone/shared/util/fetchios'
// Set up dev user for development environment, mimicking production admin user
const baseHeaders = isDev
const baseHeaders: Record<string, string> = isDev
? {
uid: 'mluukkai',
displayName: 'Matti Luukkainen',
@@ -29,33 +29,50 @@ const getHeaders = () => {
return headers
}
const api = axios.create({ baseURL: apiBasePath, headers: getHeaders(), timeout: 120_000 })
const api = Fetchios.create({ baseUrl: apiBasePath })
type ApiMethods = 'get' | 'post' | 'put' | 'delete'
/**
* Calls API with minimal preprocessing.
* NOTE! `data` -attribute has to be JSON.stringified in-order for the predefined
* 'Content-Type' to work and the receiving Express instance to parse the body correctly.
*/
export const callApi = async (
url,
method = 'get',
data,
params = {},
timeout = 0,
progressCallback?: (progress: number) => void
url: string,
method: ApiMethods = 'get',
data?: any,
params: Record<string, any> = {},
timeout = 30_000
) => {
const options = { headers: getHeaders(), timeout }
const apiRequestController = new AbortController()
const signal = timeout !== 0 ? apiRequestController.signal : undefined
const onDownloadProgress = ({ loaded, total }: AxiosProgressEvent) => {
if (progressCallback) progressCallback(Math.min(100, Math.round((loaded / (total ?? 1)) * 100)))
const options = {
params,
signal,
headers: {
'Content-Type': 'application/json',
...getHeaders(),
},
}
// const buildUrl = () => e{
// const urlParams = new URLSearchParams(params)
// return apiBasePath + url + urlParams.toString()
// }
switch (method) {
case 'get':
return api.get(url, { ...options, params, onDownloadProgress })
return api.get(url, options)
case 'post':
return api.post(url, data, options)
case 'put':
return api.put(url, data, options)
case 'delete':
return api.delete(url, { headers: options.headers, data })
return api.delete(url, options)
default:
return Promise.reject(new Error('Invalid http method'))
throw new Error('Invalid method used for API call.')
}
}
+18
View File
@@ -6,9 +6,20 @@
"": {
"name": "@oodikone/shared",
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -22,6 +33,13 @@
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -5,6 +5,7 @@
"build": "tsc"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.9.2"
}
}
+2 -1
View File
@@ -17,6 +17,7 @@
"sourceMap": true,
"sourceRoot": "/",
"inlineSources": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"types": ["node"]
}
}
+149
View File
@@ -0,0 +1,149 @@
import type { Result } from '../util'
type Params = Record<string, string> | string
// eslint-disable-next-line import-x/no-unused-modules
export type BaseConfig = { baseUrl?: string; params?: Params; timeout?: number } & RequestInit
// eslint-disable-next-line import-x/no-unused-modules
export type RequestConfig = { params?: Params; timeout?: number } & RequestInit
const buildBaseUrl = (baseUrl: string) => {
try {
return new URL(baseUrl)
} catch (_) {
// Invalid URLs will be caught here if the baseUrl cannot be extended on top of page origin.
return new URL(baseUrl, globalThis?.location.origin)
}
}
const buildUrl = (
baseUrl: string | undefined = '',
url: string | undefined = '',
baseParams?: Params,
params?: Params
) => {
const base = baseUrl ? buildBaseUrl(url ? baseUrl.replace(/\/?$/, '/') : baseUrl) : ''
const path = new URL(url.replace(/^\//, ''), base)
const baseUrlParams = new URLSearchParams(baseParams)
const urlParams = new URLSearchParams(params)
Array.from(baseUrlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
Array.from(urlParams.entries()).forEach(([key, val]) => path.searchParams.set(key, val))
return path.href
}
const buildSignal = (timeout: number | undefined): AbortSignal | undefined =>
timeout !== undefined ? AbortSignal.timeout(timeout) : undefined
const prepData = <T>(data: T) => JSON.stringify(data)
// We only really use JSON in Oodikone, so I didn't fell like implementing this function.
const getMimeType = <T>(_: T) => {
return 'application/json'
}
const convertDataBasedOnCT = <R>(contentType: string, res: Response): Promise<R> => {
// Should be the most common type, as express and JAMI return JSON
if (contentType.startsWith('application/json')) return res.json() as Promise<R>
else if (contentType.startsWith('application/octet-stream')) return res.arrayBuffer() as Promise<R>
else if (contentType.startsWith('multipart/form-data')) return res.formData() as Promise<R>
else if (contentType.startsWith('text/')) return res.text() as Promise<R>
else return res.blob() as Promise<R>
}
const handleRequestData = <R>(url: string, options: RequestInit): Promise<Result<R>> =>
globalThis
.fetch(url, options)
.then(async res => {
const ct = res.headers.get('content-type') ?? ''
return {
...res,
data: await convertDataBasedOnCT<R>(ct, res),
error: null,
}
})
.catch(error => ({
data: null,
error,
}))
const fetcher = <T>({
baseUrl,
params: baseParams,
timeout: baseTimeout,
headers: baseHeaders,
...baseRest
}: BaseConfig) => ({
get: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
...baseHeaders,
...headers,
},
method: 'GET',
...baseRest,
...rest,
}),
post: <R>(
url: string | undefined,
data: T,
{ params, headers, timeout, ...rest }: RequestConfig
): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
'Content-Type': getMimeType(data),
...baseHeaders,
...headers,
},
method: 'POST',
body: prepData(data),
...baseRest,
...rest,
}),
put: <R>(
url: string | undefined,
data: T,
{ params, headers, timeout, ...rest }: RequestConfig
): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
'Content-Type': getMimeType(data),
...baseHeaders,
...headers,
},
method: 'PUT',
body: prepData(data),
...baseRest,
...rest,
}),
delete: <R>(url: string | undefined, { params, headers, timeout, ...rest }: RequestConfig): Promise<Result<R>> =>
handleRequestData(buildUrl(baseUrl, url, baseParams, params), {
signal: buildSignal(timeout ?? baseTimeout),
headers: {
...baseHeaders,
...headers,
},
method: 'DELETE',
...baseRest,
...rest,
}),
})
export const Fetchios = {
create: <R>(config: BaseConfig) => fetcher<R>(config),
get: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
fetcher<R>({ baseUrl: url }).get(undefined, config),
post: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
fetcher<T>({ baseUrl: url }).post<R>(undefined, data, config),
put: <R, T = unknown>(url: string | undefined, data: T, config: RequestConfig): Promise<Result<R>> =>
fetcher<T>({ baseUrl: url }).put<R>(undefined, data, config),
delete: <R>(url: string | undefined, config: RequestConfig): Promise<Result<R>> =>
fetcher<R>({ baseUrl: url }).delete(undefined, config),
}
+1 -1
View File
@@ -41,7 +41,7 @@ type Err<E> = {
error: E
}
type Result<T, E = Error> = Ok<T> | Err<E>
export type Result<T, E = Error> = Ok<T> | Err<E>
/**
* @returns data, error object with distinct values.