Refactoring and fix landsbankinn graphql API

This commit is contained in:
projectmoon 2023-03-21 13:09:34 +01:00
parent 10dc0401be
commit 505ca314ba
11 changed files with 258 additions and 213 deletions

View File

@ -5,45 +5,46 @@ import { PartialStateUpdate, State } from './state';
type HttpHeaders = browser.webRequest.HttpHeaders; type HttpHeaders = browser.webRequest.HttpHeaders;
function extractFromHeaders(headers: HttpHeaders): PartialStateUpdate { function extractAuthStateFromHeaders(headers: HttpHeaders): PartialStateUpdate {
let apiKey = ''; let apiKey = '';
let gqlAuthToken = ''; let authToken = '';
let rsaFingerprint = ''; let fingerprintValue = '';
for (const header of headers) { for (const header of headers) {
switch (header.name.toLowerCase()) { switch (header.name.toLowerCase()) {
case "authorization": case "authorization":
gqlAuthToken = header.value || ''; authToken = header.value || '';
break; break;
case "apikey": case "apikey":
apiKey = header.value || ''; apiKey = header.value || '';
break; break;
case "rsa-fingerprint": case "fingerprintvalue":
rsaFingerprint = header.value || ''; fingerprintValue = header.value || '';
break; break;
} }
} }
return { apiKey, authToken, fingerprintValue };
return { apiKey, gqlAuthToken, rsaFingerprint };
} }
browser.webRequest.onBeforeSendHeaders.addListener(e => { browser.webRequest.onBeforeSendHeaders.addListener(e => {
// TODO if request errors, then clear state and try again. // TODO if request errors, then clear state and try again.
(async function() { (async function() {
const state = await State.current; let state = await State.current;
if (!state.ready && e.requestHeaders) { if (!state.ready && e.requestHeaders) {
console.info('Will update auth info from headers'); console.info('Will update auth info from headers');
// Lock ready to true so we don't have multiple attempts // Lock ready to true so we don't have multiple attempts
// at getting account info. // at getting account info.
await State.update({ ready: true }); await State.update({ ready: true });
let newState = extractFromHeaders(e.requestHeaders); let newState = extractAuthStateFromHeaders(e.requestHeaders);
state = await State.update(newState);
State.update(newState) if (!newState.accounts) {
.then(state => !state.accounts ? fetchAccounts() : state.accounts) const accounts = await fetchAccounts(state);
.then(accounts => State.update({ accounts })) await State.update({ accounts });
.then(() => console.log('done')); console.info('Acquired account data');
}
} }
})(); })();
}, matchGraphQL, ["requestHeaders"]); }, matchGraphQL, ["requestHeaders"]);

View File

@ -1,39 +0,0 @@
import { gql } from 'graphql-request/build/esm';
// Currently not imported due to a bug with Parcel and the bundle-text
// importer, and complications with running the query from the
// imported text.
// Exports
export const fetchAccounts = gql`
query fetchAccounts($first: Int, $after: String, $filter: AccountFilterArgs, $orderBy: AccountOrderBy) {
viewer {
id
accounts(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
nodes {
...AccountSelectorAccountParts
__typename
}
totalCount
}
}
}
fragment AccountSelectorAccountParts on Account {
id
accountNumber
name
kennitala
owner
state
isVaxtareikningur30
isNoticeAccount
currency
isCurrencyAccount
availableAmount
balance
orderedAmountAvailable
orderedAmountTotal
__typename
}
`

View File

