feat: 🎸 Possibly first working version

This commit is contained in:
Johan Öbrink 2020-12-20 23:02:05 +01:00
parent 084e961965
commit 0e4acba776
16 changed files with 1214 additions and 365 deletions

View File

@ -8,6 +8,7 @@ module.exports = {
extends: [
'airbnb-typescript/base',
],
ignorePatterns: ['*.test.ts'],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",

2
.gitignore vendored
View File

@ -102,3 +102,5 @@ dist
# TernJS port file
.tern-port
run.js

View File

@ -1,3 +1,46 @@
# embedded-api
Since the proxy was blocked (and also deemed a bad idea by some), this is a reboot of the API running in process in the app(s).
## How to use
### Installing
`npm i -S @skolplattformen/embedded-api` or `yarn add @skolplattformen/embedded-api`
### Calling
#### Import and init
```javascript
import init from "@skolplattformen/embedded-api";
const api = init(fetch);
```
#### Login
```javascript
api.on("login", () => {
// keep going
});
const loginStatus = await api.login("YYYYMMDDXXXX");
loginStatus.on("PENDING", console.log("BankID app not yet opened"));
loginStatus.on("USER_SIGN", console.log("BankID app is open"));
loginStatus.on("ERROR", console.log("Something went wrong"));
loginStatus.on(
"OK",
console.log("BankID sign successful. Session will be established.")
);
```
#### Loading data
```javascript
// List children
const children = await api.getChildren();
// Get calendar
const calendar = await api.getCalendar(children[0].id);
```

View File

@ -2,7 +2,7 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'.(ts|tsx)': '<rootDir>/node_modules/ts-jest/preprocessor.js'
'.(ts|tsx)': 'ts-jest'
},
testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$',
moduleFileExtensions: ["ts", "tsx", "js"]

View File

@ -1,6 +0,0 @@
const mockAxios: any = jest.genMockFromModule('axios')
// this is the key to fix the axios.create() undefined error!
mockAxios.create = jest.fn(() => mockAxios)
export default mockAxios

View File

