From c42a20a8ee52ddf401765532ad84d77431effd8d Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Sat, 22 Nov 2025 17:56:50 +0000 Subject: [PATCH 1/6] feat: (viya) apply properties to newly created files --- src/SASViyaApiClient.ts | 156 +++++++++++++++++++++++++++++++++-- src/request/RequestClient.ts | 8 +- 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index 08be277..1694797 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -35,6 +35,60 @@ interface JobExecutionResult { log?: string error?: object } +/* Viya /types/types?limit=999999 response structure */ +interface IViyaTypesResponse { + accept: string + count: number + items: IViyaTypesItem[] + limit: number + links: IViyaTypesLink[] + name: string + start: number + version: number +} + +/* Item element within the Viya types response */ +interface IViyaTypesItem { + description?: string + extensions?: string[] + iconUri?: string + label: string + links: IViyaTypesLink[] + mappedTypes?: string[] + mediaType?: string + mediaTypes?: string[] + name: string + pluralLabel?: string + properties?: IViyaTypesProperties + resourceUri?: string + serviceRootUri?: string + tags?: string[] + version: number +} + +/** + * Generic structure for a link + * in the links array of a Viya + * types/types api response + */ +type IViyaTypesLink = Record + +/** + * Generic structure for a type's + * 'properties' object from the Viya + * types/types api response + */ +type IViyaTypesProperties = Record + +/** + * Arbitrary interface for storing + * sufficient additional detail to + * create and patch a new file. + */ +interface IViyaTypesExtensionInfo { + typeDefName: string + properties: IViyaTypesProperties | undefined +} /** * A client for interfacing with the SAS Viya REST API. @@ -61,6 +115,9 @@ export class SASViyaApiClient { this.requestClient ) private folderMap = new Map() + private fileExtensionMap = new Map() + private boolExtensionMap = false // required in case the map has zero entries + // after an attempt to populate it. /** * A helper method used to call appendRequest method of RequestClient @@ -434,15 +491,96 @@ export class SASViyaApiClient { const formData = new NodeFormData() formData.append('file', contentBuffer, fileName) - return ( - await this.requestClient.post( - `/files/files?parentFolderUri=${parentFolderUri}&typeDefName=file#rawUpload`, - formData, - accessToken, - 'multipart/form-data; boundary=' + (formData as any)._boundary, - headers - ) - ).result + /** Query Viya for file metadata based on extension type. */ + + // typeDefName - Viya accepts this property during the file creation + let typeDefName: string | undefined = undefined + // Additional properties are supplied by a patch. + let filePatch: + | { + name: string + properties: IViyaTypesProperties | undefined + } + | undefined = undefined + + // The patching process requires properties related to the file-extension + const fileExtension: string | undefined = fileName + .split('.') + .pop() + ?.toLowerCase() + + if (fileExtension) { + if (!this.boolExtensionMap) { + // Populate the file extension map + // 1. Get Viya's response to this api call + const typesQueryUrl = `/types/types?limit=999999` + const response = ( + await this.requestClient.get(typesQueryUrl, accessToken) + ).result as IViyaTypesResponse + // 2. Filter the returned items that have file extensions into a map + // using forEach as an item may relate to multiple file extensions. + response.items + .filter((e) => e.extensions) + .forEach((e) => { + e.extensions?.forEach((ext) => { + this.fileExtensionMap.set(ext, { + // "name:" is the typeDefName value required for file creation. + typeDefName: e.name, + properties: e.properties + }) + }) + }) + // 3. Toggle the flag to avoid repeating this step + this.boolExtensionMap = true + } + + // Query the map for the current file extension + const fileExtInfo = this.fileExtensionMap.get(fileExtension) + if (fileExtInfo) { + // If the extension was found in the map, record the typeDefName and + // create a patch if a properties object was returned. + typeDefName = fileExtInfo.typeDefName + if (fileExtInfo.properties) + filePatch = { name: fileName, properties: fileExtInfo.properties } + } + } + + // Create the file + const createFileResponse = await this.requestClient.post( + `/files/files?parentFolderUri=${parentFolderUri}&typeDefName=${ + typeDefName ?? 'file' + }#rawUpload`, + formData, + accessToken, + 'multipart/form-data; boundary=' + (formData as any)._boundary, + headers + ) + + // If a patch was created... + if (filePatch) { + try { + const patchHeaders = { + Accept: 'application/json', + 'If-Match': '*' + } + // Get the URI of the newly created file + const fileUri = createFileResponse.result.links.filter( + (e) => e.method == 'PATCH' && e.rel == 'patch' + )[0].uri + // and apply the patch + return ( + await this.requestClient.patch( + `${fileUri}`, + filePatch, + accessToken, + patchHeaders + ) + ).result + } catch (e: any) { + throw new Error(`Error patching file ${fileName}.\n${e.message}`) + } + } + return createFileResponse.result } /** diff --git a/src/request/RequestClient.ts b/src/request/RequestClient.ts index c0295ed..82ba21b 100644 --- a/src/request/RequestClient.ts +++ b/src/request/RequestClient.ts @@ -273,9 +273,13 @@ export class RequestClient implements HttpClient { public async patch( url: string, data: any = {}, - accessToken?: string + accessToken?: string, + overrideHeaders: { [key: string]: string | number } = {} ): Promise<{ result: T; etag: string }> { - const headers = this.getHeaders(accessToken, 'application/json') + const headers = { + ...this.getHeaders(accessToken, 'application/json'), + ...overrideHeaders + } return this.httpClient .patch(url, data, { headers, withXSRFToken: true }) From 680f5a487265ba1c28250dcf1c1c83e07afc0c26 Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Sat, 22 Nov 2025 17:58:00 +0000 Subject: [PATCH 2/6] chore: (sasjs-tests) prevent redundant rendering of vertical scroll bar --- sasjs-tests/index.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sasjs-tests/index.css b/sasjs-tests/index.css index 65ebd03..e3f6098 100644 --- a/sasjs-tests/index.css +++ b/sasjs-tests/index.css @@ -12,10 +12,6 @@ body { background: #f5f5f5; } -#app { - min-height: 100vh; -} - .app__error { max-width: 800px; margin: 50px auto; @@ -40,4 +36,4 @@ body { border-radius: 4px; overflow-x: auto; } -} \ No newline at end of file +} From f335be344e2f138c6ad4de83c15e3cfc7e0262f2 Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Sat, 22 Nov 2025 22:11:55 +0000 Subject: [PATCH 3/6] build: adjusted search/replace regex/value to allow for json lines without trailing commas --- .github/workflows/server-tests.yml | 214 ++++++++++++++--------------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 687f4bc..60fabba 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -1,107 +1,107 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - -name: SASjs Build and Server Tests - -on: - pull_request: - -jobs: - test: - runs-on: ubuntu-22.04 - - strategy: - matrix: - node-version: [lts/hydrogen] - - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node-version }} - - # 2. Restore npm cache manually - - name: Restore npm cache - uses: actions/cache@v3 - id: npm-cache - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - - name: Install Dependencies - run: npm ci - - - name: Install Rimraf - run: npm i rimraf - - - name: Build Package - run: npm run package:lib - env: - CI: true - - - name: Write VPN Files - run: | - echo "$CA_CRT" > .github/vpn/ca.crt - echo "$USER_CRT" > .github/vpn/user.crt - echo "$USER_KEY" > .github/vpn/user.key - echo "$TLS_KEY" > .github/vpn/tls.key - shell: bash - env: - CA_CRT: ${{ secrets.CA_CRT}} - USER_CRT: ${{ secrets.USER_CRT }} - USER_KEY: ${{ secrets.USER_KEY }} - TLS_KEY: ${{ secrets.TLS_KEY }} - - - name: Chmod VPN files - run: | - chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key - - - name: Install Open VPN - run: | - sudo apt install apt-transport-https - sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub - sudo apt-key add openvpn-repo-pkg-key.pub - sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list - sudo apt update - sudo apt install openvpn3=17~betaUb22042+jammy - - - name: Start Open VPN 3 - run: openvpn3 session-start --config .github/vpn/config.ovpn - - - name: install pm2 - run: npm i -g pm2 - - - name: Fetch SASJS server - run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info - - - name: Deploy sasjs-tests - run: | - npm install -g replace-in-files-cli - cd sasjs-tests - replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json - npm i - replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json - replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json - replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json - replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json - cat ./public/config.json - - npm run update:adapter - pm2 start --name sasjs-test npm -- start - - - name: Sleep for 10 seconds - run: sleep 10s - shell: bash - - - name: Run cypress on sasjs - run: | - replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json - replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json - replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json - cat ./cypress.json - echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}" - - sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: SASjs Build and Server Tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-22.04 + + strategy: + matrix: + node-version: [lts/hydrogen] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + # 2. Restore npm cache manually + - name: Restore npm cache + uses: actions/cache@v3 + id: npm-cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install Dependencies + run: npm ci + + - name: Install Rimraf + run: npm i rimraf + + - name: Build Package + run: npm run package:lib + env: + CI: true + + - name: Write VPN Files + run: | + echo "$CA_CRT" > .github/vpn/ca.crt + echo "$USER_CRT" > .github/vpn/user.crt + echo "$USER_KEY" > .github/vpn/user.key + echo "$TLS_KEY" > .github/vpn/tls.key + shell: bash + env: + CA_CRT: ${{ secrets.CA_CRT}} + USER_CRT: ${{ secrets.USER_CRT }} + USER_KEY: ${{ secrets.USER_KEY }} + TLS_KEY: ${{ secrets.TLS_KEY }} + + - name: Chmod VPN files + run: | + chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key + + - name: Install Open VPN + run: | + sudo apt install apt-transport-https + sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub + sudo apt-key add openvpn-repo-pkg-key.pub + sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list + sudo apt update + sudo apt install openvpn3=17~betaUb22042+jammy + + - name: Start Open VPN 3 + run: openvpn3 session-start --config .github/vpn/config.ovpn + + - name: install pm2 + run: npm i -g pm2 + + - name: Fetch SASJS server + run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info + + - name: Deploy sasjs-tests + run: | + npm install -g replace-in-files-cli + cd sasjs-tests + replace-in-files --regex='"@sasjs/adapter":\s*"[^"]+?([^\/"]+)"' --replacement='"@sasjs/adapter":"latest"' ./package.json + npm i + replace-in-files --regex='"serverUrl":\s*"[^"]+?([^\/"]+)"' --replacemment='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json + replace-in-files --regex='"userName":\s*"[^"]+?([^\/"]+)"' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}"' ./public/config.json + replace-in-files --regex='"serverType":\s*"[^"]+?([^\/"]+)"' --replacement='"serverType":"SASJS"' ./public/config.json + replace-in-files --regex='"password":\s*"[^"]+?([^\/"]+)"' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}"' ./public/config.json + cat ./public/config.json + + npm run update:adapter + pm2 start --name sasjs-test npm -- start + + - name: Sleep for 10 seconds + run: sleep 10s + shell: bash + + - name: Run cypress on sasjs + run: | + replace-in-files --regex='"sasjsTestsUrl":\s*"[^"]+?([^\/"]+)"' --replacement='"sasjsTestsUrl":"http://localhost:3000"' ./cypress.json + replace-in-files --regex='"username":\s*"[^"]+?([^\/"]+)"' --replacement='"username":"${{ secrets.SASJS_USERNAME }}"' ./cypress.json + replace-in-files --regex='"password":\s*"[^"]+?([^\/"]+)"' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}"' ./cypress.json + cat ./cypress.json + echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}" + + sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} From 8c7767a36d2000d744189f0dbb79a075a397eaf0 Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Sat, 22 Nov 2025 22:19:34 +0000 Subject: [PATCH 4/6] fix: (build) command syntax --- .github/workflows/server-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 60fabba..6fbe81e 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -83,7 +83,7 @@ jobs: cd sasjs-tests replace-in-files --regex='"@sasjs/adapter":\s*"[^"]+?([^\/"]+)"' --replacement='"@sasjs/adapter":"latest"' ./package.json npm i - replace-in-files --regex='"serverUrl":\s*"[^"]+?([^\/"]+)"' --replacemment='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json + replace-in-files --regex='"serverUrl":\s*"[^"]+?([^\/"]+)"' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json replace-in-files --regex='"userName":\s*"[^"]+?([^\/"]+)"' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}"' ./public/config.json replace-in-files --regex='"serverType":\s*"[^"]+?([^\/"]+)"' --replacement='"serverType":"SASJS"' ./public/config.json replace-in-files --regex='"password":\s*"[^"]+?([^\/"]+)"' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}"' ./public/config.json From 480510b980b05b83ad44b2bef886352ff69f70db Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Sun, 23 Nov 2025 06:29:52 +0000 Subject: [PATCH 5/6] build: (server-tests) use jq to safely modify json --- .github/workflows/server-tests.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 6fbe81e..2d0e2f0 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -79,14 +79,15 @@ jobs: - name: Deploy sasjs-tests run: | - npm install -g replace-in-files-cli + sudo apt install jq cd sasjs-tests - replace-in-files --regex='"@sasjs/adapter":\s*"[^"]+?([^\/"]+)"' --replacement='"@sasjs/adapter":"latest"' ./package.json + jq '.dependencies."@sasjs/adapter" |= "latest"' ./package.json > ./package.temp && mv ./package.temp ./package.json npm i - replace-in-files --regex='"serverUrl":\s*"[^"]+?([^\/"]+)"' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json - replace-in-files --regex='"userName":\s*"[^"]+?([^\/"]+)"' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}"' ./public/config.json - replace-in-files --regex='"serverType":\s*"[^"]+?([^\/"]+)"' --replacement='"serverType":"SASJS"' ./public/config.json - replace-in-files --regex='"password":\s*"[^"]+?([^\/"]+)"' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}"' ./public/config.json + jq '.sasJsConfig.serverUrl |= "${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json + jq '.sasJsConfig.serverType |= "SASJS"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json + jq '.userName |= "${{ secrets.SASJS_USERNAME }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json + jq '.password |= "${{ secrets.SASJS_PASSWORD }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json + cat ./public/config.json npm run update:adapter @@ -98,9 +99,9 @@ jobs: - name: Run cypress on sasjs run: | - replace-in-files --regex='"sasjsTestsUrl":\s*"[^"]+?([^\/"]+)"' --replacement='"sasjsTestsUrl":"http://localhost:3000"' ./cypress.json - replace-in-files --regex='"username":\s*"[^"]+?([^\/"]+)"' --replacement='"username":"${{ secrets.SASJS_USERNAME }}"' ./cypress.json - replace-in-files --regex='"password":\s*"[^"]+?([^\/"]+)"' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}"' ./cypress.json + jq '.env.sasjsTestsUrl |= "http://localhost:3000"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json + jq '.env.username |= "${{ secrets.SASJS_USERNAME }}"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json + jq '.env.password |= "${{ secrets.SASJS_PASSWORD }}"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json cat ./cypress.json echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}" From ba64ed1f204d54f8dfe0afb1175ab1ad1d7c7ff7 Mon Sep 17 00:00:00 2001 From: Trevor Moody Date: Tue, 25 Nov 2025 07:39:00 +0000 Subject: [PATCH 6/6] fix: defensively coded for potential empty 'name' properties in the viya types response --- src/SASViyaApiClient.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index 1694797..5e060df 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -57,7 +57,7 @@ interface IViyaTypesItem { mappedTypes?: string[] mediaType?: string mediaTypes?: string[] - name: string + name?: string | undefined pluralLabel?: string properties?: IViyaTypesProperties resourceUri?: string @@ -86,7 +86,7 @@ type IViyaTypesProperties = Record * create and patch a new file. */ interface IViyaTypesExtensionInfo { - typeDefName: string + typeDefName: string | undefined properties: IViyaTypesProperties | undefined } @@ -524,8 +524,14 @@ export class SASViyaApiClient { .forEach((e) => { e.extensions?.forEach((ext) => { this.fileExtensionMap.set(ext, { - // "name:" is the typeDefName value required for file creation. - typeDefName: e.name, + // `name` becomes the typeDefName value at file creation time. + // `name` is ignored here if it is not populated in the map, or + // has a blank/empty value. + typeDefName: e.name + ? e.name.trim().length + ? e.name.trim() + : undefined + : undefined, properties: e.properties }) })