├── .gitattributes
├── .gitignore
├── LICENSE
├── README.md
├── assets
└── images
│ └── logo.png
├── js
├── index.js
└── modules
│ ├── LZW.js
│ ├── Parser.js
│ ├── PromiseStream.js
│ ├── SmartRequest.js
│ ├── ping.js
│ └── utils.js
├── node
└── index.js
├── package-lock.json
├── package.json
└── test
└── index.html
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.js text eol=lf
2 | *.html text eol=lf
3 | *.css text eol=lf
4 | *.md text eol=lf
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | *.pdn
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 maanlamp
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # OBA-wrapper
4 | The OBA ([Openbare Bibliotheek Amsterdam](https://oba.nl)) has a public API that is usable by *everyone* to create very cool stuff; [here is a list of such cool stuff](https://www.oba.nl/actueel/obahva/techtrack.html).
5 |
6 | Sadly, the API is a bit clunky, so I set out to make it easy to work with!
7 |
8 | _Built and maintained by [@maanlamp](https://github.com/maanlamp)._
9 |
10 | > Don't forget to ⭐ the repo if you like it :)
11 |
12 |
13 |
14 | ---
15 |
16 |
17 |
18 | ## Glossary
19 |
20 | Click to expand
21 |
22 | - [OBA-wrapper](#oba-wrapper)
23 | - [Glossary](#glossary)
24 | - [User feedback](#user-feedback)
25 | - [Getting started](#getting-started)
26 | - [ES6 Module](#es6-module)
27 | - [Node](#node)
28 | - [Sandbox](#sandbox)
29 | - [Iteration plan / planned features](#iteration-plan--planned-features)
30 | - [Tips for understanding the docs](#tips-for-understanding-the-docs)
31 | - [Technologies](#technologies)
32 | - [Simple Promise (Native promises)](#simple-promise-native-promises)
33 | - [How to use](#how-to-use)
34 | - [Promise streaming (Concurrency)](#promise-streaming-concurrency)
35 | - [How to use](#how-to-use-1)
36 | - [PromiseStream.prepend (*any[]:* ...values) -> PromiseStream
](#codepromisestreamprepend-any-values---promisestreamcode)
37 | - [PromiseStream.append (*any[]:* ...values) -> PromiseStream
](#codepromisestreamappend-any-values---promisestreamcode)
38 | - [PromiseStream.insert (*number*: index?, *any[]:* ...values) -> PromiseStream
](#codepromisestreaminsert-number-index-any-values---promisestreamcode)
39 | - [PromiseStream.pipe (*function:* through) -> PromiseStream
](#codepromisestreampipe-function-through---promisestreamcode)
40 | - [PromiseStream.pipeOrdered(*function:* through) -> PromiseStream
](#codepromisestreampipeorderedfunction-through---promisestreamcode)
41 | - [PromiseStream.all () -> Promise
](#codepromisestreamall----promiseanycode)
42 | - [PromiseStream.catch (*function:* handler) -> PromiseStream
](#codepromisestreamcatch-function-handler---promisestreamcode)
43 | - [Asynchronous iterator (Consecutiveness)](#asynchronous-iterator-consecutiveness)
44 | - [How to use](#how-to-use-2)
45 | - [for await ... of ...
](#codefor-await--of-code)
46 | - ["Smart" Requests](#%22smart%22-requests)
47 | - [How to use](#how-to-use-3)
48 | - [smartRequest(_url_: url, _object_: options?) -> Promise\
](#codesmartrequesturl-url-object-options---promiseresponsecode)
49 | - [License](#license)
50 |
51 |
52 |
53 |
54 |
55 |
56 | ---
57 |
58 |
59 |
60 | ## User feedback
61 | > **Impressive! Also a bit overengineered. **
62 | > \- _[Rijk van Zanten](https://github.com/rijkvanzanten), 2019_
63 |
64 | > **Your feedback here?**
65 | > \- Name
66 |
67 |
68 |
69 |
70 |
71 |
72 | ---
73 |
74 |
75 |
76 | ## Getting started
77 |
78 | Install the module with a package manager:
79 | ```shell
80 | npm i github:maanlamp/OBA-wrapper
81 | ```
82 |
83 | Or just download the repo as a ZIP.
84 |
85 | ### ES6 Module
86 | To use the es6 modules version, link to it in your html using a script tag:
87 | ```html
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | ```
97 |
98 | Or import it in another module:
99 | ```js
100 | import { API } from "OBA-wrapper/js/index.js";
101 | ```
102 |
103 | - Note that it is not needed to import it at the bottom of a `` tag, since a module will always be loaded after the document.
104 |
105 | - `type="module"` is *VERY* important.
106 |
107 | - Also note that if you use a package manager, the url will probably be different. For example: for npm the url would be `node_modules/OBA-wrapper/js/index.js`.
108 |
109 | ### Node
110 | ```js
111 | const API = require("OBA-wrapper/node");
112 | ```
113 |
114 | ### Sandbox
115 |
116 | The quickest way to start a working request is as follows:
117 | ```js
118 | (async () => {
119 | localStorage.clear();
120 |
121 | const api = new API({
122 | key: "ADD YOUR KEY HERE"
123 | });
124 | const stream = await api.createStream("search/banaan{5}");
125 |
126 | stream
127 | .pipe(console.log)
128 | .catch(console.error);
129 | })();
130 | ```
131 |
132 | You can also [just have some fun inside the sandbox](https://oba-wrapper-playground.netlify.com/)!
133 |
134 |
135 |
136 |
137 | ---
138 |
139 |
140 |
141 |
142 | ## Iteration plan / planned features
143 |
144 | | Symbol | Description |
145 | |-|-|
146 | | 🏃 | Will be in next release |
147 | | 💪 | Expected in next release |
148 | | ⚫️ | Under discussion |
149 |
150 | - [x] _Make server-side usage possible._
151 | - [x] _Separate `api._ping()` into own module_
152 | - [x] _Allow other formats than text in smartRequest_
153 | - [ ] 💪 If HTTP 429, respect `Retry-After` response header (instead of exponential backoff).
154 | - [ ] 🏃 Give users control over what to cache in smartRequest
155 | - [ ] 🏃 Allow offset requests (either set start page or define offset as items/pagesize)
156 | - [ ] ⚫️ Make a `[Symbol().asyncIterator]` for stream
157 | - [ ] ⚫️ Builtin filter
158 | - [ ] ⚫️ "Revivable" smart requests.
159 | - [ ] ⚫️ Expand getFetchSafeOptions in smartRequest
160 |
161 |
162 |
163 |
164 | ---
165 |
166 |
167 |
168 | ## Tips for understanding the docs
169 |
170 | Click to expand
171 |
172 | Methods are described as such:
173 | ##### methodName (_type_: argument) -> returnValue
174 | Typing is not enforced, but for clarity.
175 | When a method has no (explicit) return value, it is omitted in the description:
176 | ##### methodName (_type_: argument)
177 | Optional arguments are suffixed with a `?`:
178 | ##### methodName (_type_: optionalArgument?)
179 | When a method returns a *Promise*, the value of its fulfillment is denoted between angled brackets `< >`:
180 | ##### methodName () -> promise\
181 |
182 |
183 |
184 |
185 |
186 |
187 | ---
188 |
189 |
190 |
191 | ## Technologies
192 | Interfacing with the API can be done in several ways. This is to facilitate the coding style of everyone while using the wrapper.
193 |
194 |
195 |
196 |
197 | ### Simple Promise (Native promises)
198 | To use the API as some sort of `fetch` request, use the method `createPromise`, which will return a Promise that resolves to an array of responses.
199 |
200 | #### How to use
201 | To create a Promise through the wrapper, you simply call its method `createPromise`, which will return a promise that that resolves to an array of responses. This has no special methods. [Refer to the Promise specification for more information.](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)
202 |
203 | An example:
204 | ```js
205 | //Imagine the functions toJson, cleanJSON and
206 | //renderToDocument exist, and do what their
207 | //name says.
208 | const requests = await api.createPromise("endpoint/query");
209 | requests
210 | .then(responses => {
211 | const mapped = responses.map(toJSON);
212 | return Promise.all(mapped);
213 | }).then(jsons => {
214 | const cleaned = responses.map(cleanJSON);
215 | return Promise.all(cleaned);
216 | }).then(cleanJsons => {
217 | cleanJsons.forEach(renderToDocument);
218 | });
219 | ```
220 |
221 |
222 |
223 |
224 | ### Promise streaming (Concurrency)
225 | A _PromiseStream_ is a class that allows the "piping" of promises through functions. It is not like a Node Stream, since those require you to pipe into another stream. For those who understand streams, they are almost the same, just with functions. For those who do not know streams, let me introduce to you the wonderful world of streams! 😍
226 |
227 | #### How to use
228 | To create a PromiseStream through the wrapper, you simply call its method `createStream`, which will return a promise that resolves into a new PromiseStream. The stream has several methods:
229 |
230 | ##### PromiseStream.prepend (*any[]:* ...values) -> PromiseStream
231 | Inserts values at the beginning of the stream. `values` do not have to be promises, the stream will internally convert all values to promises.
232 |
233 | ##### PromiseStream.append (*any[]:* ...values) -> PromiseStream
234 | Inserts values at the end of the stream. `values` do not have to be promises, the stream will internally convert all values to promises.
235 |
236 | ##### PromiseStream.insert (*number*: index?, *any[]:* ...values) -> PromiseStream
237 | Inserts values into the stream at `index`. `values` do not have to be promises, the stream will internally convert all values to promises. If `index` is not provided, it will be treated as `values`.
238 |
239 | ##### PromiseStream.pipe (*function:* through) -> PromiseStream
240 | ⚠️ _Does not pipe in order! Use [PromiseStream.pipeOrdered](#codepromisestreampipeorderedfunction-through---promisestreamcode) instead._
241 |
242 | Runs a function `through` for every resolved promise in the stream. Accepts both synchronous and asynchronous functions. Returns a new stream filled with promises that resolve to the value of `through`, so you can chain them (and use previous values).
243 |
244 | An example:
245 | ```js
246 | //Imagine the functions toJson, cleanJSON and
247 | //renderToDocument exist, and do what their
248 | //name says.
249 | const stream = await api.createStream("endpoint/query");
250 | stream
251 | .pipe(toJSON)
252 | .pipe(cleanJSON)
253 | .pipe(renderToDocument);
254 | ```
255 |
256 | ##### PromiseStream.pipeOrdered(*function:* through) -> PromiseStream
257 | Runs a function `through` for every resolved promise in the stream, waiting for each previous resolvement. Accepts both synchronous and asynchronous functions. Returns a new stream filled with promises that resolve to the value of `through`, so you can chain them (and use previous values).
258 |
259 | ##### PromiseStream.all () -> Promise
260 | Shorthand for calling `Promise.all(stream.promises)`.
261 |
262 | ##### PromiseStream.catch (*function:* handler) -> PromiseStream
263 | Adds a `.catch()` to every promise to allow for individual error handling. If you just want to handle all errors at once, use `.all().catch()`.
264 |
265 |
266 |
267 |
268 | ### Asynchronous iterator (Consecutiveness)
269 | An iterator is a protocol used in JavaScript to iterate over enumerable objects. If that makes no sense to you, I mean things like arrays. You can loop (_iterate_) over those.
270 |
271 | However, arrays have synchronous iterators. That means they do not `await` the values inside, so you cannot use them for promises.
272 |
273 | But don't fret! I've made a custom asynchronous iterator for you! Simply call the API's method `createIterator`, which will return a promise that resolves into an asynchrounous array iterator. How to use it? Let me show you:
274 |
275 | #### How to use
276 |
277 | ##### for await ... of ...
278 | Because the iterator is asynchronous, you can use it within a `for await of` loop. If you have no idea what that means, take a look:
279 |
280 | ```js
281 | //Imagine the functions toJson, cleanJSON and
282 | //renderToDocument exist, and do what their
283 | //name says.
284 | const iterator = await api.createIterator("endpoint/query");
285 | for await (const response of iterator) {
286 | const json = toJSON(response);
287 | const cleanedJSON = cleanJSON(json);
288 | renderToDocument(cleanedJSON);
289 | }
290 | ```
291 | This will do the same as [this PromiseStream example](#codepromisestreampipe-function-through---promisestreamcode).
292 |
293 |
294 |
295 |
296 | ### "Smart" Requests
297 | A smart request is a request that retries 4 times ([implementing exponential backoff](https://developers.google.com/analytics/devguides/reporting/core/v3/errors#backoff)), but only if the reason of failure is not a fatal one (i.e. "*userRateLimitExceeded*", etc...).
298 |
299 | This means that there will be a greater chance of recovering from (accidental) rate limit exceedances or internal server errors.
300 |
301 | Besides that, it will use `localStorage` to cache responses by url, so later smart requests can check if their provided url was already cached. Blazingly fast 🔥!
302 |
303 | #### How to use
304 | You should not have to use a SmartRequest directly, since this wrapper uses them under the hood. You could use them _standalone_ for other purposes though. You can make use of the following methods:
305 |
306 | ##### smartRequest(_url_: url, _object_: options?) -> Promise\
307 | Sends out a fetch request that retries `options.maxTries` (defaults to `5`) times if possible. If a fatal error occured, or the maximum amount of tries was exceeded, the promise rejects with an error. If all went well, it will cache the result from url in localStorage with the key of `url`, and resolve with a response.
308 |
309 |
310 |
311 |
312 | ---
313 |
314 |
315 |
316 | ## License
317 | Licensed under MIT - Copyright © 2019 [maanlamp](https://github.com/maanlamp)
--------------------------------------------------------------------------------
/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maanlamp/OBA-wrapper/fb79eef9d8622972a3275acd65943b414c94831d/assets/images/logo.png
--------------------------------------------------------------------------------
/js/index.js:
--------------------------------------------------------------------------------
1 | import { range } from "./modules/utils.js";
2 | import { detectPingError, buildPong, supressPingError } from "./modules/ping.js";
3 | import PromiseStream from "./modules/PromiseStream.js";
4 | import { XMLToJSON, handleJSONParserError, cleanAquabrowserJSON } from "./modules/Parser.js";
5 | import smartRequest from "./modules/SmartRequest.js";
6 |
7 | export class API {
8 | constructor ({
9 | CORSProxy = "https://cors-anywhere.herokuapp.com/",
10 | baseURL = "https://zoeken.oba.nl/api/v1/",
11 | key = "NO_KEY_PROVIDED"
12 | } = {}) {
13 | this._context = null;
14 | this._URL = CORSProxy
15 | + baseURL
16 | + "ENDPOINT" //will be `.replace`d later (is this a good practise?)
17 | + "?authorization="
18 | + key;
19 | }
20 |
21 | static logError (error) {
22 | console.error(
23 | "%c%s\n%c%s",
24 | "color: #F92672;",
25 | error.message,
26 | "color: black;",
27 | error.stack.replace(/^.+\n/, ""));
28 |
29 | throw error;
30 | }
31 |
32 | _parsePartial (partial) {
33 | //if (partial.toString() === "[object Object]") return {}; //If partial is omitted, allow for options fallback
34 | //Expect: endpoint/query (can include spaces){count?,pagesize?}
35 | const regex = /(?\w+)\/?(?[^/\{]+)(?:\{(?\d+)?(?:,\s*)?(?\d+)?\})?/;
36 | if (!regex.test(partial)) throw new Error(`'${partial}' is not a valid endpoint and/or query.`);
37 |
38 | const {
39 | endpoint,
40 | value,
41 | max = 20,
42 | pagesize = 20
43 | } = partial.match(regex).groups;
44 | if (pagesize > 20) console.warn(`API supports, at most, 20 results at a time – not ${pagesize}.`);
45 |
46 | const query = (() => {
47 | switch (endpoint) {
48 | case "search": return "q";
49 | case "details": //fallthrough
50 | case "availability": return "id";
51 | default: throw new Error(`Unknown/unsupported endpoint '${endpoint}'`);
52 | }
53 | })();
54 |
55 | return {
56 | endpoint,
57 | query: encodeURI(`&${query}=${value.trim()}`),
58 | max: Number(max),
59 | pagesize: Math.min(
60 | Number(max),
61 | Number(pagesize))
62 | };
63 | }
64 |
65 | async _getRequestSpecifics (partial, options) {
66 | if (partial === undefined) throw new Error("Failed to get request specifics. Did you forget a url?");
67 |
68 | const {
69 | endpoint,
70 | query,
71 | max,
72 | pagesize
73 | } = Object.assign({}, this._parsePartial(partial), options);
74 | const url = this._URL.replace("ENDPOINT", endpoint) + query;
75 | const {count, context} = await this._ping(url, this._context);
76 | const batches = Math.ceil(Math.min(max, count) / pagesize);
77 | const builtURL = url + `&pagesize=${pagesize}&refine=true`;
78 |
79 | this._context = context;
80 |
81 | return {batches, builtURL, count};
82 | }
83 |
84 | _ping (url, context) {
85 | const builtURL = (context !== null)
86 | ? url + `&pagesize=1&refine=false&rctx=${context}`
87 | : url + `&pagesize=1&refine=false`;
88 |
89 | return fetch(builtURL) //test if it's beneficial to use smartrequest here
90 | .then(detectPingError)
91 | .then(res => res.text())
92 | .then(XMLToJSON)
93 | .then(handleJSONParserError)
94 | .then(buildPong)
95 | .catch(supressPingError);
96 | }
97 |
98 | async createStream (partial, options = {}) {
99 | const {
100 | batches,
101 | builtURL,
102 | count
103 | } = await this._getRequestSpecifics(partial, options);
104 |
105 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
106 |
107 | return new PromiseStream(
108 | range(batches)
109 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`)
110 | .map(url => smartRequest(url)))
111 | .pipe(XMLToJSON)
112 | .pipe(cleanAquabrowserJSON)
113 | .catch(API.logError);
114 | }
115 |
116 | async createIterator (partial, options = {}) {
117 | const {
118 | batches,
119 | builtURL,
120 | count
121 | } = await this._getRequestSpecifics(partial, options);
122 |
123 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
124 |
125 | async function* iterator () {
126 | const requests = range(batches)
127 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`);
128 |
129 | while (requests.length > 0) {
130 | const url = requests.shift();
131 | yield await smartRequest(url)
132 | .then(XMLToJSON)
133 | .then(cleanAquabrowserJSON)
134 | .catch(API.logError);
135 | }
136 | }
137 |
138 | return iterator.call(this);
139 | }
140 |
141 | async createPromise (partial, options = {}) {
142 | const {
143 | batches,
144 | builtURL,
145 | count
146 | } = await this._getRequestSpecifics(partial, options);
147 |
148 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
149 |
150 | return range(batches)
151 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`)
152 | .map(url => smartRequest(url)
153 | .then(XMLToJSON)
154 | .then(cleanAquabrowserJSON)
155 | .catch(API.logError));
156 | }
157 |
158 | availability (frabl) { //I hate this "solution", please fix dis kty <3
159 | const url = this._URL.replace("ENDPOINT", "availability");
160 | return smartRequest(url + `&frabl=${frabl}`)
161 | .then(XMLToJSON)
162 | .catch(API.logError);
163 | }
164 |
165 | details (frabl) { //I hate this "solution", please fix dis kty <3
166 | const url = this._URL.replace("ENDPOINT", "details");
167 | return smartRequest(url + `&frabl=${frabl}`)
168 | .then(XMLToJSON)
169 | .catch(API.logError);
170 | }
171 | }
172 |
173 | window.API = API;
--------------------------------------------------------------------------------
/js/modules/LZW.js:
--------------------------------------------------------------------------------
1 | //Copied from
2 | //https://gist.github.com/revolunet/843889#gistcomment-2795911
3 | //Slightly edited and minified :)
4 |
5 | export default class LWZ{static compress(t){if(!t)return t;const e=new Map,r=(t+"").split("");let s,h=[],l=r[0],n=256;for(let t=1;t1?e.get(l):l.charCodeAt(0)),e.set(l+s,n),n++,l=s);h.push(l.length>1?e.get(l):l.charCodeAt(0));for(let t=0;t 0) {
16 | json["_attributes"] = {};
17 | }
18 |
19 | for (const attr of xmldoc.attributes) {
20 | json["_attributes"][attr.nodeName] = attr.nodeValue;
21 | }
22 | } else if (xmldoc.nodeType === nodeTypes["text"]) {
23 | json = xmldoc.nodeValue;
24 | }
25 |
26 | if (xmldoc.hasChildNodes()) {
27 | for (const child of xmldoc.childNodes) {
28 | const nodeName = child.nodeName.replace(/^#/, "_");
29 |
30 | if (json[nodeName] === undefined) {
31 | json[nodeName] = parse(child);
32 | } else {
33 | if (json[nodeName].push === undefined) {
34 | json[nodeName] = Array(json[nodeName]);
35 | }
36 | json[nodeName].push(parse(child));
37 | }
38 | }
39 | }
40 |
41 | return json;
42 | }
43 |
44 | return parse(doc);
45 | }
46 |
47 | export function JSONToXML (json) {
48 | //?
49 | }
50 |
51 | export function handleJSONParserError (json) {
52 | const err = json.aquabrowser.error;
53 | if (err) throw new Error(`${err.code._text} ${err.reason._text}`);
54 | return json;
55 | }
56 |
57 | export function filterWhitespaceElements (val) {
58 | if (typeof val.filter !== "function") return val;
59 | return val.filter(item => {
60 | return (typeof item === "string") ? /^\S+$/.test(item) : item;
61 | });
62 | }
63 |
64 | export function cleanAquabrowserJSON (json) {
65 | const root = json.aquabrowser.results.result;
66 |
67 | //What have I done...
68 | // function clean (item) {
69 | // const [lastname, firstname] = (item.authors||{"main-author":{_text:"defined, un"}})["main-author"]._text.split(", ");
70 | // let [raw, pages, type, size] = /\[?(\d+)\]? (?:ongenummerde)? ?p(?:agina(?:'|’)s)?[^;]*?:? ?([^;]+)? ; (\d+(?:x| ?× ?)?\d* cm)/g.exec((item.description||{"physical-description":{}})["physical-description"]._text) || [null, null, null, null];
71 | // if (!size) size = /.*(\d+(?:x| × )?\d* cm)/g.exec((item.description||{"physical-description":{}})["physical-description"]._text);
72 | // return {
73 | // author: {
74 | // fullname: `${firstname} ${lastname}`,
75 | // firstname: firstname,
76 | // lastname: lastname
77 | // },
78 | // images: [item.coverimages.coverimage].flat().map(coverimage => coverimage._text).filter(url => !url.includes("~")),
79 | // title: {
80 | // // short: (item.titles||{"short-title":{}})["short-title"]._text, //For some reason, this ALWAYS errors.. I don't understand :(
81 | // full: item.titles.title._text
82 | // },
83 | // format: item.formats.format._text,
84 | // identifiers: Object.entries(item.identifiers||{}).map(([identifier, body]) => {return {[identifier]: body._text}}),
85 | // publication: {
86 | // year: (item.publication||{year:{}}).year._text,
87 | // publisher: (item.publication||{publishers:{publisher:{}}}).publishers.publisher._text,
88 | // place: (item.publication||{publishers:{publisher:{place:undefined}}}).publishers.publisher.place
89 | // },
90 | // languages: {
91 | // this: (item.languages||{language:{}}.language)._text,
92 | // original: ((item.languages||{})["original-language"] || (item.languages||{}).language || {})._text
93 | // },
94 | // subjects: [(item.subjects||{})["topical-subject"]||{}].flat().map(subject => subject._text),
95 | // genres: [(item.genres||{genre:{}}).genre].flat().map(genre => genre._text),
96 | // characteristics: {
97 | // pages: Number(pages),
98 | // size: size,
99 | // types: (type||"").split(",").map(string => string.trim()),
100 | // raw: raw
101 | // },
102 | // summary: (item.summaries||{summary:{}}).summary._text,
103 | // notes: [(item.notes || {}).note||{}].flat().map(note => note._text || null).filter(note => note !== null),
104 | // targetAudiences: [(item["target-audiences"] && item["target-audiences"] || {})["target-audience"]||{}].flat().map(audience => audience._text || null).filter(audience => audience !== null),
105 | // series: ((item.series && item.series["series-title"] && item.series["series-title"]._text) || null)
106 | // };
107 | // }
108 |
109 | // return root.map(clean);
110 | return root;
111 | }
--------------------------------------------------------------------------------
/js/modules/PromiseStream.js:
--------------------------------------------------------------------------------
1 | export default class PromiseStream {
2 | constructor (array = []) {
3 | this.promises = this._promisify(array);
4 | }
5 |
6 | _promisify (values) {
7 | return values.map(item => {
8 | return (item instanceof Promise) ? item : Promise.resolve(item);
9 | });
10 | }
11 |
12 | prepend (...values) {
13 | this.promises.unshift(...this._promisify(values));
14 | return this;
15 | }
16 |
17 | append (...values) {
18 | this.promises.push(...this._promisify(values));
19 | return this;
20 | }
21 |
22 | insert (position, ...values) {
23 | if (values.length === 0) return this.append(position);
24 | this.promises.splice(index, 0, ...values);
25 | return this;
26 | }
27 |
28 | pipe (callback) { //.then everything, not waiting for previous, not passing val of prev cb
29 | this.promises = this.promises
30 | .map((promise, index, source) => promise
31 | .then(value => callback(value, index, source)));
32 |
33 | return this;
34 | }
35 |
36 | pipeOrdered (callback) { //.then everything, waiting for previous, not passing val of prev cb
37 | this.promises = this.promises.reduce((acc, promise, index, source) => {
38 | const prev = acc[acc.length - 1];
39 | return [...acc, prev.then(async () => await callback(await promise, index, source))];
40 | }, [Promise.resolve()]);
41 |
42 | return this;
43 | }
44 |
45 | all () {
46 | return Promise.all(this.promises);
47 | }
48 |
49 | catch (callback) {
50 | this.promises = this.promises
51 | .map((promise, index, source) => promise
52 | .catch(async error => await callback(error, index, source)));
53 |
54 | return this;
55 | }
56 | }
--------------------------------------------------------------------------------
/js/modules/SmartRequest.js:
--------------------------------------------------------------------------------
1 | import { timeout } from "./utils.js";
2 | import LWZ from "./LZW.js";
3 |
4 | export default async function smartRequest (url = "", options = {
5 | maxTries: 5,
6 | format: "text"
7 | }) {
8 | //Acts like a simple fetch, but retries 4 times before rejecting if server is busy
9 | //implements exponential backoff https://developers.google.com/analytics/devguides/reporting/core/v3/errors#backoff
10 | //CHECK FOR RETRY-AFTER HEADER IF STATUS === 429 || STATUSTEXT.MATCH("TOO MANY REQUESTS")
11 | //Allow other formats than text
12 | //Give users control over what to cache
13 | //Expand getFetchSafeOptions
14 | const cached = inCache(url);
15 | if (cached !== false) return cached;
16 |
17 | const fetchOptions = getFetchSafeOptions(options);
18 | const maxTries = options.maxTries;
19 | const retryStatusCodes = [500, 502, 503, 504];
20 | const retryStatusTexts = [
21 | "Internal Server Error", //500
22 | "Bad Gateway", //502
23 | "Service Unavailable", //503
24 | "Gateway Timeout" //504
25 | ];
26 |
27 | try {
28 | const response = await $try(url, maxTries);
29 | return await cache(url, await response.text());
30 | } catch (error) {
31 | if (error.status) return error;
32 | throw error;
33 | }
34 |
35 | function getFetchSafeOptions (object) {
36 | return {
37 | headers: {
38 | "Accept": "text/plain, application/xml"
39 | }
40 | };
41 | }
42 |
43 | function padding (tries) {
44 | return tries ** 2 * 1000 + Math.floor(Math.random() * 1000);
45 | }
46 |
47 | function inCache (url) {
48 | const value = localStorage.getItem(url);
49 | return (value !== null)
50 | ? (console.log("Cache match"), LWZ.decompress(value))
51 | : false;
52 | }
53 |
54 | function cannotRetry (error) {
55 | return !(retryStatusCodes.includes(error.status)
56 | || (error.statusText && retryStatusTexts
57 | .some(retryErr => error.statusText
58 | .toLowerCase()
59 | .match(retryErr.toLowerCase()))));
60 | }
61 |
62 | async function $try (url, maxTries, tries = 0) {
63 | if (tries >= maxTries) throw new Error(`Polling limit (${maxTries}) was exceeded without getting a valid response.`);
64 | try {
65 | return await fetch(url, fetchOptions);
66 | } catch (error) {
67 | if (cannotRetry(error)) throw error;
68 | await timeout(padding(tries++));
69 | return $try(url, maxTries, tries);
70 | }
71 | }
72 |
73 | async function cache (key, value) {
74 | localStorage.setItem(String(key), LWZ.compress(value));
75 | return value;
76 | }
77 | }
--------------------------------------------------------------------------------
/js/modules/ping.js:
--------------------------------------------------------------------------------
1 | class Pong {
2 | constructor (count = 0, context = "") {
3 | this.count = Number(count);
4 | this.context = String(context);
5 | }
6 | }
7 |
8 | export function detectPingError (res) {
9 | if (!res.ok) throw new Error(`Cannot ping ${res.url} ${res.status} (${res.statusText})`);
10 | return res;
11 | }
12 |
13 | export function buildPong (json) {
14 | return new Pong(
15 | Number(json.aquabrowser.meta.count._text),
16 | String(json.aquabrowser.meta.rctx._text));
17 | }
18 |
19 | export function supressPingError (err) {
20 | console.warn(`Supressed ${err}`);
21 | return new Pong();
22 | }
--------------------------------------------------------------------------------
/js/modules/utils.js:
--------------------------------------------------------------------------------
1 | export function range (size = 0, end = size) {
2 | //refactor this shizzle?
3 | if (size === end) return [...Array(size).keys()];
4 | return [...Array(end - size).keys()].map((_, i) => i + size);
5 | }
6 |
7 | export function timeout (timeout, code = 200, message = "OK") {
8 | return new Promise((resolve, reject) => {
9 | setTimeout(() => {
10 | resolve({status: code, statusText: message});
11 | }, timeout);
12 | });
13 | }
14 |
15 | export function timeoutFail (timeout, code = 404, message = "Not Found") {
16 | return new Promise((resolve, reject) => {
17 | setTimeout(() => {
18 | reject({status: code, statusText: message});
19 | }, timeout);
20 | });
21 | }
22 |
23 | export function timeoutFailable (timeout, codes = {
24 | succes: 200,
25 | fail: 404
26 | }, messages = {
27 | succes: "OK",
28 | fail: "Service Unavailable"
29 | }) {
30 | return new Promise((resolve, reject) => {
31 | setTimeout(() => {
32 | (Math.random() <= .5)
33 | ? reject({status: codes.fail, statusText: messages.fail})
34 | : resolve({status: codes.succes, statusText: messages.succes});
35 | }, timeout);
36 | });
37 | }
38 |
39 | export function msToSecs (ms) {
40 | return ms * 1000;
41 | }
42 |
43 | export function secsToMs (secs) {
44 | return secs / 1000;
45 | }
46 |
47 | export function isObject (val) {
48 | return Object.prototype.toString.call(val) === "[object Object]";
49 | }
--------------------------------------------------------------------------------
/node/index.js:
--------------------------------------------------------------------------------
1 | const PromiseStream = require("promisestream").default;
2 | const smartfetch = require("smartfetch").default;
3 | const fetch = require("node-fetch");
4 | const XMLParser = require("xml-to-json-promise").xmlDataToJSON;
5 |
6 | const cache = new Map();
7 | const smartfetchOptions = {
8 | fetch,
9 | store: {
10 | get: (key) => cache.get(key),
11 | set: (key, value) => cache.set(key, value)
12 | },
13 | format: "text",
14 | maxTries: 5,
15 | maxTimeout: 30
16 | };
17 |
18 | function range (size = 0, end = size) {
19 | //refactor this shizzle?
20 | if (size === end) return [...Array(size).keys()];
21 | return [...Array(end - size).keys()].map((_, i) => i + size);
22 | }
23 |
24 | class Pong {
25 | constructor (count = 0, context = "") {
26 | this.count = Number(count);
27 | this.context = String(context);
28 | }
29 | }
30 |
31 | function detectPingError (res) {
32 | if (!res.ok) throw new Error(`Cannot ping ${res.url} ${res.status} (${res.statusText})`);
33 | return res;
34 | }
35 |
36 | function buildPong (json) {
37 | return new Pong(
38 | Number(json.aquabrowser.meta[0].count[0]),
39 | String(json.aquabrowser.meta[0].rctx[0]));
40 | }
41 |
42 | function supressPingError (err) {
43 | console.warn(`Supressed ${err}`);
44 | return new Pong();
45 | }
46 |
47 | function XMLToJSON (xml) {
48 | return XMLParser(xml);
49 | }
50 |
51 | function handleJSONParserError (json) {
52 | const err = json.aquabrowser.error;
53 | if (err) throw new Error(`${err.code[0]} ${err.reason[0]}`);
54 | return json;
55 | }
56 |
57 | function cleanAquabrowserJSON (json) {
58 | return json.aquabrowser.results[0].result;
59 | }
60 |
61 | module.exports = class API {
62 | constructor ({
63 | CORSProxy = "https://cors-anywhere.herokuapp.com/",
64 | baseURL = "https://zoeken.oba.nl/api/v1/",
65 | key = "NO_KEY_PROVIDED"
66 | } = {}) {
67 | this._context = null;
68 | this._URL = CORSProxy
69 | + baseURL
70 | + "ENDPOINT" //will be `.replace`d later (is this a good practise?)
71 | + "?authorization="
72 | + key;
73 | }
74 |
75 | static logError (error) {
76 | console.error(
77 | "%c%s\n%c%s",
78 | "color: #F92672;",
79 | error.message,
80 | "color: black;",
81 | error.stack.replace(/^.+\n/, ""));
82 |
83 | throw error;
84 | }
85 |
86 | _parsePartial (partial) {
87 | //if (partial.toString() === "[object Object]") return {}; //If partial is omitted, allow for options fallback
88 | //Expect: endpoint/query (can include spaces){count?,pagesize?}
89 | const regex = /(?\w+)\/?(?[^/\{]+)(?:\{(?\d+)?(?:,\s*)?(?\d+)?\})?/;
90 | if (!regex.test(partial)) throw new Error(`'${partial}' is not a valid endpoint and/or query.`);
91 |
92 | const {
93 | endpoint,
94 | value,
95 | max = 20,
96 | pagesize = 20
97 | } = partial.match(regex).groups;
98 | if (pagesize > 20) console.warn(`API supports, at most, 20 results at a time – not ${pagesize}.`);
99 |
100 | const query = (() => {
101 | switch (endpoint) {
102 | case "search": return "q";
103 | case "details": //fallthrough
104 | case "availability": return "id";
105 | default: throw new Error(`Unknown/unsupported endpoint '${endpoint}'`);
106 | }
107 | })();
108 |
109 | return {
110 | endpoint,
111 | query: encodeURI(`&${query}=${value.trim()}`),
112 | max: Number(max),
113 | pagesize: Math.min(
114 | Number(max),
115 | Number(pagesize))
116 | };
117 | }
118 |
119 | async _getRequestSpecifics (partial, options) {
120 | if (partial === undefined) throw new Error("Failed to get request specifics. Did you forget a url?");
121 |
122 | const {
123 | endpoint,
124 | query,
125 | max,
126 | pagesize
127 | } = Object.assign({}, this._parsePartial(partial), options);
128 | const url = this._URL.replace("ENDPOINT", endpoint) + query;
129 | const {count, context} = await this._ping(url, this._context);
130 | const batches = Math.ceil(Math.min(max, count) / pagesize);
131 | const builtURL = url + `&pagesize=${pagesize}&refine=true`;
132 |
133 | this._context = context;
134 |
135 | return {batches, builtURL, count};
136 | }
137 |
138 | _ping (url, context) {
139 | const builtURL = (context !== null)
140 | ? url + `&pagesize=1&refine=false&rctx=${context}`
141 | : url + `&pagesize=1&refine=false`;
142 |
143 | return fetch(builtURL, {headers:{"Origin": null}}) //test if it's beneficial to use smartrequest here
144 | .then(detectPingError)
145 | .then(res => res.text())
146 | .then(XMLToJSON)
147 | .then(handleJSONParserError)
148 | .then(buildPong)
149 | .catch(supressPingError);
150 | }
151 |
152 | async createStream (partial, options = {}) {
153 | const {
154 | batches,
155 | builtURL,
156 | count
157 | } = await this._getRequestSpecifics(partial, options);
158 |
159 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
160 |
161 | return new PromiseStream(
162 | range(batches)
163 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`)
164 | .map(url => smartfetch(url, smartfetchOptions)))
165 | .pipe(XMLToJSON)
166 | .pipe(cleanAquabrowserJSON)
167 | .catch(API.logError);
168 | }
169 |
170 | async createIterator (partial, options = {}) {
171 | const {
172 | batches,
173 | builtURL,
174 | count
175 | } = await this._getRequestSpecifics(partial, options);
176 |
177 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
178 |
179 | async function* iterator () {
180 | const requests = range(batches)
181 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`);
182 |
183 | while (requests.length > 0) {
184 | const url = requests.shift();
185 | yield await smartfetch(url, smartfetchOptions)
186 | .then(XMLToJSON)
187 | .then(cleanAquabrowserJSON)
188 | .catch(API.logError);
189 | }
190 | }
191 |
192 | return iterator.call(this);
193 | }
194 |
195 | async createPromise (partial, options = {}) {
196 | const {
197 | batches,
198 | builtURL,
199 | count
200 | } = await this._getRequestSpecifics(partial, options);
201 |
202 | if (count === 0) throw new Error(`No results found for '${partial}'.`);
203 |
204 | return range(batches)
205 | .map(index => builtURL + `&page=${index + 1}&rctx=${this._context}`)
206 | .map(url => smartfetch(url, smartfetchOptions)
207 | .then(XMLToJSON)
208 | .then(cleanAquabrowserJSON)
209 | .catch(API.logError));
210 | }
211 |
212 | availability (frabl) { //I hate this "solution", please fix dis kty <3
213 | const url = this._URL.replace("ENDPOINT", "availability");
214 | return smartfetch(url + `&frabl=${frabl}`, smartfetchOptions)
215 | .then(XMLToJSON)
216 | .catch(API.logError);
217 | }
218 |
219 | details (frabl) { //I hate this "solution", please fix dis kty <3
220 | const url = this._URL.replace("ENDPOINT", "details");
221 | return smartfetch(url + `&frabl=${frabl}`, smartfetchOptions)
222 | .then(XMLToJSON)
223 | .catch(API.logError);
224 | }
225 | }
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oba-wrapper",
3 | "version": "1.0.0",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "native-promise-only": {
8 | "version": "0.8.1",
9 | "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz",
10 | "integrity": "sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE="
11 | },
12 | "node-fetch": {
13 | "version": "2.3.0",
14 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz",
15 | "integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA=="
16 | },
17 | "object-assign": {
18 | "version": "4.1.1",
19 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
20 | "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
21 | },
22 | "promisestream": {
23 | "version": "github:maanlamp/promisestream#c682e0cca5cd4ddf486fb7fd11b598284baf843e",
24 | "from": "github:maanlamp/promisestream"
25 | },
26 | "sax": {
27 | "version": "1.2.4",
28 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
29 | "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
30 | },
31 | "smartfetch": {
32 | "version": "github:maanlamp/smartfetch#0e0287fad3b9417f2f1cdc3f607e798dfd427cb6",
33 | "from": "github:maanlamp/smartfetch"
34 | },
35 | "xml-to-json-promise": {
36 | "version": "0.0.3",
37 | "resolved": "https://registry.npmjs.org/xml-to-json-promise/-/xml-to-json-promise-0.0.3.tgz",
38 | "integrity": "sha1-4A7fwp9UY8XP8hgmlg2m9m6QYAc=",
39 | "requires": {
40 | "native-promise-only": "^0.8.1",
41 | "object-assign": "^4.0.1",
42 | "xml2js": "~0.4.16"
43 | }
44 | },
45 | "xml2js": {
46 | "version": "0.4.19",
47 | "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz",
48 | "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==",
49 | "requires": {
50 | "sax": ">=0.6.0",
51 | "xmlbuilder": "~9.0.1"
52 | }
53 | },
54 | "xmlbuilder": {
55 | "version": "9.0.7",
56 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
57 | "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oba-wrapper",
3 | "version": "1.0.0",
4 | "description": "A wrapper to untuitively use the OBA-api",
5 | "main": "js/index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/maanlamp/OBA-wrapper.git"
12 | },
13 | "author": "maanlamp",
14 | "license": "MIT",
15 | "bugs": {
16 | "url": "https://github.com/maanlamp/OBA-wrapper/issues"
17 | },
18 | "homepage": "https://github.com/maanlamp/OBA-wrapper#readme",
19 | "dependencies": {
20 | "node-fetch": "^2.3.0",
21 | "promisestream": "github:maanlamp/promisestream",
22 | "smartfetch": "github:maanlamp/smartfetch",
23 | "xml-to-json-promise": "0.0.3"
24 | },
25 | "devDependencies": {}
26 | }
27 |
--------------------------------------------------------------------------------
/test/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | API Tester
8 |
9 |
10 |
82 |
83 |
84 |
85 |
121 | run!
122 |
123 |
124 |
125 |
153 |
154 |
--------------------------------------------------------------------------------