From bb2c8d22a65c437b5a62885e8386c02408414130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=BF=20corey=20=28they/them=29?= Date: Fri, 10 Mar 2023 10:16:14 -0800 Subject: [PATCH] v1.0.0 --- README.md | 23 ++-- dist/ajax.d.ts | 79 +++++++++++++ dist/ajax.js | 147 +++++++++++++++++++++++ dist/ajax.js.flow | 99 ++++++++++++++++ dist/index.d.ts | 5 +- dist/index.js | 18 ++- dist/index.js.flow | 19 ++- dist/link-header.d.ts | 15 +++ dist/link-header.js | 74 ++++++++++++ dist/link-header.js.flow | 19 +++ dist/process.d.ts | 4 - dist/process.js | 9 -- dist/process.js.flow | 5 - package.json | 11 +- src/ajax.ts | 245 +++++++++++++++++++++++++++++++++++++++ src/declarations.d.ts | 1 + src/index.test.ts | 23 ++-- src/index.ts | 15 ++- src/link-header.ts | 84 ++++++++++++++ src/process.ts | 5 - yarn.lock | 92 ++++++++++++++- 21 files changed, 935 insertions(+), 57 deletions(-) create mode 100644 dist/ajax.d.ts create mode 100644 dist/ajax.js create mode 100644 dist/ajax.js.flow create mode 100644 dist/link-header.d.ts create mode 100644 dist/link-header.js create mode 100644 dist/link-header.js.flow delete mode 100644 dist/process.d.ts delete mode 100644 dist/process.js delete mode 100644 dist/process.js.flow create mode 100644 src/ajax.ts create mode 100644 src/declarations.d.ts create mode 100644 src/link-header.ts delete mode 100644 src/process.ts diff --git a/README.md b/README.md index 33caff7..62ca922 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ -# npm Package Template +# @freckle/ajax -Our custom template repository for creating a package published to npm. +## Install -[Creating a repository from a template][docs]. +```sh +yarn add @freckle/ajax +``` -[docs]: https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template +## Ajax helpers -**NOTE**: Be sure to look for strings like "TODO", "Package name", or "package-name" and update -them accordingly. +See [ajax.ts](./src/ajax.ts). -## Install +## Link header -```sh -yarn add package-name -``` +See [link-header.ts](./src/link-header.ts). -## process(input) +--- -TODO: Document public API for package. +[LICENSE](./LICENSE) diff --git a/dist/ajax.d.ts b/dist/ajax.d.ts new file mode 100644 index 0000000..170e25a --- /dev/null +++ b/dist/ajax.d.ts @@ -0,0 +1,79 @@ +type MethodT = 'POST' | 'GET' | 'PATCH' | 'PUT' | 'HEAD' | 'DELETE'; +type ContentTypeT = 'application/json; charset=utf-8' | 'application/x-www-form-urlencoded' | 'text/plain' | 'text/csv'; +type DataTypeT = 'json' | 'text'; +type AjaxCallOptionsT = { + url: string; + method: MethodT; + data?: any; + contentType?: ContentTypeT | null; + dataType: DataTypeT; + cache?: boolean; + xhrFields?: { + withCredentials: boolean; + }; + timeout?: number; +}; +export declare function ajaxCall(options: AjaxCallOptionsT): Promise; +type MethodWithStringifiedDataT = 'POST' | 'PATCH' | 'PUT' | 'HEAD' | 'DELETE'; +type MethodWithRawDataT = 'GET'; +export type AjaxJsonCallOptionsT = { + url: string; + method: MethodWithStringifiedDataT; + data?: string; + cache?: boolean; + xhrFields?: { + withCredentials: boolean; + }; + timeout?: number; +} | { + url: string; + method: MethodWithRawDataT; + data?: any; + cache?: boolean; + xhrFields?: { + withCredentials: boolean; + }; + timeout?: number; +}; +export declare function ajaxJsonCall(options: AjaxJsonCallOptionsT): Promise; +type AjaxFormCallOptionsT = { + url: string; + method: MethodT; + data?: any; +}; +export declare function ajaxFormCall(options: AjaxFormCallOptionsT): Promise; +type AjaxFormFileUploadOptionsT = { + url: string; + data: any; + method?: MethodT; + timeout?: number; +}; +export declare function ajaxFormFileUpload(options: AjaxFormFileUploadOptionsT): Promise; +export type AjaxFileDownloadOptionsT = { + url: string; + accept: ContentTypeT; + defaultFilename: string; +}; +export declare function ajaxFileDownload(options: AjaxFileDownloadOptionsT): Promise; +type SendBeaconOptionsT = Inexact<{ + url: string; + data: Inexact<{ + [x: string]: any; + }>; +}>; +export declare function sendBeacon(options: SendBeaconOptionsT): void; +export declare function checkUrlExistence(url: string): Promise; +/** + * This hack gets around a Chrome caching bug wherein the audio request generated + * by the audio web api leads to chrome caching a response that does not contain + * the appropriate CORS headers. Any subsequent testing of that resource by this method + * would then attempt to fetch the resource from cache and would error out with a missing + * access-control-allow-origin error. By appending the path name to the audioPath + * here, but not for the audio web api, we create a separate cache for this + * resource request and bypass using the cached response with the missing + * CORS Headers. This is reproducible in Chrome only. + * + * Root cause: https://bugs.chromium.org/p/chromium/issues/detail?id=260239 + */ +export declare function appendParamToRemedyCorsBug(path: string): string; +export {}; diff --git a/dist/ajax.js b/dist/ajax.js new file mode 100644 index 0000000..59073de --- /dev/null +++ b/dist/ajax.js @@ -0,0 +1,147 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.appendParamToRemedyCorsBug = exports.checkUrlExistence = exports.sendBeacon = exports.ajaxFileDownload = exports.ajaxFormFileUpload = exports.ajaxFormCall = exports.ajaxJsonCall = exports.ajaxCall = void 0; +const maybe_1 = require("@freckle/maybe"); +function ajaxCall(options) { + const { url, method, data, contentType, dataType, cache, xhrFields, timeout } = options; + const contentTypeHeader = contentType !== null && contentType !== undefined ? { contentType } : {}; + const timeoutParam = timeout !== null && timeout !== undefined ? { timeout } : {}; + return new Promise((resolve, reject) => { + $.ajax(Object.assign(Object.assign({ url, type: method, data, + dataType, + cache, + xhrFields }, timeoutParam), contentTypeHeader)) + .then(resolve) + .fail(reject); + }); +} +exports.ajaxCall = ajaxCall; +function ajaxJsonCall(options) { + const { url, method, data, cache, xhrFields, timeout } = options; + // If we are not sending any data along with the request then there is no need to specify the contentType + // For cross-domain requests, setting the content type to anything other than application/x-www-form-urlencoded, + // multipart/form-data, or text/plain will trigger the browser to send a preflight OPTIONS request to the server. + // We don't want to make a preflight request where it has no use. + const contentType = data !== null && data !== undefined ? 'application/json; charset=utf-8' : null; + const dataType = 'json'; + return ajaxCall({ url, method, data, contentType, dataType, cache, xhrFields, timeout }); +} +exports.ajaxJsonCall = ajaxJsonCall; +function ajaxFormCall(options) { + const { url, method, data } = options; + const contentType = 'application/x-www-form-urlencoded'; + const dataType = 'json'; + const cache = false; + return ajaxCall({ url, method, data, contentType, dataType, cache }); +} +exports.ajaxFormCall = ajaxFormCall; +function ajaxFormFileUpload(options) { + const { url, data, method, timeout } = options; + const timeoutParam = timeout !== null && timeout !== undefined ? { timeout } : {}; + return new Promise((resolve, reject) => { + $.ajax(Object.assign({ url, type: method ? method : 'POST', data, contentType: false, processData: false }, timeoutParam)) + .then(resolve) + .fail(reject); + }); +} +exports.ajaxFormFileUpload = ajaxFormFileUpload; +function ajaxFileDownload(options) { + const { url, accept, defaultFilename } = options; + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open('GET', url, true); + request.withCredentials = true; + request.responseType = 'blob'; + request.setRequestHeader('Accept', accept); + // Reject on error + request.onerror = () => { + reject({ + status: request.status, + statusText: request.statusText + }); + }; + // Create an anchor that downloads Blob using FileReader + request.onload = () => { + var _a; + if (request.status >= 200 && request.status < 300) { + const contentType = request.getResponseHeader('Content-Type'); + const disposition = request.getResponseHeader('Content-Disposition'); + const blob = new Blob([(_a = request.response) !== null && _a !== void 0 ? _a : ''], { type: contentType !== null && contentType !== void 0 ? contentType : undefined }); + const reader = new FileReader(); + reader.onload = e => { + const anchor = document.createElement('a'); + anchor.style.display = 'none'; + const target = e.target; + if (target instanceof FileReader && typeof target.result === 'string') { + anchor.href = target.result; + anchor.download = (0, maybe_1.fromMaybe)(() => defaultFilename, contentDispositionFilename(disposition)); + anchor.click(); + resolve(); + } + else { + reject({ + status: request.status, + statusText: request.statusText + }); + } + }; + reader.readAsDataURL(blob); + } + else { + reject({ + status: request.status, + statusText: request.statusText + }); + } + }; + // Go + request.send(); + }); +} +exports.ajaxFileDownload = ajaxFileDownload; +function contentDispositionFilename(mDisposition) { + return (0, maybe_1.mthen)(mDisposition, disposition => (0, maybe_1.mthen)(disposition.trim().match(/attachment; filename="(.*)"/), ([_ignore, filename]) => filename)); +} +function sendBeacon(options) { + const { url, data } = options; + try { + const jsonData = JSON.stringify(data); + window.navigator.sendBeacon(url, jsonData); + // eslint-disable-next-line no-empty + } + catch (e) { } +} +exports.sendBeacon = sendBeacon; +function checkUrlExistence(url) { + return new Promise(resolve => { + ajaxCall({ + url, + method: 'HEAD', + contentType: 'text/plain', + dataType: 'text' + }) + .then(() => { + resolve(true); + }) + .catch(() => { + resolve(false); + }); + }); +} +exports.checkUrlExistence = checkUrlExistence; +/** + * This hack gets around a Chrome caching bug wherein the audio request generated + * by the audio web api leads to chrome caching a response that does not contain + * the appropriate CORS headers. Any subsequent testing of that resource by this method + * would then attempt to fetch the resource from cache and would error out with a missing + * access-control-allow-origin error. By appending the path name to the audioPath + * here, but not for the audio web api, we create a separate cache for this + * resource request and bypass using the cached response with the missing + * CORS Headers. This is reproducible in Chrome only. + * + * Root cause: https://bugs.chromium.org/p/chromium/issues/detail?id=260239 + */ +function appendParamToRemedyCorsBug(path) { + return path.includes('?') ? `${path}&via=xmlHttpRequest` : `${path}?via=xmlHttpRequest`; +} +exports.appendParamToRemedyCorsBug = appendParamToRemedyCorsBug; diff --git a/dist/ajax.js.flow b/dist/ajax.js.flow new file mode 100644 index 0000000..fba7f8d --- /dev/null +++ b/dist/ajax.js.flow @@ -0,0 +1,99 @@ +// @flow +declare type MethodT = "POST" | "GET" | "PATCH" | "PUT" | "HEAD" | "DELETE"; +declare type ContentTypeT = + | "application/json; charset=utf-8" + | "application/x-www-form-urlencoded" + | "text/plain" + | "text/csv"; +declare type DataTypeT = "json" | "text"; +declare type AjaxCallOptionsT = {| + url: string, + method: MethodT, + data?: any, + contentType?: ContentTypeT | null, + dataType: DataTypeT, + cache?: boolean, + xhrFields?: {| + withCredentials: boolean, + |}, + timeout?: number, +|}; +declare export function ajaxCall(options: AjaxCallOptionsT): Promise; +declare type MethodWithStringifiedDataT = + | "POST" + | "PATCH" + | "PUT" + | "HEAD" + | "DELETE"; +declare type MethodWithRawDataT = "GET"; +export type AjaxJsonCallOptionsT = + | {| + url: string, + method: MethodWithStringifiedDataT, + data?: string, + cache?: boolean, + xhrFields?: {| + withCredentials: boolean, + |}, + timeout?: number, + |} + | {| + url: string, + method: MethodWithRawDataT, + data?: any, + cache?: boolean, + xhrFields?: {| + withCredentials: boolean, + |}, + timeout?: number, + |}; +declare export function ajaxJsonCall( + options: AjaxJsonCallOptionsT +): Promise; +declare type AjaxFormCallOptionsT = {| + url: string, + method: MethodT, + data?: any, +|}; +declare export function ajaxFormCall( + options: AjaxFormCallOptionsT +): Promise; +declare type AjaxFormFileUploadOptionsT = {| + url: string, + data: any, + method?: MethodT, + timeout?: number, +|}; +declare export function ajaxFormFileUpload( + options: AjaxFormFileUploadOptionsT +): Promise; +export type AjaxFileDownloadOptionsT = {| + url: string, + accept: ContentTypeT, + defaultFilename: string, +|}; +declare export function ajaxFileDownload( + options: AjaxFileDownloadOptionsT +): Promise; +declare type SendBeaconOptionsT = Inexact<{| + url: string, + data: Inexact<{ + [x: string]: any, + }>, +|}>; +declare export function sendBeacon(options: SendBeaconOptionsT): void; +declare export function checkUrlExistence(url: string): Promise; + +/** + * This hack gets around a Chrome caching bug wherein the audio request generated + * by the audio web api leads to chrome caching a response that does not contain + * the appropriate CORS headers. Any subsequent testing of that resource by this method + * would then attempt to fetch the resource from cache and would error out with a missing + * access-control-allow-origin error. By appending the path name to the audioPath + * here, but not for the audio web api, we create a separate cache for this + * resource request and bypass using the cached response with the missing + * CORS Headers. This is reproducible in Chrome only. + * + * Root cause: https://bugs.chromium.org/p/chromium/issues/detail?id=260239 + */ +declare export function appendParamToRemedyCorsBug(path: string): string; diff --git a/dist/index.d.ts b/dist/index.d.ts index 998f436..733d9b2 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1 +1,4 @@ -export { process } from './process'; +export { ajaxCall, ajaxJsonCall, ajaxFormCall, ajaxFormFileUpload, ajaxFileDownload, sendBeacon, checkUrlExistence, appendParamToRemedyCorsBug } from './ajax'; +export type { AjaxJsonCallOptionsT, AjaxFileDownloadOptionsT } from './ajax'; +export { fromString, toString, parseLinkHeader, fetchWithLinks } from './link-header'; +export type { LinkName, LinkPathT, LinksT } from './link-header'; diff --git a/dist/index.js b/dist/index.js index 8e5979a..6968165 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,5 +1,17 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.process = void 0; -var process_1 = require("./process"); -Object.defineProperty(exports, "process", { enumerable: true, get: function () { return process_1.process; } }); +exports.fetchWithLinks = exports.parseLinkHeader = exports.toString = exports.fromString = exports.appendParamToRemedyCorsBug = exports.checkUrlExistence = exports.sendBeacon = exports.ajaxFileDownload = exports.ajaxFormFileUpload = exports.ajaxFormCall = exports.ajaxJsonCall = exports.ajaxCall = void 0; +var ajax_1 = require("./ajax"); +Object.defineProperty(exports, "ajaxCall", { enumerable: true, get: function () { return ajax_1.ajaxCall; } }); +Object.defineProperty(exports, "ajaxJsonCall", { enumerable: true, get: function () { return ajax_1.ajaxJsonCall; } }); +Object.defineProperty(exports, "ajaxFormCall", { enumerable: true, get: function () { return ajax_1.ajaxFormCall; } }); +Object.defineProperty(exports, "ajaxFormFileUpload", { enumerable: true, get: function () { return ajax_1.ajaxFormFileUpload; } }); +Object.defineProperty(exports, "ajaxFileDownload", { enumerable: true, get: function () { return ajax_1.ajaxFileDownload; } }); +Object.defineProperty(exports, "sendBeacon", { enumerable: true, get: function () { return ajax_1.sendBeacon; } }); +Object.defineProperty(exports, "checkUrlExistence", { enumerable: true, get: function () { return ajax_1.checkUrlExistence; } }); +Object.defineProperty(exports, "appendParamToRemedyCorsBug", { enumerable: true, get: function () { return ajax_1.appendParamToRemedyCorsBug; } }); +var link_header_1 = require("./link-header"); +Object.defineProperty(exports, "fromString", { enumerable: true, get: function () { return link_header_1.fromString; } }); +Object.defineProperty(exports, "toString", { enumerable: true, get: function () { return link_header_1.toString; } }); +Object.defineProperty(exports, "parseLinkHeader", { enumerable: true, get: function () { return link_header_1.parseLinkHeader; } }); +Object.defineProperty(exports, "fetchWithLinks", { enumerable: true, get: function () { return link_header_1.fetchWithLinks; } }); diff --git a/dist/index.js.flow b/dist/index.js.flow index c8cda61..2ca542c 100644 --- a/dist/index.js.flow +++ b/dist/index.js.flow @@ -1,2 +1,19 @@ // @flow -declare export { process } from "./process"; +declare export { + ajaxCall, + ajaxJsonCall, + ajaxFormCall, + ajaxFormFileUpload, + ajaxFileDownload, + sendBeacon, + checkUrlExistence, + appendParamToRemedyCorsBug, +} from "./ajax"; +export type { AjaxJsonCallOptionsT, AjaxFileDownloadOptionsT } from "./ajax"; +declare export { + fromString, + toString, + parseLinkHeader, + fetchWithLinks, +} from "./link-header"; +export type { LinkName, LinkPathT, LinksT } from "./link-header"; diff --git a/dist/link-header.d.ts b/dist/link-header.d.ts new file mode 100644 index 0000000..ec8583e --- /dev/null +++ b/dist/link-header.d.ts @@ -0,0 +1,15 @@ +import { type ParserT } from '@freckle/parser'; +export type LinkName = 'first' | 'previous' | 'next' | 'last'; +export type LinkPathT = string; +export declare function fromString(linkUrl: string): LinkPathT; +export declare function toString(linkUrl: LinkPathT): string; +export type LinksT = { + [name in LinkName]: LinkPathT; +}; +export declare function parseLinkHeader(linkHeader: string | null): LinksT; +type ResponseT = { + response: T; + links: LinksT; +}; +export declare function fetchWithLinks(url: string, parseAttrs: ParserT): Promise>; +export {}; diff --git a/dist/link-header.js b/dist/link-header.js new file mode 100644 index 0000000..d7e6da6 --- /dev/null +++ b/dist/link-header.js @@ -0,0 +1,74 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.fetchWithLinks = exports.parseLinkHeader = exports.toString = exports.fromString = void 0; +const isNil_1 = __importDefault(require("lodash/isNil")); +const parser_1 = require("@freckle/parser"); +function fromString(linkUrl) { + return linkUrl; +} +exports.fromString = fromString; +function toString(linkUrl) { + return linkUrl; +} +exports.toString = toString; +/* Code Imported from https://gist.github.com/niallo/3109252 + * Allows us to read the Link Header and transform it in a usable object + */ +function parseLinkHeader(linkHeader) { + if ((0, isNil_1.default)(linkHeader) || linkHeader.trim().length === 0) { + throw new Error('Expected non-zero Link header'); + } + // Split parts by comma + const parts = linkHeader.split(','); + const links = {}; + // Parse each part into a named link + parts.forEach(part => { + const section = part.split(';'); + if (section.length !== 2) { + return; + } + const url = section[0].replace(/<(.*)>/, '$1').trim(); + const rawName = section[1].replace(/rel="(.*)"/, '$1').trim(); + const name = toLinkName(rawName); + links[name] = url; + }); + return links; +} +exports.parseLinkHeader = parseLinkHeader; +const toLinkName = (rawName) => { + switch (rawName) { + case 'first': + case 'previous': + case 'next': + case 'last': + return rawName; + default: + throw new Error(`Could not parse ${rawName}`); + } +}; +function fetchWithLinks(url, parseAttrs) { + return new Promise((resolve, reject) => { + $.ajax({ + url, + type: 'GET' + }) + .then((response, _textStatus, jqXHR) => { + try { + const linkHeader = jqXHR.getResponseHeader('Link'); + const links = parseLinkHeader(linkHeader); + const parsedResponse = parser_1.Parser.run(response, parseAttrs); + resolve({ response: parsedResponse, links }); + } + catch (error) { + reject(error); + } + }) + .fail((_jqXHR, _textStatus, errorThrown) => { + reject(new Error(errorThrown)); + }); + }); +} +exports.fetchWithLinks = fetchWithLinks; diff --git a/dist/link-header.js.flow b/dist/link-header.js.flow new file mode 100644 index 0000000..44e22cd --- /dev/null +++ b/dist/link-header.js.flow @@ -0,0 +1,19 @@ +// @flow +import { type, ParserT } from "@freckle/parser"; +export type LinkName = "first" | "previous" | "next" | "last"; +export type LinkPathT = string; +declare export function fromString(linkUrl: string): LinkPathT; +declare export function toString(linkUrl: LinkPathT): string; +export type LinksT = $ObjMapi< + { [k: LinkName]: any }, + (name) => LinkPathT +>; +declare export function parseLinkHeader(linkHeader: string | null): LinksT; +declare type ResponseT = {| + response: T, + links: LinksT, +|}; +declare export function fetchWithLinks( + url: string, + parseAttrs: ParserT +): Promise>; diff --git a/dist/process.d.ts b/dist/process.d.ts deleted file mode 100644 index 1eb7543..0000000 --- a/dist/process.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type MyType = { - data: string; -}; -export declare const process: (input: MyType) => string; diff --git a/dist/process.js b/dist/process.js deleted file mode 100644 index 1f56cf2..0000000 --- a/dist/process.js +++ /dev/null @@ -1,9 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.process = void 0; -const kebabCase_1 = __importDefault(require("lodash/kebabCase")); -const process = (input) => (0, kebabCase_1.default)(input.data); -exports.process = process; diff --git a/dist/process.js.flow b/dist/process.js.flow deleted file mode 100644 index eb481f9..0000000 --- a/dist/process.js.flow +++ /dev/null @@ -1,5 +0,0 @@ -// @flow -export type MyType = {| - data: string, -|}; -declare export var process: (input: MyType) => string; diff --git a/package.json b/package.json index 2914605..7aa09fb 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "@freckle/package-name", + "name": "@freckle/ajax", "version": "1.0.0", - "description": "TODO: Package description", + "description": "Freckle ajax helpers", "author": "Freckle", "license": "MIT", "main": "./dist/index.js", "repository": { "type": "git", - "url": "git+https://github.com/freckle/package-name-js.git" + "url": "git+https://github.com/freckle/ajax-js.git" }, "scripts": { "build": "rm -r dist && tsc -d && node gen-flow.js", @@ -15,11 +15,14 @@ "format": "prettier --write 'src/**/*.ts'" }, "dependencies": { + "@freckle/maybe": "https://github.com/freckle/maybe-js.git#v2.2.0", + "@freckle/parser": "https://github.com/freckle/parser-js.git#v1.3.2", "lodash": "^4.17.21" }, "devDependencies": { "@types/jest": "^28.1.1", - "@types/lodash": "^4.14.182", + "@types/jquery": "^3.5.16", + "@types/lodash": "^4.14.191", "cpy-cli": "3.1.1", "flow-bin": "0.110.0", "flowgen": "^1.17.0", diff --git a/src/ajax.ts b/src/ajax.ts new file mode 100644 index 0000000..0b96ac5 --- /dev/null +++ b/src/ajax.ts @@ -0,0 +1,245 @@ +import {fromMaybe, mthen} from '@freckle/maybe' + +type MethodT = 'POST' | 'GET' | 'PATCH' | 'PUT' | 'HEAD' | 'DELETE' +type ContentTypeT = + | 'application/json; charset=utf-8' + | 'application/x-www-form-urlencoded' + | 'text/plain' + | 'text/csv' +type DataTypeT = 'json' | 'text' + +type AjaxCallOptionsT = { + url: string + method: MethodT + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any + contentType?: ContentTypeT | null + dataType: DataTypeT + cache?: boolean + xhrFields?: { + withCredentials: boolean + } + timeout?: number +} + +export function ajaxCall(options: AjaxCallOptionsT): Promise { + const {url, method, data, contentType, dataType, cache, xhrFields, timeout} = options + const contentTypeHeader = contentType !== null && contentType !== undefined ? {contentType} : {} + const timeoutParam = timeout !== null && timeout !== undefined ? {timeout} : {} + + return new Promise((resolve, reject) => { + $.ajax({ + url, + type: method, + data, + dataType, + cache, + xhrFields, + ...timeoutParam, + ...contentTypeHeader + }) + .then(resolve) + .fail(reject) + }) +} + +type MethodWithStringifiedDataT = 'POST' | 'PATCH' | 'PUT' | 'HEAD' | 'DELETE' +type MethodWithRawDataT = 'GET' + +export type AjaxJsonCallOptionsT = + | { + url: string + method: MethodWithStringifiedDataT + data?: string + cache?: boolean + xhrFields?: { + withCredentials: boolean + } + timeout?: number + } + | { + url: string + method: MethodWithRawDataT + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any + cache?: boolean + xhrFields?: { + withCredentials: boolean + } + timeout?: number + } + +export function ajaxJsonCall(options: AjaxJsonCallOptionsT): Promise { + const {url, method, data, cache, xhrFields, timeout} = options + // If we are not sending any data along with the request then there is no need to specify the contentType + // For cross-domain requests, setting the content type to anything other than application/x-www-form-urlencoded, + // multipart/form-data, or text/plain will trigger the browser to send a preflight OPTIONS request to the server. + // We don't want to make a preflight request where it has no use. + const contentType = data !== null && data !== undefined ? 'application/json; charset=utf-8' : null + const dataType = 'json' + return ajaxCall({url, method, data, contentType, dataType, cache, xhrFields, timeout}) +} + +type AjaxFormCallOptionsT = { + url: string + method: MethodT + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any +} + +export function ajaxFormCall(options: AjaxFormCallOptionsT): Promise { + const {url, method, data} = options + const contentType = 'application/x-www-form-urlencoded' + const dataType = 'json' + const cache = false + return ajaxCall({url, method, data, contentType, dataType, cache}) +} + +type AjaxFormFileUploadOptionsT = { + url: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any + method?: MethodT + timeout?: number +} + +export function ajaxFormFileUpload(options: AjaxFormFileUploadOptionsT): Promise { + const {url, data, method, timeout} = options + const timeoutParam = timeout !== null && timeout !== undefined ? {timeout} : {} + return new Promise((resolve, reject) => { + $.ajax({ + url, + type: method ? method : 'POST', + data, + contentType: false, + processData: false, + ...timeoutParam + }) + .then(resolve) + .fail(reject) + }) +} + +export type AjaxFileDownloadOptionsT = { + url: string + accept: ContentTypeT + defaultFilename: string +} + +export function ajaxFileDownload(options: AjaxFileDownloadOptionsT): Promise { + const {url, accept, defaultFilename} = options + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.open('GET', url, true) + request.withCredentials = true + request.responseType = 'blob' + request.setRequestHeader('Accept', accept) + + // Reject on error + request.onerror = () => { + reject({ + status: request.status, + statusText: request.statusText + }) + } + + // Create an anchor that downloads Blob using FileReader + request.onload = () => { + if (request.status >= 200 && request.status < 300) { + const contentType = request.getResponseHeader('Content-Type') + const disposition = request.getResponseHeader('Content-Disposition') + const blob = new Blob([request.response ?? ''], {type: contentType ?? undefined}) + const reader = new FileReader() + reader.onload = e => { + const anchor = document.createElement('a') + anchor.style.display = 'none' + + const target = e.target + if (target instanceof FileReader && typeof target.result === 'string') { + anchor.href = target.result + anchor.download = fromMaybe( + () => defaultFilename, + contentDispositionFilename(disposition) + ) + anchor.click() + resolve() + } else { + reject({ + status: request.status, + statusText: request.statusText + }) + } + } + reader.readAsDataURL(blob) + } else { + reject({ + status: request.status, + statusText: request.statusText + }) + } + } + + // Go + request.send() + }) +} + +function contentDispositionFilename(mDisposition?: string | null): string | undefined | null { + return mthen(mDisposition, disposition => + mthen( + disposition.trim().match(/attachment; filename="(.*)"/), + ([_ignore, filename]) => filename + ) + ) +} + +type SendBeaconOptionsT = Inexact<{ + url: string + data: Inexact<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any + }> // Object to stringify +}> + +export function sendBeacon(options: SendBeaconOptionsT) { + const {url, data} = options + + try { + const jsonData = JSON.stringify(data) + window.navigator.sendBeacon(url, jsonData) + // eslint-disable-next-line no-empty + } catch (e) {} +} + +export function checkUrlExistence(url: string): Promise { + return new Promise(resolve => { + ajaxCall({ + url, + method: 'HEAD', + contentType: 'text/plain', + dataType: 'text' + }) + .then(() => { + resolve(true) + }) + .catch(() => { + resolve(false) + }) + }) +} + +/** + * This hack gets around a Chrome caching bug wherein the audio request generated + * by the audio web api leads to chrome caching a response that does not contain + * the appropriate CORS headers. Any subsequent testing of that resource by this method + * would then attempt to fetch the resource from cache and would error out with a missing + * access-control-allow-origin error. By appending the path name to the audioPath + * here, but not for the audio web api, we create a separate cache for this + * resource request and bypass using the cached response with the missing + * CORS Headers. This is reproducible in Chrome only. + * + * Root cause: https://bugs.chromium.org/p/chromium/issues/detail?id=260239 + */ +export function appendParamToRemedyCorsBug(path: string): string { + return path.includes('?') ? `${path}&via=xmlHttpRequest` : `${path}?via=xmlHttpRequest` +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..07a7885 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1 @@ +declare type Inexact = T diff --git a/src/index.test.ts b/src/index.test.ts index b09df87..377bf92 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,11 +1,20 @@ -import {process} from '.' +import {parseLinkHeader} from './link-header' -describe('Package name', () => { - describe('process', () => { - it('should succeed on input', () => { - const input = {data: 'testString'} - const result = process(input) - expect(result).toEqual('test-string') +describe('api-student-table.js', () => { + describe('parseLinkHeader', () => { + test('should correctly return the link when only one is given', () => { + const res = parseLinkHeader('; rel="first"') + expect(res).toEqual({first: '/3/students?limit=10&schools.id=2'}) + }) + + test('should correctly return the link when several links are given', () => { + const res = parseLinkHeader( + '; rel="first", ; rel="next"' + ) + expect(res).toEqual({ + first: '/3/students?limit=10&schools.id=2', + next: '/3/students?limit=10&position=94&schools.id=2' + }) }) }) }) diff --git a/src/index.ts b/src/index.ts index 2953b46..61e8fa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,14 @@ -export {process} from './process' +export { + ajaxCall, + ajaxJsonCall, + ajaxFormCall, + ajaxFormFileUpload, + ajaxFileDownload, + sendBeacon, + checkUrlExistence, + appendParamToRemedyCorsBug +} from './ajax' +export type {AjaxJsonCallOptionsT, AjaxFileDownloadOptionsT} from './ajax' + +export {fromString, toString, parseLinkHeader, fetchWithLinks} from './link-header' +export type {LinkName, LinkPathT, LinksT} from './link-header' diff --git a/src/link-header.ts b/src/link-header.ts new file mode 100644 index 0000000..f04da6d --- /dev/null +++ b/src/link-header.ts @@ -0,0 +1,84 @@ +import isNil from 'lodash/isNil' +import {Parser, type ParserT} from '@freckle/parser' + +export type LinkName = 'first' | 'previous' | 'next' | 'last' +export type LinkPathT = string + +export function fromString(linkUrl: string): LinkPathT { + return linkUrl +} + +export function toString(linkUrl: LinkPathT): string { + return linkUrl +} + +export type LinksT = { + [name in LinkName]: LinkPathT +} + +/* Code Imported from https://gist.github.com/niallo/3109252 + * Allows us to read the Link Header and transform it in a usable object + */ + +export function parseLinkHeader(linkHeader: string | null): LinksT { + if (isNil(linkHeader) || linkHeader.trim().length === 0) { + throw new Error('Expected non-zero Link header') + } + + // Split parts by comma + const parts = linkHeader.split(',') + const links = {} as LinksT + + // Parse each part into a named link + parts.forEach(part => { + const section = part.split(';') + if (section.length !== 2) { + return + } + + const url = section[0].replace(/<(.*)>/, '$1').trim() + const rawName = section[1].replace(/rel="(.*)"/, '$1').trim() + const name = toLinkName(rawName) + links[name] = url + }) + return links +} + +const toLinkName = (rawName: string): LinkName => { + switch (rawName) { + case 'first': + case 'previous': + case 'next': + case 'last': + return rawName + default: + throw new Error(`Could not parse ${rawName}`) + } +} + +type ResponseT = { + response: T + links: LinksT +} + +export function fetchWithLinks(url: string, parseAttrs: ParserT): Promise> { + return new Promise((resolve, reject) => { + $.ajax({ + url, + type: 'GET' + }) + .then((response, _textStatus, jqXHR) => { + try { + const linkHeader = jqXHR.getResponseHeader('Link') + const links = parseLinkHeader(linkHeader) + const parsedResponse = Parser.run(response, parseAttrs) + resolve({response: parsedResponse, links}) + } catch (error) { + reject(error) + } + }) + .fail((_jqXHR, _textStatus, errorThrown) => { + reject(new Error(errorThrown)) + }) + }) +} diff --git a/src/process.ts b/src/process.ts deleted file mode 100644 index 8afd829..0000000 --- a/src/process.ts +++ /dev/null @@ -1,5 +0,0 @@ -import kebabCase from 'lodash/kebabCase' - -export type MyType = {data: string} - -export const process = (input: MyType): string => kebabCase(input.data) diff --git a/yarn.lock b/yarn.lock index 8ff68d9..9c26092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -399,12 +399,15 @@ __metadata: languageName: node linkType: hard -"@freckle/package-name@workspace:.": +"@freckle/ajax@workspace:.": version: 0.0.0-use.local - resolution: "@freckle/package-name@workspace:." + resolution: "@freckle/ajax@workspace:." dependencies: + "@freckle/maybe": "https://github.com/freckle/maybe-js.git#v2.2.0" + "@freckle/parser": "https://github.com/freckle/parser-js.git#v1.3.2" "@types/jest": ^28.1.1 - "@types/lodash": ^4.14.182 + "@types/jquery": ^3.5.16 + "@types/lodash": ^4.14.191 cpy-cli: 3.1.1 flow-bin: 0.110.0 flowgen: ^1.17.0 @@ -417,6 +420,53 @@ __metadata: languageName: unknown linkType: soft +"@freckle/exhaustive-js@https://github.com/freckle/exhaustive-js.git#v1.0.0": + version: 1.0.0 + resolution: "@freckle/exhaustive-js@https://github.com/freckle/exhaustive-js.git#commit=1480200972984ba530bb7d33ea83dee967391685" + checksum: 5c6903e17e1ec53978a151cace982bf220000458ebc517ed52bea8701f94e06c8f32762b5e671e38c4b6ef833713f40713e8951cdcb61ed7f5637fc98b87efc0 + languageName: node + linkType: hard + +"@freckle/maybe@https://github.com/freckle/maybe-js.git#v2.1.0": + version: 2.0.0 + resolution: "@freckle/maybe@https://github.com/freckle/maybe-js.git#commit=deab1f020e54c644b0836d306b1a57bad9e620a9" + dependencies: + lodash: ^4.17.21 + checksum: a5d4e025e98c24d862fee857336f9bc83c6df8904fdd62fee0693f442af93ce419ee1b693cd7f563ba808ff6b17b91c3135fca5c6804f746ee597b22ef46df38 + languageName: node + linkType: hard + +"@freckle/maybe@https://github.com/freckle/maybe-js.git#v2.2.0": + version: 2.1.0 + resolution: "@freckle/maybe@https://github.com/freckle/maybe-js.git#commit=163a3fd15a50ee114d6a322aeff7f8fbd9e619ab" + dependencies: + lodash: ^4.17.21 + checksum: 0232e21f9b7467e183ece4ac32e65203d185d03c01c2fe774618d4b6d7dc5a8ac417c9d5a66b1ded46d6eaf43fe39dbc0bcb1af2780c2eb0e9ece760abb4bd1c + languageName: node + linkType: hard + +"@freckle/non-empty-js@https://github.com/freckle/non-empty-js.git#v2.1.1": + version: 2.1.1 + resolution: "@freckle/non-empty-js@https://github.com/freckle/non-empty-js.git#commit=3c206e1cb15932c345cff96e1fcdd9e7c52cdbf8" + dependencies: + "@freckle/maybe": "https://github.com/freckle/maybe-js.git#v2.1.0" + lodash: 4.17.21 + checksum: a457aba1cdcb3300cb2635c63588ad220cb64caf0699f5f15f4cc5c03639af1cfb27b3ba2934a4596f77a66deb3ca46a93daaa0b1674a54b56b6f37f5ab33244 + languageName: node + linkType: hard + +"@freckle/parser@https://github.com/freckle/parser-js.git#v1.3.2": + version: 1.3.2 + resolution: "@freckle/parser@https://github.com/freckle/parser-js.git#commit=06c9f91dcfe34c1dcdb9a3633fc360d10d3ebd0a" + dependencies: + "@freckle/exhaustive-js": "https://github.com/freckle/exhaustive-js.git#v1.0.0" + "@freckle/non-empty-js": "https://github.com/freckle/non-empty-js.git#v2.1.1" + lodash: 4.17.21 + moment-timezone: 0.5.35 + checksum: 8733c7d141f2577e6b36667576221093dbc68a234645fd752ebdf9910e228caa275ea044d00fef689c2063af98dc8dd39aee804c4e29d3015b40b1a7ea36c89f + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -965,6 +1015,15 @@ __metadata: languageName: node linkType: hard +"@types/jquery@npm:^3.5.16": + version: 3.5.16 + resolution: "@types/jquery@npm:3.5.16" + dependencies: + "@types/sizzle": "*" + checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732 + languageName: node + linkType: hard + "@types/jsdom@npm:^20.0.0": version: 20.0.1 resolution: "@types/jsdom@npm:20.0.1" @@ -976,7 +1035,7 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.182": +"@types/lodash@npm:^4.14.191": version: 4.14.191 resolution: "@types/lodash@npm:4.14.191" checksum: ba0d5434e10690869f32d5ea49095250157cae502f10d57de0a723fd72229ce6c6a4979576f0f13e0aa9fbe3ce2457bfb9fa7d4ec3d6daba56730a51906d1491 @@ -1018,6 +1077,13 @@ __metadata: languageName: node linkType: hard +"@types/sizzle@npm:*": + version: 2.3.3 + resolution: "@types/sizzle@npm:2.3.3" + checksum: 586a9fb1f6ff3e325e0f2cc1596a460615f0bc8a28f6e276ac9b509401039dd242fa8b34496d3a30c52f5b495873922d09a9e76c50c2ab2bcc70ba3fb9c4e160 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -3664,7 +3730,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.20, lodash@npm:^4.17.21": +"lodash@npm:4.17.21, lodash@npm:^4.17.20, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -3994,6 +4060,22 @@ __metadata: languageName: node linkType: hard +"moment-timezone@npm:0.5.35": + version: 0.5.35 + resolution: "moment-timezone@npm:0.5.35" + dependencies: + moment: ">= 2.9.0" + checksum: 0f3907282dc9ae3d405fefaccf486dc4222945ff479127fd269e6c4ddc25e526e7ca9e849d6bf941c871bd17e875b256bdb276137a55db9fce4177c792a003df + languageName: node + linkType: hard + +"moment@npm:>= 2.9.0": + version: 2.29.4 + resolution: "moment@npm:2.29.4" + checksum: 0ec3f9c2bcba38dc2451b1daed5daded747f17610b92427bebe1d08d48d8b7bdd8d9197500b072d14e326dd0ccf3e326b9e3d07c5895d3d49e39b6803b76e80e + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0"