@ -1,140 +0,0 @@
import { GraphQLClient } from 'graphql-request/build/esm';
import { setDefaultOptions, format } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { RequestConfig } from 'graphql-request/build/esm/types';
import { State } from './state';
import * as queries from './graphql/queries';
import { FetchAccountsResponse } from '~graphql/models';
setDefaultOptions({ locale: enUS });
const apiEndpoint = 'https://netbanki.landsbankinn.is/services/accounts/$accountID/transactions';
export enum TransactionFormat { JSON };
export enum OutputFormat { CSV };
export interface DownloadTransactionsRequest {
accountNumber: string;
dateFrom: Date;
dateTo: Date;
outputFormat: OutputFormat;
}
export interface RawTransaction {
//txn id?
id: string;
//often null
accountNumber: string | null;
amount: Number;
currencyCode: string;
//ISO date
bookingDate: string;
//ISO date
valueDate: string;
balanceAfterTransaction: Number;
displayText: string;
//how do these relate to id?
reference: string;
referenceNumber: string;
//how does it relate to the booking/value dates?
//exact time of txn/
timestamp: string;
textCodeDescription: string;
payerDescription: string;
// e.g. Utteky med debitkorti
description: String;
batchID: string;
// only for individuals?
recipientKennitala: string;
recipientName: string;
//probably some kind of txn category (central bank?)
transactionCodekey: string;
bankBranch: string;
debitCardWarrantyNumber: string | null;
//currency to ISK exchange rate
exchangeRate: Number;
// what is displayed to the user in the table
customerDescription: string;
//don't know what this could be.
details: null;
}
//Sat Jan 03 2023
const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy');
function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) {
const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`;
const from = encodeURIComponent(formatDate(dateFrom));
const to = encodeURIComponent(formatDate(dateTo));
const params = `?from=${from}&to=${to}`
return url + params;
}
export async function downloadTransactions(req: DownloadTransactionsRequest): Promise<Array<RawTransaction>> {
const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo);
const options: RequestInit = {
method: "GET",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
};
const result = await fetch(url, options);
if (result.ok) {
return await result.json();
} else {
//attempt to read json.
const error = await result.json();
if (error.hasOwnProperty('message')) {
console.error(error['message']);
}
console.error(result);
return new Array();
}
}
export async function fetchAccounts(): Promise<FetchAccountsResponse> {
const state = await State.current;
const options: RequestConfig = {
mode: "cors",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
apikey: state.apiKey,
authorization: state.gqlAuthToken,
"rsa-fingerprint": state.rsaFingerprint
},
};
const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options);
const variables = {
"first": 100,
"filter": {
"includeClosed": true
},
};
const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables);
//TODO transform janky graphql response into nicer data structure for us.
return data;
}

View File

@ -0,0 +1,51 @@
import { GraphQLClient } from 'graphql-request/build/esm';
import { RequestConfig } from 'graphql-request/build/esm/types';
import * as queries from './queries';
import { FetchAccountsResponse } from './models';
import { Account } from '../models';
// The Context interface separates the required authentication
// information from the actual GraphQL API call. Anything that meets
// the shape (i.e. the application auth state) can be passed in.
export interface Context {
apiKey: string;
authToken: string;
fingerprintValue: string;
}
function convertToDomain(resp: FetchAccountsResponse): Array<Account> {
const accounts = [];
for (const edge of resp.viewer.accounts.edges) {
accounts.push({
accountNumber: edge.node.accountNumber,
currency: edge.node.currency,
name: edge.node.name
});
}
return accounts;
}
export async function fetchAccounts(ctx: Context): Promise<Array<Account>> {
const options: RequestConfig = {
mode: "cors",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
apikey: ctx.apiKey,
authorization: ctx.authToken,
"rsa-fingerprint": ctx.fingerprintValue,
"onlinebank-type": "personal" //Todo can it be also business?
},
};
const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options);
const variables = {
"pageSize": 40,
};
const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables);
return convertToDomain(data);
}

View File

@ -8,23 +8,31 @@ export interface Viewer {
} }
export interface Accounts { export interface Accounts {
nodes: Array<Node>; edges: Array<Edge>;
totalCount: number; totalCount: number;
} }
export interface Edge {
//appears to be an id?
cursor: string;
node: Node;
}
export interface Node { export interface Node {
id: string; id: string;
accountNumber: string; idAccount: string;
name: string; name: string;
kennitala: string; accountNumber: string;
owner: string; balance: number;
state: string; availableAmount: number;
currency: string;
accountProductName: string;
overdraftLimit: number;
isVaxtareikningur30: boolean; isVaxtareikningur30: boolean;
isNoticeAccount: boolean; isNoticeAccount: boolean;
currency: string; hasDebitCards: boolean;
isCurrencyAccount: boolean; canBeRegularSavingsAccount: boolean;
availableAmount: number; isWithdrawalAccount: boolean;
balance: number; isOverdraftAllowed: boolean;
orderedAmountAvailable: number; showOnFrontpage: boolean;
orderedAmountTotal: number;
} }

View File

@ -0,0 +1,47 @@
import { gql } from 'graphql-request/build/esm';
// Currently not imported due to a bug with Parcel and the bundle-text
// importer, and complications with running the query from the
// imported text.
// Exports
export const fetchAccounts = gql`
query fetchDomesticAccounts($after: String, $pageSize: Int = 20) {
viewer {
id
accounts(first: $pageSize, after: $after, filter: {includedCurrencies: ["ISK"]}) {
edges {
cursor
node {
id
idAccount
name
accountNumber
balance
availableAmount
currency
accountProductName
overdraftLimit
isVaxtareikningur30
isNoticeAccount
hasDebitCards
canBeRegularSavingsAccount
isWithdrawalAccount
isOverdraftAllowed
showOnFrontpage
__typename
}
__typename
}
totalCount
pageInfo {
hasNextPage
currentPage
__typename
}
__typename
}
__typename
}
}
`

View File

@ -0,0 +1,5 @@
// Simply re-exports the organized code into one unified API.
export * from './netbanki/api';
export * from './netbanki/models';
export * from './graphql/api';
export * from './graphql/models';

View File

@ -0,0 +1,9 @@
// These models are common, unified, domain-specific types for the
// extension. The models under the specific API clients are specific
// to Landsbankinn's own domain.
export interface Account {
accountNumber: string;
name: String;
currency: string;
}

View File

@ -0,0 +1,42 @@
import { setDefaultOptions, format } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { RawTransaction, DownloadTransactionsRequest } from './models';
setDefaultOptions({ locale: enUS });
const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy');
function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) {
const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`;
const from = encodeURIComponent(formatDate(dateFrom));
const to = encodeURIComponent(formatDate(dateTo));
const params = `?from=${from}&to=${to}`
return url + params;
}
export async function downloadTransactions(req: DownloadTransactionsRequest): Promise<Array<RawTransaction>> {
const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo);
const options: RequestInit = {
method: "GET",
credentials: "include",
cache: "no-cache",
referrerPolicy: "strict-origin-when-cross-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
};
const result = await fetch(url, options);
if (result.ok) {
return await result.json();
} else {
//attempt to read error json.
const error = await result.json();
if (error.hasOwnProperty('message')) {
console.error(error['message']);
}
console.error(result);
return new Array();
}
}

