UNPKG

api

Version:

Magical SDK generation from an OpenAPI definition 🪄

328 lines • 13 kB
import fs from 'node:fs'; import path from 'node:path'; import semver from 'semver'; import ssri from 'ssri'; import validateNPMPackageName from 'validate-npm-package-name'; import { SupportedLanguages } from './codegen/factory.js'; import Fetcher from './fetcher.js'; import { PACKAGE_VERSION } from './packageInfo.js'; export default class Storage { static dir; static lockfile; fetcher; /** * This is the original source that the file came from (relative/absolute file path, URL, ReadMe * registry UUID, etc.). * * @example @developers/v2.0#nysezql0wwo236 * @example https://raw.githubusercontent.com/readmeio/oas-examples/main/3.0/json/petstore-simple.json * @example ./petstore.json */ source; /** * The language that this SDK was generated for. */ language; /** * The identifier that this was installed as. * * @example petstore */ identifier; constructor(source, language, identifier) { Storage.setStorageDir(); this.fetcher = new Fetcher(source); this.source = source; if (language) this.language = language; if (identifier) this.identifier = identifier; // This should default to false so we have awareness if we've looked at the lockfile yet. Storage.lockfile = false; } static getLockfilePath() { return path.join(Storage.dir, 'api.json'); } static getAPIsDir() { return path.join(Storage.dir, 'apis'); } static setStorageDir(dir) { if (dir) { Storage.dir = dir; return; } else if (Storage.dir) { // If we already have a storage dir set and aren't explicitly it to something new then we // shouldn't overwrite what we've already got. return; } Storage.dir = path.join(process.cwd(), '.api'); fs.mkdirSync(Storage.dir, { recursive: true }); fs.mkdirSync(Storage.getAPIsDir(), { recursive: true }); } /** * Retrieves the project's root directory path. * * If a storage directory has not been explicitly set, this method will default it to * the current working directory. It then returns the directory name of the storage path. * */ static getProjectDir() { if (!Storage.dir) { Storage.setStorageDir(); } return path.dirname(Storage.dir); } /** * Reset the state of the entire storage system. * * This will completely destroy the contents of the `.api/` directory! * * @internal */ static async reset() { // oxlint-disable-next-line unicorn/consistent-function-scoping const noop = () => { // If any of these file and directory removals fail we don't need to throw any errors because // this method is only used internally in unit tests. }; if (Storage.getLockfilePath()) { await fs.promises .writeFile(Storage.getLockfilePath(), JSON.stringify(Storage.getDefaultLockfile(), null, 2)) .catch(noop); } if (Storage.getAPIsDir()) { await fs.promises.rm(Storage.getAPIsDir(), { recursive: true }).catch(noop); await fs.promises.mkdir(Storage.getAPIsDir(), { recursive: true }).catch(noop); } } static getDefaultLockfile() { const majorVersion = semver.parse(PACKAGE_VERSION)?.major || 'latest'; return { $schema: `https://unpkg.com/api@${majorVersion}/schema.json`, apis: [], }; } static generateIntegrityHash(definition) { return ssri .fromData(JSON.stringify(definition), { algorithms: ['sha512'], }) .toString(); } static getLockfile() { if (typeof Storage.lockfile === 'object') { return Storage.lockfile; } if (fs.existsSync(Storage.getLockfilePath())) { const file = fs.readFileSync(Storage.getLockfilePath(), 'utf8'); try { Storage.lockfile = JSON.parse(file); } catch { // If we can't parse the lock file for whatever reason then it's probably corrupted so we // should reset it to the default. Storage.lockfile = Storage.getDefaultLockfile(); } } else { Storage.lockfile = Storage.getDefaultLockfile(); } return Storage.lockfile; } static isIdentifierValid(identifier, prefixWithAPINamespace) { // Is this identifier already in storage? if (Storage.isInLockFile({ identifier })) { throw new Error(`"${identifier}" is already taken in your \`.api/\` directory. Please try another identifier.`); } const isValidForNPM = validateNPMPackageName(prefixWithAPINamespace ? `@api/${identifier}` : identifier); if (!isValidForNPM.validForNewPackages) { // `prompts` doesn't support surfacing multiple errors in a `validate` call so we can only // surface the first to the user. throw new Error(`Identifier cannot be used for an NPM package: ${isValidForNPM?.errors?.[0] || '[error unavailable]'}`); } return true; } static isInLockFile(search) { // Because this method may run before we initialize a new storage object we should make sure // that we have a storage directory present. Storage.setStorageDir(); if (!search.identifier && !search.source) { throw new TypeError('An `identifier` or `source` must be supplied to this method to search in the lockfile.'); } const lockfile = Storage.getLockfile(); if (typeof lockfile !== 'object' || lockfile === null || !lockfile.apis) { return false; } const res = lockfile.apis.find(a => { if (search.identifier) { return a.identifier === search.identifier; } return a.source === search.source; }); return res === undefined ? false : res; } setLanguage(language) { // `language` wasn't always present in the lockfile so if we don't have one we should default // to JS. if (!language) { this.language = SupportedLanguages.JS; return; } this.language = language; } setIdentifier(identifier) { this.identifier = identifier; } /** * Determine if the current spec + identifier we're working with is already in the lockfile. */ isInLockfile() { return Boolean(this.getFromLockfile()); } /** * Retrieve the lockfile record for the current spec + identifier if it exists in the lockfile. * */ getFromLockfile() { const lockfile = Storage.getLockfile(); return lockfile.apis.find(a => a.identifier === this.identifier); } /** * Retrieve the lockfile record, if it exists, for a given identifier. * */ static getFromLockfile(identifier) { const lockfile = Storage.getLockfile(); return lockfile.apis.find(a => a.identifier === identifier); } getSDKLanguage() { const entry = this.getFromLockfile(); // We may not have `language` in the lockfile for old users but we default to JS so we can // safely return that if this isn't present. return entry?.language || SupportedLanguages.JS; } getPackageName() { const entry = this.getFromLockfile(); if (entry?.private) { return `@api/${entry.identifier}`; } } getIdentifierStorageDir() { if (!this.isInLockfile()) { throw new Error(`${this.source} has not been saved to storage yet and must do so before being retrieved.`); } return path.join(Storage.getAPIsDir(), this.identifier); } getAPIDefinitionPath() { return path.join(this.getIdentifierStorageDir(), 'openapi.json'); } getAPIDefinition() { const filePath = this.getAPIDefinitionPath(); const file = fs.readFileSync(filePath, 'utf8'); try { return JSON.parse(file); } catch (err) { throw new Error(`Sorry we were unable to parse JSON in ${filePath}. Reason: ${err.message}`, { cause: err }); } } saveSourceFiles(files) { if (!this.isInLockfile()) { throw new Error(`${this.source} has not been saved to storage yet and must do so before being retrieved.`); } return new Promise(resolve => { const savedSource = []; Object.entries(files).forEach(([fileName, contents]) => { const sourceFilePath = path.join(this.getIdentifierStorageDir(), fileName); // If this file is stored in a subdirectory then we need to create it. if (path.dirname(fileName) !== '.') { const dir = path.dirname(fileName); const dirPath = path.join(this.getIdentifierStorageDir(), dir); if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath); } } fs.writeFileSync(sourceFilePath, contents); savedSource.push(sourceFilePath); }); resolve(savedSource); }); } async load(shouldSave = true) { return this.fetcher.load().then(async (spec) => { if (shouldSave) { return this.save(spec); } return spec; }); } /** * Initialize a directory in the storage system for this identifier and add it into the lockfile. * This does not create or save the source code for this SDK, that work happens within the * code generation system. * * @see {@link https://api.readme.dev/docs/how-it-works#api-directory} */ save(spec) { if (!this.identifier) { throw new TypeError('An identifier must be set before saving the API definition into storage.'); } else if (!this.language) { throw new TypeError('A language must be set before saving the API definition into storage.'); } // Create our main `.api/` directory. if (!fs.existsSync(Storage.dir)) { fs.mkdirSync(Storage.dir, { recursive: true }); } // Create the `.api/apis/` diretory where we'll be storing API definitions. if (!fs.existsSync(Storage.getAPIsDir())) { fs.mkdirSync(Storage.getAPIsDir(), { recursive: true }); } if (!this.isInLockfile()) { // This API doesn't exist within our storage system yet so we need to record it in the // lockfile. const identifierStorageDir = path.join(Storage.getAPIsDir(), this.identifier); const saved = JSON.stringify(spec, null, 2); // Create the `.api/apis/<identifier>` directory where we'll be storing this API definition // and eventually its codegen'd SDK. if (!fs.existsSync(identifierStorageDir)) { fs.mkdirSync(identifierStorageDir, { recursive: true }); } Storage.lockfile.apis.push({ private: true, identifier: this.identifier, source: this.source, integrity: Storage.generateIntegrityHash(spec), installerVersion: PACKAGE_VERSION, language: this.language, createdAt: new Date().toISOString(), }); fs.writeFileSync(path.join(identifierStorageDir, 'openapi.json'), saved); fs.writeFileSync(Storage.getLockfilePath(), JSON.stringify(Storage.lockfile, null, 2)); } else { // Is this the same spec that we already have? Should we update it? // @todo } return spec; } /** * Delete the stored source code for the given identifier and purge it from the lockfile. * */ async remove() { // Delete the codegen'd SDK source code. const identifierDir = this.getIdentifierStorageDir(); await fs.promises.rm(identifierDir, { recursive: true }).catch(() => { // If the identifier directory doesn't exist for some reason we can continue on and remove it // from the lockfile because some sort of corruption happened. }); // Remove the SDK from the lockfile. const lockfile = Storage.lockfile; const idx = lockfile.apis.findIndex(api => api.identifier === this.identifier); lockfile.apis.splice(idx, 1); Storage.lockfile = lockfile; fs.writeFileSync(Storage.getLockfilePath(), JSON.stringify(Storage.lockfile, null, 2)); } } //# sourceMappingURL=storage.js.map