Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: decoupling the spec fetching process from the caching library #428

Merged
merged 1 commit into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 51 additions & 105 deletions packages/api/src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OASDocument } from './types';
import type { OASDocument } from 'oas/@types/rmoas.types';

import 'isomorphic-fetch';
import OpenAPIParser from '@readme/openapi-parser';
import yaml from 'js-yaml';
import crypto from 'crypto';
import findCacheDir from 'find-cache-dir';
import fs from 'fs';
Expand All @@ -11,6 +11,8 @@ import makeDir from 'make-dir';

import { PACKAGE_NAME } from './packageInfo';

import Fetcher from './fetcher';

type CacheStore = Record<string, { path: string; original: string | OASDocument; title?: string; version?: string }>;

export default class Cache {
Expand All @@ -26,21 +28,16 @@ export default class Cache {

cached: false | CacheStore;

fetcher: Fetcher;

constructor(uri: string | OASDocument) {
Cache.setCacheDir();
Cache.cacheStore = path.join(Cache.dir, 'cache.json');
Cache.specsCache = path.join(Cache.dir, 'specs');

// Resolve OpenAPI definition shorthand accessors from within the ReadMe API Registry.
//
// Examples:
// - @petstore/v1.0#n6kvf10vakpemvplx
// - @petstore#n6kvf10vakpemvplx
this.uri =
typeof uri === 'string'
? uri.replace(/^@[a-zA-Z0-9-_]+\/?(.+)#([a-z0-9]+)$/, 'https://dash.readme.com/api/v1/api-registry/$2')
: uri;
this.fetcher = new Fetcher(uri);

this.uri = this.fetcher.uri;
this.uriHash = Cache.getCacheHash(this.uri);

// This should default to false so we have awareness if we've looked at the cache yet.
Expand Down Expand Up @@ -88,6 +85,28 @@ export default class Cache {
await fs.promises.rmdir(Cache.specsCache, { recursive: true });
}

static validate(json: any) {
if (json.swagger) {
throw new Error('Sorry, this module only supports OpenAPI definitions.');
}

// The `validate` method handles dereferencing for us.
return OpenAPIParser.validate(json, {
dereference: {
// If circular `$refs` are ignored they'll remain in the API definition as
// `$ref: String`. This allows us to not only do easy circular reference detection but
// also stringify and save dereferenced API definitions back into the cache directory.
circular: 'ignore',
},
}).catch(err => {
if (/is not a valid openapi definition/i.test(err.message)) {
throw new Error("Sorry, that doesn't look like a valid OpenAPI definition.");
}

throw err;
});
}

isCached() {
const cache = this.getCache();
return cache && this.uriHash in cache;
Expand Down Expand Up @@ -129,109 +148,36 @@ export default class Cache {
return this.uri;
}

try {
const url = new URL(this.uri);
this.uri = url.href;
return this.fetcher.load().then(async spec => this.save(spec));
}

return this.saveUrl();
} catch (err) {
return this.saveFile();
save(spec: OASDocument) {
if (!fs.existsSync(Cache.dir)) {
fs.mkdirSync(Cache.dir, { recursive: true });
}
}

save(json: Record<string, unknown>) {
if (json.swagger) {
throw new Error('Sorry, this module only supports OpenAPI definitions.');
if (!fs.existsSync(Cache.specsCache)) {
fs.mkdirSync(Cache.specsCache, { recursive: true });
}

return Promise.resolve(json)
.then((res: any) => {
// The `validate` method handles dereferencing for us.
return OpenAPIParser.validate(res, {
dereference: {
// If circular `$refs` are ignored they'll remain in the API definition as
// `$ref: String`. This allows us to not only do easy circular reference detection but
// also stringify and save dereferenced API definitions back into the cache directory.
circular: 'ignore',
},
}).catch(err => {
if (/is not a valid openapi definition/i.test(err.message)) {
throw new Error("Sorry, that doesn't look like a valid OpenAPI definition.");
}

throw err;
});
})
.then(async spec => {
if (!fs.existsSync(Cache.dir)) {
fs.mkdirSync(Cache.dir, { recursive: true });
}

if (!fs.existsSync(Cache.specsCache)) {
fs.mkdirSync(Cache.specsCache, { recursive: true });
}

const cache = this.getCache();
if (!(this.uriHash in cache)) {
const saved = JSON.stringify(spec, null, 2);
const jsonHash = crypto.createHash('md5').update(saved).digest('hex');

cache[this.uriHash] = {
path: path.join(Cache.specsCache, `${jsonHash}.json`),
original: this.uri,
title: 'title' in spec.info ? spec.info.title : undefined,
version: 'version' in spec.info ? spec.info.version : undefined,
};

fs.writeFileSync(cache[this.uriHash].path, saved);
fs.writeFileSync(Cache.cacheStore, JSON.stringify(cache, null, 2));

this.cached = cache;
}

return spec;
});
}
const cache = this.getCache();
if (!(this.uriHash in cache)) {
const saved = JSON.stringify(spec, null, 2);
const jsonHash = crypto.createHash('md5').update(saved).digest('hex');

saveUrl() {
const url = this.uri as string;
return fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`Unable to retrieve URL (${url}). Reason: ${res.statusText}`);
}

if (res.headers.get('content-type') === 'application/yaml' || /\.(yaml|yml)/.test(url)) {
return res.text().then(text => {
return yaml.load(text);
});
}

return res.json();
})
.then((json: Record<string, unknown>) => this.save(json));
}
cache[this.uriHash] = {
path: path.join(Cache.specsCache, `${jsonHash}.json`),
original: this.uri,
title: 'title' in spec.info ? spec.info.title : undefined,
version: 'version' in spec.info ? spec.info.version : undefined,
};

saveFile() {
// Support relative paths by resolving them against the cwd.
this.uri = path.resolve(process.cwd(), this.uri as string);
fs.writeFileSync(cache[this.uriHash].path, saved);
fs.writeFileSync(Cache.cacheStore, JSON.stringify(cache, null, 2));

if (!fs.existsSync(this.uri)) {
throw new Error(
`Sorry, we were unable to load ${this.uri} OpenAPI definition. Please either supply a URL or a path on your filesystem.`
);
this.cached = cache;
}

const filePath = this.uri;

return Promise.resolve(fs.readFileSync(filePath, 'utf8'))
.then((res: string) => {
if (/\.(yaml|yml)/.test(filePath)) {
return yaml.load(res);
}

return JSON.parse(res);
})
.then(json => this.save(json));
return spec;
}
}
105 changes: 105 additions & 0 deletions packages/api/src/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { OASDocument } from 'oas/@types/rmoas.types';

import 'isomorphic-fetch';
import OpenAPIParser from '@readme/openapi-parser';
import yaml from 'js-yaml';
import fs from 'fs';
import path from 'path';

export default class Fetcher {
uri: string | OASDocument;

constructor(uri: string | OASDocument) {
/**
* Resolve OpenAPI definition shorthand accessors from within the ReadMe API Registry.
*
* @example @petstore/v1.0#n6kvf10vakpemvplx
* @example @petstore#n6kvf10vakpemvplx
*/
this.uri =
typeof uri === 'string'
? uri.replace(/^@[a-zA-Z0-9-_]+\/?(.+)#([a-z0-9]+)$/, 'https://dash.readme.com/api/v1/api-registry/$2')
: uri;
}

async load() {
if (typeof this.uri !== 'string') {
throw new TypeError(
"Something disastrous happened and a non-string URI was supplied to the Fetcher library. This shouldn't have happened!"
);
}

return Promise.resolve(this.uri)
.then(uri => {
try {
const url = new URL(uri);

return Fetcher.getURL(url.href);
} catch (err) {
return Fetcher.getFile(uri);
}
})
.then(res => Fetcher.validate(res))
.then(res => res as OASDocument);
}

static getURL(url: string) {
// @todo maybe include our user-agent here to identify our request
return fetch(url).then(res => {
if (!res.ok) {
throw new Error(`Unable to retrieve URL (${url}). Reason: ${res.statusText}`);
}

if (res.headers.get('content-type') === 'application/yaml' || /\.(yaml|yml)/.test(url)) {
return res.text().then(text => {
return yaml.load(text);
});
}

return res.json();
});
}

static getFile(uri: string) {
// Support relative paths by resolving them against the cwd.
const file = path.resolve(process.cwd(), uri);

if (!fs.existsSync(file)) {
throw new Error(
`Sorry, we were unable to load an API definition from ${file}. Please either supply a URL or a path on your filesystem.`
);
}

return Promise.resolve(fs.readFileSync(file, 'utf8')).then((res: string) => {
if (/\.(yaml|yml)/.test(file)) {
return yaml.load(res);
}

return JSON.parse(res);
});
}

static validate(json: any) {
if (json.swagger) {
throw new Error('Sorry, this module only supports OpenAPI definitions.');
}

// The `validate` method handles dereferencing for us.
return OpenAPIParser.validate(json, {
dereference: {
/**
* If circular `$refs` are ignored they'll remain in the API definition as `$ref: String`.
* This allows us to not only do easy circular reference detection but also stringify and
* save dereferenced API definitions back into the cache directory.
*/
circular: 'ignore',
},
}).catch(err => {
if (/is not a valid openapi definition/i.test(err.message)) {
throw new Error("Sorry, that doesn't look like a valid OpenAPI definition.");
}

throw err;
});
}
}
3 changes: 1 addition & 2 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Operation } from 'oas';
import type { OASDocument } from './types';
import type { HttpMethods } from 'oas/@types/rmoas.types';
import type { HttpMethods, OASDocument } from 'oas/@types/rmoas.types';
import type { ConfigOptions } from './core';

import Oas from 'oas';
Expand Down
9 changes: 0 additions & 9 deletions packages/api/src/types.ts

This file was deleted.

4 changes: 3 additions & 1 deletion packages/api/test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable mocha/no-setup-in-describe */
import type { OASDocument } from 'oas/@types/rmoas.types';

import { assert, expect } from 'chai';
import nock from 'nock';
import uniqueTempDir from 'unique-temp-dir';
Expand All @@ -22,7 +24,7 @@ describe('#auth()', function () {
});

beforeEach(function () {
sdk = api(securityOas);
sdk = api(securityOas as unknown as OASDocument);
});

describe('API Keys', function () {
Expand Down
Loading