@ -1,11 +1,20 @@
import { AxiosAdapter, AxiosInstance } from 'axios'
import routes from './routes'
import { Child } from './types'
import { CalendarItem, Child, Fetch, RequestInit } from './types'
import { etjanst, child, calendarItem } from './parse'
export const list = (client: AxiosInstance) => async (): Promise<Child[]> => {
export const list = (fetch: Fetch, init?: RequestInit) => async (): Promise<Child[]> => {
const url = routes.children
const response = await client.get(url)
return response.data
const response = await fetch(url, init)
const data = await response.json()
return etjanst(data).map(child)
}
export const details = (client: AxiosAdapter) => async (id: string): Promise<Child> => ({})
export const calendar = (fetch: Fetch, init?:RequestInit) => async (childId: string): Promise<CalendarItem[]> => {
const url = routes.calendar(childId)
const response = await fetch(url, init)
const data = await response.json()
console.log(etjanst(data))
return etjanst(data).map(calendarItem)
}
// export const details = (_fetch: Fetch) => async (_id: string): Promise<Child> => ({})

View File

@ -1,59 +1,43 @@
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { list } from './children'
import { checkStatus, getCookies, login } from './login'
import { EventEmitter } from 'events'
import {
checkStatus, getSessionCookie, login, LoginStatus,
} from './login'
import { CalendarItem, Child, Fetch, RequestInit } from './types'
import { calendar, list } from './children'
const pause = async (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
class Api extends EventEmitter {
private fetch: Fetch
const emitter = new EventEmitter()
const init = () => {
const config: AxiosRequestConfig = {
headers: {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.87 Safari/537.36'
},
maxRedirects: 0,
withCredentials: true
private session?: RequestInit
constructor(fetch: Fetch) {
super()
this.fetch = fetch
}
const client = axios.create(config)
let cookies: any = {}
client.interceptors.request.use((config) => {
console.log('request', config.method, config.url)
config.headers.Cookie = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join('; ')
return config
})
client.interceptors.response.use((response) => {
console.log('response', response.status, response.statusText, response.headers['set-cookie'])
if (response.headers['set-cookie']) {
const setCookies: string[] = response.headers['set-cookie']
setCookies.map((c) => c.split('=')).forEach(([key, value]) => cookies[key] = value)
}
return response
})
async login(personalNumber: string): Promise<LoginStatus> {
const ticket = await login(this.fetch)(personalNumber)
const loginStatus = checkStatus(this.fetch)(ticket)
loginStatus.on('OK', async () => {
const sessionCookie = await getSessionCookie(this.fetch)()
this.session = { headers: { Cookie: sessionCookie } }
return {
...emitter,
login: async (personalNumber: string) => {
const ticket = await login(client)(personalNumber)
await pause(1000)
const check = checkStatus(client)(ticket)
check.on('OK', async () => {
console.log('get cookie')
const newCookies = await getCookies(client)()
cookies = {...cookies, ...newCookies}
console.log(cookies)
this.emit('login')
})
return loginStatus
}
emitter.emit('login')
})
return check
},
getChildren: async () => {
const result = await list(client)()
return result
},
async getChildren(): Promise<Child[]> {
const data = await list(this.fetch, this.session)()
return data
}
async getCalendar(childId: string): Promise<CalendarItem[]> {
const data = await calendar(this.fetch, this.session)(childId)
return data
}
}
export default init
export default function init(fetch: Fetch) {
return new Api(fetch)
}

View File

@ -1,15 +1,18 @@
import axios, { AxiosInstance } from 'axios'
import { login, checkStatus, getCookie } from './login'
jest.mock('axios')
let client: jest.Mocked<AxiosInstance>
import { login, checkStatus, getSessionCookie } from './login'
import { Fetch, Headers, Response } from './types'
describe('login', () => {
let fetch: jest.Mocked<Fetch >
let response: jest.Mocked<Response>
let headers: jest.Mocked<Headers>
beforeEach(() => {
client = axios.create() as jest.Mocked<AxiosInstance>
client.get.mockReset()
client.post.mockReset()
headers = { get: jest.fn() }
response = {
json: jest.fn(),
text: jest.fn(),
headers,
}
fetch = jest.fn().mockResolvedValue(response)
})
describe('#login', () => {
it('returns the correct result', async () => {
@ -19,8 +22,8 @@ describe('login', () => {
order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663',
}
client.post.mockResolvedValue({ data })
const result = await login(client)(personalNumber)
response.json.mockResolvedValue(data)
const result = await login(fetch)(personalNumber)
expect(result).toEqual({ order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663' })
})
@ -29,34 +32,30 @@ describe('login', () => {
const ticket = { order: '5fe57e4c-9ad2-4b52-b794-48adef2f6663' }
it('emits PENDING', (done) => {
client.get.mockResolvedValue({ data: 'PENDING' })
response.text.mockResolvedValue('PENDING')
const check = checkStatus(client)(ticket)
const check = checkStatus(fetch)(ticket)
check.on('PENDING', async () => {
await check.cancel()
done()
})
})
it('retries on PENDING', (done) => {
client.get.mockResolvedValueOnce({ data: 'PENDING' })
client.get.mockResolvedValueOnce({ data: 'OK' })
response.text.mockResolvedValueOnce('PENDING')
response.text.mockResolvedValueOnce('OK')
const check = checkStatus(client)(ticket)
const check = checkStatus(fetch)(ticket)
check.on('OK', () => {
expect(client.get).toHaveBeenCalledTimes(2)
expect(fetch).toHaveBeenCalledTimes(2)
done()
})
})
})
describe('#getCookie', () => {
it('sets cookie as client interceptor', async () => {
client.get.mockResolvedValue({
headers: {
'set-cookie': 'cookie',
},
})
describe('#getSessionCookie', () => {
it('returns session cookie', async () => {
headers.get.mockReturnValue('cookie')
const cookie = await getCookie(client)()
const cookie = await getSessionCookie(fetch)()
expect(cookie).toEqual('cookie')
})

View File

@ -1,12 +1,12 @@
import { EventEmitter } from 'events'
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
import routes from './routes'
import { AuthTicket } from './types'
import { AuthTicket, Fetch } from './types'
export const login = (client: AxiosInstance) => async (personalNumber: string): Promise<AuthTicket> => {
export const login = (fetch: Fetch) => async (personalNumber: string): Promise<AuthTicket> => {
const url = routes.login(personalNumber)
const result = await client.get<AuthTicket>(url)
return { order: result.data.order }
const response = await fetch(url)
const { order } = await response.json()
return { order }
}
/*
@ -21,21 +21,22 @@ export enum LoginEvent {
export class LoginStatus extends EventEmitter {
private url: string
private client: AxiosInstance
private fetch: Fetch
private cancelled: boolean = false
constructor(client: AxiosInstance, url: string) {
constructor(fetch: Fetch, url: string) {
super()
this.client = client
this.fetch = fetch
this.url = url
this.check()
}
async check() {
const status = await this.client.get<string>(this.url)
this.emit(status.data)
if (!this.cancelled && status.data !== 'OK' && status.data !== 'ERROR!') {
const response = await this.fetch(this.url)
const status = await response.text()
this.emit(status)
if (!this.cancelled && status !== 'OK' && status !== 'ERROR!') {
setTimeout(() => this.check(), 1000)
}
}
@ -45,43 +46,13 @@ export class LoginStatus extends EventEmitter {
}
}
export const checkStatus = (client: AxiosInstance) => (ticket: AuthTicket): LoginStatus => {
export const checkStatus = (fetch: Fetch) => (ticket: AuthTicket): LoginStatus => {
const url = routes.loginStatus(ticket.order)
return new LoginStatus(client, url)
return new LoginStatus(fetch, url)
}
const parseCookies = (newCookies: string[]): any => {
return newCookies
.map((c) => c.split('=')).map(([key, val]) => ({[key]: val}))
.reduce((obj1, obj2) => ({...obj1, ...obj2}))
}
export const getCookies = (client: AxiosInstance) => async (url = routes.loginCookie, cookies = {}): Promise<any> => {
try {
const response = await client.get(url)
if (response.headers['set-cookie']) {
cookies = {
...cookies,
...parseCookies(response.headers['set-cookie'])
}
}
return cookies
} catch (err) {
const { response } = err as AxiosError
if (response?.status === 302) {
if (response.headers['set-cookie']) {
cookies = {
...cookies,
...parseCookies(response.headers['set-cookie'])
}
}
if (response.headers.location) {
return getCookies(client)(response.headers.location, cookies)
} else {
return cookies
}
} else {
throw err
}
}
export const getSessionCookie = (fetch: Fetch) => async (): Promise<string> => {
const url = routes.loginCookie
const response = await fetch(url)
return response.headers.get('set-cookie') || ''
}

44
lib/parse.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { etjanst, EtjanstResponse } from "./parse"
describe('parse', () => {
describe('etjanst', () => {
let response: EtjanstResponse
beforeEach(() => {
response = {
Success: true,
Error: null,
Data: [
{
Name: 'Some name',
Id: '42C3997E-D772-423F-9290-6FEEB3CB2DA7',
SDSId: '786E3393-F044-4660-9105-B444DEB289AA',
Status: 'GR',
UserType: 'Student',
SchoolId: 'DE2E1293-0F40-4B91-9D91-1E99355DC257',
SchoolName: null,
GroupId: null,
GroupName: null,
Classes: 'VHsidan_0495CABC-77DB-41D7-824B-8B4D63E50D15;Section_AD1BB3B2-C1EE-4DFE-8209-CB6D42CE23D7;Section_0E67D0BF-594C-4C1B-9291-E753926DCD40;VHsidan_1C94EC54-9798-401C-B973-2454246D95DA',
isSameSDSId: false,
ResultUnitId: null,
ResultUnitName: null,
UnitId: null,
UnitName: null
}
]
}
})
it('returns data on success', () => {
expect(etjanst(response)).toBeInstanceOf(Array)
})
it('throws error on Error', () => {
response.Success = false
response.Error = 'b0rk'
expect(() => etjanst(response)).toThrowError('b0rk')
})
it('camelCases data keys', () => {
const parsed = etjanst(response)
expect(parsed[0].name).toEqual(response.Data[0].Name)
})
})
})

28
lib/parse.ts Normal file
View File

@ -0,0 +1,28 @@
import { CalendarItem, Child } from './types'
const camel = require('camelcase-keys')
export interface EtjanstResponse {
Success: boolean
Error: string|null
Data: any|any[]
}
export const etjanst = (response: EtjanstResponse): any|any[] => {
if (!response.Success) {
throw new Error(response.Error || '')
}
return camel(response.Data, { deep: true })
}
export const child = ({
id, sdsId, name, status, schoolId,
}: any): Child => ({
id, sdsId, name, status, schoolId,
})
export const calendarItem = ({
id, title, description, location, startDate, endDate, allDay,
}: any): CalendarItem => ({
id, title, description, location, startDate, endDate, allDay,
})

View File

@ -8,6 +8,7 @@ const routes = {
loginStatus: (order: string) => `${hosts.login}/NECSadcmbid/authenticate/NECSadcmbid?TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvaGVt&verifyorder=${order}&_=${Date.now()}`,
loginCookie: `${hosts.login}/NECSadcmbid/authenticate/SiteMinderAuthADC?TYPE=33554433&REALMOID=06-42f40edd-0c5b-4dbc-b714-1be1e907f2de&GUID=&SMAUTHREASON=0&METHOD=GET&SMAGENTNAME=IfNE0iMOtzq2TcxFADHylR6rkmFtwzoxRKh5nRMO9NBqIxHrc38jFyt56FASdxk1&TARGET=-SM-HTTPS%3a%2f%2flogin001%2estockholm%2ese%2fNECSadc%2fmbid%2fb64startpage%2ejsp%3fstartpage%3daHR0cHM6Ly9ldGphbnN0LnN0b2NraG9sbS5zZS92YXJkbmFkc2hhdmFyZS9pbmxvZ2dhZDIvR2V0Q2hpbGRyZW4%3d`,
children: `${hosts.etjanst}/vardnadshavare/inloggad2/GetChildren`,
calendar: (childId: string) => `${hosts.etjanst}/vardnadshavare/inloggad2/Calender/GetSchoolCalender?childId=${childId}&rowLimit=50`,
}
export default routes

View File

@ -1,18 +1,25 @@
export interface AuthTicket {
order: string
export interface RequestInit {
headers?: any
method?: string
body?: string
}
/**
* <p>A JWT token that should be used for authorizing requests</p>
* @export
* @interface AuthToken
*/
export interface AuthToken {
/**
* @type {string}
* @memberof AuthToken
*/
token: string;
export interface Headers {
get(name: string): string | null
}
export interface Response {
headers: Headers
text: () => Promise<string>
json: () => Promise<any>
}
export interface Fetch {
(url: string, init?: RequestInit): Promise<Response>
}
export interface AuthTicket {
order: string
}
/**
@ -20,40 +27,12 @@ export interface AuthToken {
* @interface CalendarItem
*/
export interface CalendarItem {
/**
* @type {number}
* @memberof CalendarItem
*/
id?: number;
/**
* @type {string}
* @memberof CalendarItem
*/
title?: string;
/**
* @type {string}
* @memberof CalendarItem
*/
description?: string;
/**
* @type {string}
* @memberof CalendarItem
*/
location?: string;
/**
* @type {Date}
* @memberof CalendarItem
*/
startDate?: string;
/**
* @type {Date}
* @memberof CalendarItem
*/
endDate?: string;
/**
* @type {boolean}
* @memberof CalendarItem
*/
allDay?: boolean;
}
@ -62,10 +41,6 @@ export interface CalendarItem {
* @interface Child
*/
export interface Child {
/**
* @type {string}
* @memberof Child
*/
id?: string;
/**
* <p>Special ID used to access certain subsystems</p>
@ -73,10 +48,6 @@ export interface Child {
* @memberof Child
*/
sdsId?: string;
/**
* @type {string}
* @memberof Child
*/
name?: string;
/**
* <p>F - förskola, GR - grundskola?</p>
@ -84,49 +55,14 @@ export interface Child {
* @memberof Child
*/
status?: string;
/**
* @type {string}
* @memberof Child
*/
schoolId?: string;
}
/**
* @export
* @interface ChildAll
*/
export interface ChildAll {
/**
* @type {Child}
* @memberof ChildAll
*/
child?: Child;
/**
* @type {NewsItem[]}
* @memberof ChildAll
*/
news?: NewsItem[];
/**
* @type {CalendarItem[]}
* @memberof ChildAll
*/
calendar?: CalendarItem[];
/**
* @type {Notification[]}
* @memberof ChildAll
*/
notifications?: Notification[];
}
/**
* @export
* @interface Classmate
*/
export interface Classmate {
/**
* @type {string}
* @memberof Classmate
*/
sisId?: string;
/**
* <p>The name of the class of this classmate</p>
@ -134,20 +70,8 @@ export interface Classmate {
* @memberof Classmate
*/
className?: string;
/**
* @type {string}
* @memberof Classmate
*/
firstname?: string;
/**
* @type {string}
* @memberof Classmate
*/
lastname?: string;
/**
* @type {Guardian[]}
* @memberof Classmate
*/
guardians?: Guardian[];
}
@ -156,30 +80,10 @@ export interface Classmate {
* @interface Guardian
*/
export interface Guardian {
/**
* @type {string}
* @memberof Guardian
*/
email?: string;
/**
* @type {string}
* @memberof Guardian
*/
firstname?: string;
/**
* @type {string}
* @memberof Guardian
*/
lastname?: string;
/**
* @type {string}
* @memberof Guardian
*/
mobile?: string;
/**
* @type {string}
* @memberof Guardian
*/
address?: string;
}
@ -189,40 +93,12 @@ export interface Guardian {
* @interface NewsItem
*/
export interface NewsItem {
/**
* @type {string}
* @memberof NewsItem
*/
id?: string;
/**
* @type {string}
* @memberof NewsItem
*/
header?: string;
/**
* @type {string}
* @memberof NewsItem
*/
intro?: string;
/**
* @type {string}
* @memberof NewsItem
*/
body?: string;
/**
* @type {string}
* @memberof NewsItem
*/
published?: string;
/**
* @type {string}
* @memberof NewsItem
*/
modified?: string;
/**
* @type {string}
* @memberof NewsItem
*/
imageUrl?: string;
}
@ -231,27 +107,11 @@ export interface NewsItem {
* @interface Notification
*/
export interface Notification {
/**
* @type {string}
* @memberof Notification
*/
id?: string;
/**
* @type {string}
* @memberof Notification
*/
sender?: {
name?: string;
};
/**
* @type {Date}
* @memberof Notification
*/
dateCreated?: string;
/**
* @type {string}
* @memberof Notification
*/
message?: string;
/**
* <p>
@ -262,14 +122,6 @@ export interface Notification {
* @memberof Notification
*/
url?: string;
/**
* @type {string}
* @memberof Notification
*/
category?: string;
/**
* @type {string}
* @memberof Notification
*/
messageType?: string;
}

View File

@ -8,19 +8,26 @@
"license": "Apache-2.0",
"private": false,
"scripts": {
"lint": "eslint '*/**/*.{js,ts}' --quiet --fix",
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
"test": "jest",
"build": "tsc"
},
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/jest": "^26.0.19",
"@types/node-fetch": "^2.5.7",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"eslint": "^7.16.0",
"eslint-config-airbnb-typescript": "^12.0.0",
"eslint-plugin-import": "^2.22.1",
"fetch-cookie": "^0.11.0",
"jest": "^26.6.3",
"node-fetch": "^2.6.1",
"ts-jest": "^26.4.4",
"typescript": "^4.1.3"
},
"dependencies": {
"axios": "^0.21.0",
"camelcase-keys": "^6.2.2",
"events": "^3.2.0"
}
}

22
run.js
View File

@ -1,22 +0,0 @@
const init = require('./dist').default
async function run () {
try {
const api = init()
const status = await api.login('197304161511')
status.on('PENDING', () => console.log('PENDING'))
status.on('USER_SIGN', () => console.log('USER_SIGN'))
status.on('ERROR', () => console.error('ERROR'))
status.on('OK', () => console.log('OK'))
api.on('login', async () => {
console.log('Logged in')
const children = await api.getChildren()
console.log(children)
})
} catch (err) {
console.error(err)
}
}
run()

1012
yarn.lock

File diff suppressed because it is too large Load Diff