mirror of
https://github.com/sasjs/adapter.git
synced 2025-12-11 01:14:36 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9defdd1dc | ||
|
|
2e14d4f28c | ||
|
|
96cb77da45 | ||
|
|
1ee07eeecf | ||
|
|
b4c0946883 | ||
|
|
efcf3b273c | ||
|
|
761bf8de38 | ||
|
|
5ccfc18a35 | ||
|
|
92434e48ad | ||
|
|
d3c91e143a | ||
|
|
872e73b5f0 | ||
|
|
af4ad3a7af | ||
|
|
1ff67ed93c | ||
|
|
d2739d1791 | ||
|
|
487cb489f3 | ||
|
|
d9cb2db61f | ||
|
|
35f37ac796 | ||
|
|
d7ad0288b9 |
17
README.md
17
README.md
@@ -20,9 +20,9 @@
|
||||
|
||||
SASjs is a open-source framework for building Web Apps on SAS® platforms. You can use as much or as little of it as you like. This repository contains the JS adapter, the part that handles the to/from SAS communication on the client side. There are 3 ways to install it:
|
||||
|
||||
1 - `npm install @sasjs/adapter` - for use in a node project
|
||||
1 - `npm install @sasjs/adapter` - for use in a nodeJS project (recommended)
|
||||
|
||||
2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@2/index.js) and use a copy of the latest JS file
|
||||
2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@3/index.min.js) and use a copy of the latest JS file
|
||||
|
||||
3 - Reference directly from the CDN - in which case click [here](https://www.jsdelivr.com/package/npm/@sasjs/adapter?tab=collection) and select "SRI" to get the script tag with the integrity hash.
|
||||
|
||||
@@ -96,7 +96,12 @@ const sasJs = new SASjs({your config})
|
||||
More on the config later.
|
||||
|
||||
### SAS Logon
|
||||
The login process can be handled directly, as below, or as a callback function to a SAS request.
|
||||
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `LoginMechanism` attribute of the sasJs config object (above):
|
||||
|
||||
* `LoginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
|
||||
* `LoginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
|
||||
|
||||
Sample code for logging in with the `Default` approach:
|
||||
|
||||
```javascript
|
||||
sasJs.logIn('USERNAME','PASSWORD'
|
||||
@@ -109,6 +114,8 @@ sasJs.logIn('USERNAME','PASSWORD'
|
||||
}
|
||||
```
|
||||
|
||||
More examples of using authentication, and more, can be found in the [SASjs Seed Apps](https://github.com/search?q=topic%3Asasjs-app+org%3Asasjs+fork%3Atrue) on github.
|
||||
|
||||
### Request / Response
|
||||
A simple request can be sent to SAS in the following fashion:
|
||||
|
||||
@@ -247,11 +254,11 @@ Where an entire column is made up of special missing numerics, there would be no
|
||||
|
||||
Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://adapter.sasjs.io/classes/types.sasjsconfig.html). The main config items are:
|
||||
|
||||
* `appLoc` - this is the folder under which the SAS services will be created.
|
||||
* `appLoc` - this is the folder (eg in metadata or SAS Drive) under which the SAS services are created.
|
||||
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
|
||||
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
|
||||
* `debug` - if `true` then SAS Logs and extra debug information is returned.
|
||||
* `LoginMechanism` - either `Default` or `Redirected`. If `Redirected` then authentication occurs through the injection of an additional screen, which contains the SASLogon prompt. This allows for more complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the redirect flow can also be modified. If left at "Default" then the developer must capture the username and password and use these with the `.login()` method.
|
||||
* `LoginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
|
||||
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
|
||||
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
|
||||
* `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
|
||||
|
||||
25
src/SASjs.ts
25
src/SASjs.ts
@@ -27,7 +27,6 @@ import {
|
||||
ComputeJobExecutor,
|
||||
JesJobExecutor,
|
||||
Sas9JobExecutor,
|
||||
SasJsJobExecutor,
|
||||
FileUploader
|
||||
} from './job-execution'
|
||||
import { ErrorResponse } from './types/errors'
|
||||
@@ -63,7 +62,6 @@ export default class SASjs {
|
||||
private computeJobExecutor: JobExecutor | null = null
|
||||
private jesJobExecutor: JobExecutor | null = null
|
||||
private sas9JobExecutor: JobExecutor | null = null
|
||||
private sasJsJobExecutor: JobExecutor | null = null
|
||||
|
||||
constructor(config?: Partial<SASjsConfig>) {
|
||||
this.sasjsConfig = {
|
||||
@@ -79,7 +77,8 @@ export default class SASjs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the sas code against SAS9 server
|
||||
* Executes code against a SAS 9 server. Requires a runner to be present in
|
||||
* the users home directory in metadata.
|
||||
* @param linesOfCode - lines of sas code from the file to run.
|
||||
* @param username - a string representing the username.
|
||||
* @param password - a string representing the password.
|
||||
@@ -99,7 +98,7 @@ export default class SASjs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the sas code against SASViya server
|
||||
* Executes sas code in a SAS Viya compute session.
|
||||
* @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution.
|
||||
* @param linesOfCode - lines of sas code from the file to run.
|
||||
* @param contextName - context name on which code will be run on the server.
|
||||
@@ -643,7 +642,8 @@ export default class SASjs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request to the SAS Service specified in `SASjob`. The response
|
||||
* Makes a request to program specified in `SASjob` (could be a Viya Job, a
|
||||
* SAS 9 Stored Process, or a SASjs Server Stored Program). The response
|
||||
* object will always contain table names in lowercase, and column names in
|
||||
* uppercase. Values are returned formatted by default, unformatted
|
||||
* values can be configured as an option in the `%webout` macro.
|
||||
@@ -652,7 +652,8 @@ export default class SASjs {
|
||||
* the SAS `_program` parameter to run a Job Definition or SAS 9 Stored
|
||||
* Process). Is prepended at runtime with the value of `appLoc`.
|
||||
* @param data - a JSON object containing one or more tables to be sent to
|
||||
* SAS. Can be `null` if no inputs required.
|
||||
* SAS. For an example of the table structure, see the project README. This
|
||||
* value can be `null` if no inputs are required.
|
||||
* @param config - provide any changes to the config here, for instance to
|
||||
* enable/disable `debug`. Any change provided will override the global config,
|
||||
* for that particular function call.
|
||||
@@ -682,9 +683,10 @@ export default class SASjs {
|
||||
|
||||
const validationResult = this.validateInput(data)
|
||||
|
||||
// status is true if the data passes validation checks above
|
||||
if (validationResult.status) {
|
||||
if (config.serverType === ServerType.Sasjs) {
|
||||
return await this.sasJsJobExecutor!.execute(
|
||||
return await this.webJobExecutor!.execute(
|
||||
sasJob,
|
||||
data,
|
||||
config,
|
||||
@@ -693,7 +695,7 @@ export default class SASjs {
|
||||
extraResponseAttributes
|
||||
)
|
||||
} else if (
|
||||
config.serverType !== ServerType.Sas9 &&
|
||||
config.serverType === ServerType.SasViya &&
|
||||
config.useComputeApi !== undefined &&
|
||||
config.useComputeApi !== null
|
||||
) {
|
||||
@@ -1114,13 +1116,6 @@ export default class SASjs {
|
||||
this.sasViyaApiClient!
|
||||
)
|
||||
|
||||
this.sasJsJobExecutor = new SasJsJobExecutor(
|
||||
this.sasjsConfig.serverUrl,
|
||||
this.sasjsConfig.serverType!,
|
||||
this.jobsPath,
|
||||
this.requestClient
|
||||
)
|
||||
|
||||
this.sas9JobExecutor = new Sas9JobExecutor(
|
||||
this.sasjsConfig.serverUrl,
|
||||
this.sasjsConfig.serverType!,
|
||||
|
||||
@@ -37,7 +37,10 @@ export class SASjsApiClient {
|
||||
}>(
|
||||
'SASjsApi/drive/deploy',
|
||||
{ fileTree: members, appLoc: appLoc },
|
||||
access_token
|
||||
access_token,
|
||||
undefined,
|
||||
{},
|
||||
{ maxContentLength: Infinity, maxBodyLength: Infinity }
|
||||
)
|
||||
|
||||
return Promise.resolve(result)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
|
||||
/**
|
||||
@@ -24,26 +23,17 @@ export async function getAccessTokenForViya(
|
||||
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
}
|
||||
const headers = {
|
||||
Authorization: 'Basic ' + token
|
||||
Authorization: 'Basic ' + token,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
|
||||
let formData
|
||||
if (typeof FormData === 'undefined') {
|
||||
formData = new NodeFormData()
|
||||
} else {
|
||||
formData = new FormData()
|
||||
}
|
||||
formData.append('grant_type', 'authorization_code')
|
||||
formData.append('code', authCode)
|
||||
const data = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode
|
||||
})
|
||||
|
||||
const authResponse = await requestClient
|
||||
.post(
|
||||
url,
|
||||
formData,
|
||||
undefined,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
.post(url, data, undefined, 'application/x-www-form-urlencoded', headers)
|
||||
.then((res) => res.result as SasAuthResponse)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting access token. ')
|
||||
|
||||
@@ -35,11 +35,12 @@ describe('getAccessTokenForViya', () => {
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
'/SASLogon/oauth/token',
|
||||
expect.any(NodeFormData),
|
||||
expect.any(URLSearchParams),
|
||||
undefined,
|
||||
expect.stringContaining('multipart/form-data; boundary='),
|
||||
'application/x-www-form-urlencoded',
|
||||
{
|
||||
Authorization: 'Basic ' + token
|
||||
Authorization: 'Basic ' + token,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { convertToCSV } from '../utils/convertToCsv'
|
||||
|
||||
/**
|
||||
* One of the approaches SASjs takes to send tables-formatted JSON (see README)
|
||||
* to SAS is as multipart form data, where each table is provided as a specially
|
||||
* formatted CSV file.
|
||||
* @param formData Different objects are used depending on whether the adapter is
|
||||
* running in the browser, or in the CLI
|
||||
* @param data Special, tables-formatted JSON (see README)
|
||||
* @returns Populated formData
|
||||
*/
|
||||
export const generateFileUploadForm = (
|
||||
formData: FormData,
|
||||
formData: FormData | NodeFormData,
|
||||
data: any
|
||||
): FormData => {
|
||||
): FormData | NodeFormData => {
|
||||
for (const tableName in data) {
|
||||
if (!Array.isArray(data[tableName])) continue
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { convertToCSV } from '../utils/convertToCsv'
|
||||
import { splitChunks } from '../utils/splitChunks'
|
||||
|
||||
export const generateTableUploadForm = (formData: FormData, data: any) => {
|
||||
export const generateTableUploadForm = (
|
||||
formData: FormData | NodeFormData,
|
||||
data: any
|
||||
) => {
|
||||
const sasjsTables = []
|
||||
const requestParams: any = {}
|
||||
let tableCounter = 0
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes,
|
||||
ServerType
|
||||
} from '@sasjs/utils/types'
|
||||
import {
|
||||
ErrorResponse,
|
||||
JobExecutionError,
|
||||
LoginRequiredError
|
||||
} from '../types/errors'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import {
|
||||
isRelativePath,
|
||||
appendExtraResponseAttributes,
|
||||
getValidJson
|
||||
} from '../utils'
|
||||
import { BaseJobExecutor } from './JobExecutor'
|
||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
||||
|
||||
export class SasJsJobExecutor extends BaseJobExecutor {
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
serverType: ServerType,
|
||||
private jobsPath: string,
|
||||
private requestClient: RequestClient
|
||||
) {
|
||||
super(serverUrl, serverType)
|
||||
}
|
||||
|
||||
async execute(
|
||||
sasJob: string,
|
||||
data: any,
|
||||
config: any,
|
||||
loginRequiredCallback?: any,
|
||||
authConfig?: AuthConfig,
|
||||
extraResponseAttributes: ExtraResponseAttributes[] = []
|
||||
) {
|
||||
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
|
||||
const program = isRelativePath(sasJob)
|
||||
? config.appLoc
|
||||
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
|
||||
: sasJob
|
||||
: sasJob
|
||||
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
|
||||
|
||||
const requestParams = this.getRequestParams(config)
|
||||
|
||||
const requestPromise = new Promise((resolve, reject) => {
|
||||
this.requestClient!.post(
|
||||
apiUrl,
|
||||
{ ...requestParams, ...data },
|
||||
authConfig?.access_token
|
||||
)
|
||||
.then(async (res: any) => {
|
||||
const parsedSasjsServerLog = res.result.log
|
||||
.map((logLine: any) => logLine.line)
|
||||
.join('\n')
|
||||
|
||||
const resObj = {
|
||||
result: res.result._webout,
|
||||
log: parsedSasjsServerLog
|
||||
}
|
||||
this.requestClient!.appendRequest(resObj, sasJob, config.debug)
|
||||
|
||||
let jsonResponse = res.result
|
||||
|
||||
if (config.debug) {
|
||||
const webout = parseWeboutResponse(res.result._webout, apiUrl)
|
||||
jsonResponse = getValidJson(webout)
|
||||
} else {
|
||||
jsonResponse = getValidJson(res.result._webout)
|
||||
}
|
||||
|
||||
const responseObject = appendExtraResponseAttributes(
|
||||
{ result: jsonResponse },
|
||||
extraResponseAttributes
|
||||
)
|
||||
resolve(responseObject)
|
||||
})
|
||||
.catch(async (e: Error) => {
|
||||
if (e instanceof JobExecutionError) {
|
||||
this.requestClient!.appendRequest(e, sasJob, config.debug)
|
||||
reject(new ErrorResponse(e?.message, e))
|
||||
}
|
||||
|
||||
if (e instanceof LoginRequiredError) {
|
||||
this.appendWaitingRequest(() => {
|
||||
return this.execute(
|
||||
sasJob,
|
||||
data,
|
||||
config,
|
||||
loginRequiredCallback,
|
||||
authConfig,
|
||||
extraResponseAttributes
|
||||
).then(
|
||||
(res: any) => {
|
||||
resolve(res)
|
||||
},
|
||||
(err: any) => {
|
||||
reject(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
await loginCallback()
|
||||
} else {
|
||||
reject(new ErrorResponse(e?.message, e))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
private getRequestParams(config: any): any {
|
||||
const requestParams: any = {}
|
||||
|
||||
if (config.debug) {
|
||||
requestParams['_omittextlog'] = 'false'
|
||||
requestParams['_omitsessionresults'] = 'false'
|
||||
|
||||
requestParams['_debug'] = 131
|
||||
}
|
||||
|
||||
return requestParams
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import {
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes,
|
||||
@@ -108,7 +109,12 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
...this.getRequestParams(config)
|
||||
}
|
||||
|
||||
let formData = new FormData()
|
||||
/**
|
||||
* Use the available form data object (FormData in Browser, NodeFormData in
|
||||
* Node)
|
||||
*/
|
||||
let formData =
|
||||
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
|
||||
|
||||
if (data) {
|
||||
const stringifiedData = JSON.stringify(data)
|
||||
@@ -143,8 +149,19 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/* The NodeFormData object does not set the request header - so, set it */
|
||||
const contentType =
|
||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
||||
: undefined
|
||||
|
||||
const requestPromise = new Promise((resolve, reject) => {
|
||||
this.requestClient!.post(apiUrl, formData, authConfig?.access_token)
|
||||
this.requestClient!.post(
|
||||
apiUrl,
|
||||
formData,
|
||||
authConfig?.access_token,
|
||||
contentType
|
||||
)
|
||||
.then(async (res: any) => {
|
||||
const parsedSasjsServerLog =
|
||||
this.serverType === ServerType.Sasjs
|
||||
@@ -178,8 +195,12 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
: res.result
|
||||
break
|
||||
case ServerType.Sasjs:
|
||||
const webout = parseWeboutResponse(res.result._webout, apiUrl)
|
||||
jsonResponse = getValidJson(webout)
|
||||
if (typeof res.result._webout === 'object') {
|
||||
jsonResponse = res.result._webout
|
||||
} else {
|
||||
const webout = parseWeboutResponse(res.result._webout, apiUrl)
|
||||
jsonResponse = getValidJson(webout)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else if (this.serverType === ServerType.Sasjs) {
|
||||
@@ -187,7 +208,7 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
}
|
||||
|
||||
const responseObject = appendExtraResponseAttributes(
|
||||
{ result: jsonResponse },
|
||||
{ result: jsonResponse, log: parsedSasjsServerLog },
|
||||
extraResponseAttributes
|
||||
)
|
||||
resolve(responseObject)
|
||||
|
||||
@@ -3,5 +3,4 @@ export * from './FileUploader'
|
||||
export * from './JesJobExecutor'
|
||||
export * from './JobExecutor'
|
||||
export * from './Sas9JobExecutor'
|
||||
export * from './SasJsJobExecutor'
|
||||
export * from './WebJobExecutor'
|
||||
|
||||
@@ -207,7 +207,8 @@ export class RequestClient implements HttpClient {
|
||||
data: any,
|
||||
accessToken: string | undefined,
|
||||
contentType = 'application/json',
|
||||
overrideHeaders: { [key: string]: string | number } = {}
|
||||
overrideHeaders: { [key: string]: string | number } = {},
|
||||
additionalSettings: { [key: string]: string | number } = {}
|
||||
): Promise<{ result: T; etag: string }> {
|
||||
const headers = {
|
||||
...this.getHeaders(accessToken, contentType),
|
||||
@@ -215,7 +216,11 @@ export class RequestClient implements HttpClient {
|
||||
}
|
||||
|
||||
return this.httpClient
|
||||
.post<T>(url, data, { headers, withCredentials: true })
|
||||
.post<T>(url, data, {
|
||||
headers,
|
||||
withCredentials: true,
|
||||
...additionalSettings
|
||||
})
|
||||
.then((response) => {
|
||||
throwIfError(response)
|
||||
|
||||
@@ -630,7 +635,7 @@ const parseError = (data: string) => {
|
||||
if (parts.length > 1) {
|
||||
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
|
||||
const message = `Stored process not found: ${storedProcessPath}`
|
||||
return new JobExecutionError(404, message, '')
|
||||
return new JobExecutionError(500, message, '')
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -644,7 +649,7 @@ const parseError = (data: string) => {
|
||||
if (parts.length > 1) {
|
||||
const log = parts[1].split('<pre>')[1].split('</pre>')[0]
|
||||
const message = `This request completed with errors.`
|
||||
return new JobExecutionError(404, message, log)
|
||||
return new JobExecutionError(500, message, log)
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const getValidJson = (str: string | object): object => {
|
||||
|
||||
if (typeof str === 'object') return str
|
||||
|
||||
if (str === '') return {}
|
||||
|
||||
return JSON.parse(str)
|
||||
} catch (e) {
|
||||
if (e instanceof JsonParseArrayError) throw e
|
||||
|
||||
Reference in New Issue
Block a user