View File

@ -0,0 +1,61 @@
export enum TransactionFormat { JSON };
export enum OutputFormat { CSV };
export interface DownloadTransactionsRequest {
accountNumber: string;
dateFrom: Date;
dateTo: Date;
outputFormat: OutputFormat;
}
export interface RawTransaction {
//txn id?
id: string;
//often null
accountNumber: string | null;
amount: Number;
currencyCode: string;
//ISO date
bookingDate: string;
//ISO date
valueDate: string;
balanceAfterTransaction: Number;
displayText: string;
//how do these relate to id?
reference: string;
referenceNumber: string;
//how does it relate to the booking/value dates?
//exact time of txn/
timestamp: string;
textCodeDescription: string;
payerDescription: string;
// e.g. Utteky med debitkorti
description: String;
batchID: string;
// only for individuals?
recipientKennitala: string;
recipientName: string;
//probably some kind of txn category (central bank?)
transactionCodekey: string;
bankBranch: string;
debitCardWarrantyNumber: string | null;
//currency to ISK exchange rate
exchangeRate: Number;
// what is displayed to the user in the table
customerDescription: string;
//don't know what this could be.
details: null;
}

View File

@ -1,23 +1,23 @@
import { Level } from 'level'; import { Level } from 'level';
import { FetchAccountsResponse } from '~graphql/models'; import { Account } from '~landsbankinn/models';
export interface ExporterState { export interface ExporterState {
apiKey: string; apiKey: string;
gqlAuthToken: string; authToken: string;
rsaFingerprint: string; fingerprintValue: string;
accounts: FetchAccountsResponse; accounts: Array<Account>;
ready: boolean; ready: boolean;
} }
export interface PartialStateUpdate { export interface PartialStateUpdate {
apiKey?: string; apiKey?: string;
gqlAuthToken?: string; authToken?: string;
rsaFingerprint?: string; fingerprintValue?: string;
accounts?: FetchAccountsResponse; accounts?: Array<Account>;
ready?: boolean; ready?: boolean;
} }
const FIVE_MINUTES = 5 * 60 * 1000; const FIVE_MINUTES = 0.25 * 60 * 1000;
type LevelError = Error | null | undefined; type LevelError = Error | null | undefined;
@ -63,7 +63,7 @@ class LevelState {
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES); this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES);
} }
async update(stateUpdate: ExporterState | PartialStateUpdate) { async update(stateUpdate: ExporterState | PartialStateUpdate): Promise<ExporterState> {
this.clearTTL(); this.clearTTL();
const currentState: ExporterState = await this.current; const currentState: ExporterState = await this.current;
let newState = { ...currentState, ...stateUpdate }; let newState = { ...currentState, ...stateUpdate };