Repository: eduardoboucas/static-api-generator
Branch: master
Commit: c0a6331d9464
Files: 8
Total size: 33.7 KB
Directory structure:
gitextract_ggg079tt/
├── .gitignore
├── .travis.yml
├── README.md
├── index.js
├── lib/
│ ├── api.js
│ └── io.js
├── package.json
└── test/
└── api.test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
node_modules
================================================
FILE: .travis.yml
================================================
language: node_js
cache:
directories:
- node_modules
notifications:
email: false
node_js:
- '6'
================================================
FILE: README.md
================================================
<img src="https://raw.githubusercontent.com/eduardoboucas/static-api-generator/master/.github/logo.png" alt="Pluma logo" height="120"/>
[](https://www.npmjs.com/package/pluma)
[](http://standardjs.com/)
[](https://travis-ci.org/eduardoboucas/static-api-generator)
Static API generator is a Node.js application that creates a basic JSON API from a tree of directories and files. Think of a static site generator, like Jekyll or Hugo, but for APIs.
It takes your existing data files, which you may already be using to feed a static site generator or similar, and creates an API layer with whatever structure you want, leaving the original files untouched. Static API generator helps you deliver your data to client-side applications or third-party syndication services.
Couple it with services like [GitHub Pages](https://pages.github.com/) or [Netlify](https://www.netlify.com/) and you can serve your API right from the repository too. 🦄
---
- [Installation](#installation)
- [Usage](#usage)
- [API](#api)
- [Q&A](#qa)
---
## 1. Installation
- Install via npm
```shell
npm install static-api-generator --save
```
- Require the module and create an API
```js
const API = require('static-api-generator')
const api = new API(constructorOptions)
```
## 2. Usage
Imagine the following repository holding data about movies, organised by language, genre and year. Information about each movie will be in its own YAML file named after the movie.
```
input/
├── english
│ ├── action
│ │ ├── 2016
│ │ │ ├── deadpool.yaml
│ │ │ └── the-great-wall.yaml
│ │ └── 2017
│ │ ├── logan.yaml
│ │ └── the-fate-of-the-furious.yaml
│ └── horror
│ └── 2017
│ ├── alien-covenant.yaml
│ └── get-out.yaml
└── portuguese
└── action
└── 2016
└── tropa-de-elite.yaml
```
### 2.1. Initialisation
Create an API by specifying its blueprint, so that the static API generator can understand how the data is structured, and the directory where the generated files should be saved.
```js
const moviesApi = new API({
blueprint: 'source/:language/:genre/:year/:movie',
outputPath: 'output'
})
```
### 2.2 Generating endpoints
The following will generate and endpoint for each movie (e.g. `/english/action/2016/deadpool.json`).
```js
moviesApi.generate({
endpoints: ['movie']
})
```
Endpoints can be created for any data level. The following creates additional endpoints at the genre level (e.g. `/english/action.json`).
```js
moviesApi.generate({
endpoints: ['genre', 'movie']
})
```
It's also possible to manipulate the hierarchy of the data by choosing a different root level. For example, one could generate endpoints for each genre without a separation enforced by language, its original parent level. This means being able to create endpoints like `/action.json` (as opposed to `/english/action.json`), where all action movies are listed regardless of their language.
```js
moviesApi.generate({
endpoints: ['genre', 'movie'],
root: 'genre'
})
```
## 3. API
### 3.1. Constructor
```js
const API = require('static-api-generator')
const api = new API({
addIdToFiles: Boolean,
blueprint: String,
pluralise: Boolean,
outputPath: String
})
```
The constructor method takes an object with the following properties.
---
- #### `addIdToFiles`
Whether to add an `id` field to uniquely identify each data file. IDs are generated by computing an MD5 hash of the full path of the file.
*Default:*
`false`
*Example result:*
`"review_id": "96a9b996439528ecb9050774c3e79ff2"`
---
- #### `blueprint`
**Required**. A path describing the hierarchy and nomenclature of the data. It should start with a static path to the directory where all the files are located, followed by the name of each data level (starting with a colon).
For the [pluralise](#pluralise) option to work well, the names of the data levels should be singular (e.g. `languages` vs. `language`)
*Example:*
`'input/:language/:genre/:year/:movie'`
---
- #### `outputPath`
**Required**. The path to the directory where endpoint files should be created.
*Example:*
`'output'`
---
- #### `pluralise`
The name of each data level (e.g. `"genre"`) is used in the singular form when identifying a single entity (e.g. `{"genre_id": "12345"}`) and in the plural form when identifying a list of entities (e.g. `{"genres": [...]}`). That behaviour can be disabled by setting this property to `false`.
*Default:*
`true`
---
### 3.2. Method: `generate`
```js
api.generate({
endpoints: Array,
entriesPerPage: Number,
index: String,
levels: Array,
root: String,
sort: Object
})
```
The `generate` method creates one or more endpoints. It takes an object with the following properties.
---
- #### `endpoints`
The names of the data levels to create individual endpoints for, which means generating a JSON file for each file or directory that corresponds to a given level.
*Default:*
`[]`
*Example:*
`['genre', 'movie']`
---
- #### `entriesPerPage`
The maximum number of entries (data files) to include in an endpoint file. If the number of entries exceeds this number, additional endpoints are created (e.g. `/action.json`, `/action-2.json`, etc.).
*Default:*
`10`
*Example:*
`3`
---
- #### `index`
The name of the main/index endpoint file.
*Default:*
The pluralised name of the root level (e.g. `languages`).
*Example:*
`languages` (generates `languages.json`)
---
- #### `levels`
An array containing the names of the levels to include in the endpoints. By default, an endpoint for a level will include all its child levels (e.g. an endpoint for a *genre* will have a list of *years*, which will have a list of *movies*). If a level is omitted (e.g. *year*), then all its entries are grouped together (i.e. an endpoint for a *genre* will have a list of all its *movies*, without any separation by *year*).
*Default:*
All levels
*Example:*
`['language', 'genre', 'movie']`
---
- #### `root`
The name of the root level. When this doesn't correspond to the first level in the blueprint, the data tree is manipulated so that all entries become grouped by the new root level.
*Default:*
The name of the first level of the blueprint (e.g. `language`)
*Example:*
`genre`
---
- #### `sort`
An object that defines how the various data levels should be sorted. For levels corresponding to directories, an object with a single property (`order`) is expected, determining whether directory names are sorted from A-Z (`ascending`) or Z-A (`descending`).
When levels contain entries, an additional property (`field`) defines what field of the data files should be used to sort the entries.
*Default:*
`{}` (directories will be sorted alphabetically from A-Z, entries will be sorted alphabetically from A-Z based on their filename)
*Example:*
```js
{
genre: {
order: 'descending'
},
movie: {
field: 'budget',
order: 'descending'
}
}
```
---
## 4. Q&A
- **Why did you build this?**
GitHub has been the centrepiece of my daily workflow as a developer for many years. I love the idea of using a repository not only for version control, but also as the single source of truth for a data set. As a result, I created [several](https://staticman.net) [projects](https://speedtracker.org) that explore the usage of GitHub repositories as data stores, and I've used that approach in several professional and personal projects, including [my own site/blog](https://eduardoboucas.com).
- **Couldn't Jekyll, Hugo or XYZ do the same thing?**
Possibly. Most static site generators are capable of generating JSON, but usually using awkward/brittle methods. Most of those applications were built to generate HTML pages and that's where they excel on. I tried to create a minimalistic and easy-as-it-gets way of generating something very specific: a bunch of JSON files that, when bundled together, form a very basic API layer.
- **Where can I host this?**
[GitHub Pages](https://pages.github.com/) is a very appealing option, since you could serve the API right from the repository. It has CORS enabled, so you could easily consume it from a client-side application using React, Angular, Vue or whatever you prefer. You could even use a CI service like [Travis](https://travis-ci.org/) to listen for commits on a given branch (say `master`) and automatically run Static API Generator and push the generated output to a `gh-pages` branch, making the process of generating the API when data changes fully automated.
[Netlify](https://www.netlify.com/) is also very cool and definitely worth trying.
- **Would it be possible to add feature X, Y or Z?**
Probably. File an [issue](https://github.com/eduardoboucas/static-api-generator/issues) or, even better, a [pull request](https://github.com/eduardoboucas/static-api-generator/pulls) and I'm happy to help. Bare in mind that this is a side project (one of too many) which I'm able to dedicate a very limited amount of time to, so please be patient and try to understand if I tell you I don't have the capacity to build what you're looking for.
- **Who designed the logo?**
The logo was created by [Arthur Shlain](https://thenounproject.com/ArtZ91/) from The Noun Project and it's licensed under a [Creative Commons Attribution](https://creativecommons.org/licenses/by/3.0/us/) license.
================================================
FILE: index.js
================================================
module.exports = require('./lib/api')
================================================
FILE: lib/api.js
================================================
'use strict'
const deepmerge = require('deepmerge')
const io = require('./io')
const md5 = require('md5')
const multimatch = require('multimatch')
const objectPath = require('object-path')
const path = require('path')
const pluralize = require('pluralize')
const API = function ({
addIdToFiles = true,
blueprint,
outputPath = 'output',
pluralise = true
}) {
this.blueprint = this._parseBlueprint(blueprint)
this.addIdToFiles = addIdToFiles
this.usePluralisation = pluralise
this.outputPath = outputPath
this.baseDirectory = this.blueprint.base.join('/')
this.readQueue = {}
this.walk = io
.walkDirectory(this.baseDirectory)
.then(({paths, tree}) => {
this.paths = paths
this.tree = tree
})
.then(() => io.removeDirectory(outputPath))
}
API.prototype._createEndpointPayload = function ({
itemsPerPage,
name,
page = 1,
results,
targetObj
}) {
const filePath = this._getPaginatedFilename(name, page)
const pageOffset = (page - 1) * itemsPerPage
const numPages = Math.ceil(results.length / itemsPerPage)
let metadata = {
itemsPerPage,
pages: numPages
}
if (page > 1) {
metadata.previousPage = this._getPaginatedURL(
name,
page - 1
)
}
if (page < numPages) {
metadata.nextPage = this._getPaginatedURL(
name,
page + 1
)
}
targetObj[filePath] = {
results: results.slice(pageOffset, pageOffset + itemsPerPage),
metadata
}
if (page < numPages) {
this._createEndpointPayload({
itemsPerPage,
name,
page: page + 1,
results,
targetObj
})
}
}
API.prototype._filterTreeLevels = function ({
currentLevel = 0,
displayLevels,
rootNode,
tree,
unassignedKeys
}) {
const includesNextLevel = displayLevels.includes(currentLevel + 1)
const newUnassignedKeys = !includesNextLevel && rootNode
if (!tree || Array.isArray(tree)) {
if (includesNextLevel) {
return tree
}
if (unassignedKeys) {
return {}
}
return null
}
let filteredTree = {}
if (includesNextLevel) {
Object.keys(tree).forEach(key => {
filteredTree[key] = this._filterTreeLevels({
currentLevel: currentLevel + 1,
displayLevels,
rootNode,
tree: tree[key],
unassignedKeys: newUnassignedKeys
})
})
} else {
const treeValues = Object.keys(tree).map(key => this._filterTreeLevels({
currentLevel: currentLevel + 1,
displayLevels,
rootNode,
tree: tree[key],
unassignedKeys: newUnassignedKeys
})).filter(Boolean)
filteredTree = treeValues.length > 1
? deepmerge.all(treeValues)
: treeValues[0]
}
return filteredTree
}
API.prototype._getAggregator = function ({
cumulativePath = [],
currentLevel = 0,
targetLevel,
targetObject,
tree
}) {
if (!tree) {
return
}
if (currentLevel > targetLevel) {
targetObject[cumulativePath.join('/')] = tree
return
}
if (Array.isArray(tree)) {
tree.forEach(file => {
const extension = path.extname(file)
targetObject[file.slice(0, file.lastIndexOf(extension))] = file
})
return
}
return Object.keys(tree).map(node => {
return this._getAggregator({
cumulativePath: cumulativePath.concat(node),
currentLevel: currentLevel + 1,
targetLevel,
targetObject,
tree: tree[node]
})
})
}
API.prototype._getCompareFunction = function (order, field) {
return (a, b) => {
const itemA = field ? a[field] : a
const itemB = field ? b[field] : b
const diff = order === 'descending'
? itemB - itemA
: itemA - itemB
if (diff < 0) {
return -1
} else if (diff > 0) {
return 1
}
return 0
}
}
API.prototype._getEndpointTree = function (baseLevel, displayLevels) {
const matches = multimatch(
Object.keys(this.paths),
'*' + '/*'.repeat(baseLevel)
)
let tree = {}
matches.forEach(match => {
let matchPath
let newDataAtMatchPath
if (this.paths[match] && this.paths[match].file) {
matchPath = path.dirname(match) + path.basename(match, path.extname(match))
newDataAtMatchPath = match
} else {
const matchNodes = match.split('/')
const matchRoot = matchNodes[matchNodes.length - 1]
const subTree = this._filterTreeLevels({
currentLevel: matchNodes.length - 1,
displayLevels,
rootNode: matchNodes[0],
tree: objectPath.get(this.tree, matchNodes.join('.'))
})
matchPath = [matchRoot]
.concat(matchNodes.slice(0, -1))
.filter((node, index) => displayLevels.includes(index))
.join('.')
const dataAtMatchPath = objectPath.get(tree, matchPath)
newDataAtMatchPath = dataAtMatchPath
? deepmerge(dataAtMatchPath, subTree)
: subTree
}
objectPath.set(tree, matchPath, newDataAtMatchPath)
})
return tree
}
API.prototype._getFileContents = function (filePath, levelName, readQueue) {
this.readQueue[filePath] = this.readQueue[filePath] ||
io.readFile(filePath, true)
const read = this.readQueue[filePath].then(contents => {
const fileContents = this.addIdToFiles
? Object.assign({}, {
[`${levelName}_id`]: md5(filePath)
}, contents)
: contents
return fileContents
})
readQueue.push(read)
return read
}
API.prototype._getLevelNames = function (baseLevel) {
let levels = Array.from(this.blueprint.levels)
const base = levels.splice(baseLevel, 1)
return base.concat(levels)
}
API.prototype._getOutputFromTree = function ({
level = 0,
levelNames,
parentLevelName = null,
privateReadQueue,
root = null,
sort,
tree
}) {
const levelName = levelNames[level]
if (!tree && level < levelNames.length) {
return null
}
let newTree = {}
if (root) {
newTree[parentLevelName + '_id'] = root
}
if (tree) {
const nodeName = root
? this._pluralise(levelName)
: 'results'
if (Array.isArray(tree)) {
newTree[nodeName] = []
tree.forEach(file => {
const filePath = path.join(this.baseDirectory, file)
this._getFileContents(filePath, levelName, privateReadQueue)
.then(fileContents => {
if (sort[levelName]) {
newTree[nodeName] = this._mergeSortedArrays(
newTree[nodeName],
[fileContents],
this._getCompareFunction(
sort[levelName].order,
sort[levelName].field
)
)
} else {
newTree[nodeName].push(fileContents)
}
})
})
} else {
let sortedKeys = Object.keys(tree)
if (sortedKeys.length === 0) {
newTree[nodeName] = []
return newTree
}
if (sort[levelName]) {
const compareFn = this._getCompareFunction(
sort[levelName].order
)
sortedKeys = sortedKeys.sort(compareFn)
}
newTree[nodeName] = sortedKeys.map((node, index) => {
if (typeof tree[node] === 'string') {
const filePath = path.join(this.baseDirectory, tree[node])
this._getFileContents(filePath, levelName, privateReadQueue)
.then(fileContents => {
newTree[nodeName][index] = fileContents
})
return
}
return this._getOutputFromTree({
level: level + 1,
levelNames,
parentLevelName: levelName,
privateReadQueue,
root: node,
sort,
tree: tree[node]
})
})
}
}
return newTree
}
API.prototype._getPaginatedFilename = function (filePath, page) {
return path.join(
this.outputPath,
this._getPaginatedURL(filePath, page)
)
}
API.prototype._getPaginatedURL = function (filePath, page) {
return '/' + filePath + (page > 1 ? `-${page}` : '') + '.json'
}
API.prototype._mergeSortedArrays = function (a, b, compareFn) {
let result = []
let i = 0
let j = 0
while (a[i] && b[j]) {
if (compareFn(a[i], b[j]) === -1) {
result.push(a[i])
i++
} else {
result.push(b[j])
j++
}
}
return result.concat(a.slice(i)).concat(b.slice(j))
}
API.prototype._parseBlueprint = function (blueprint) {
let parsedblueprint = {
base: [],
levels: []
}
blueprint.split('/').forEach(node => {
if (node.indexOf(':') === 0) {
parsedblueprint.levels.push(node.slice(1))
} else {
parsedblueprint.base.push(node)
}
})
return parsedblueprint
}
API.prototype._pluralise = function (text) {
return text && this.usePluralisation
? pluralize(text)
: text
}
API.prototype.generate = function ({
endpoints: aggregators = [],
entriesPerPage: itemsPerPage = 10,
index: endpointPath,
levels,
root = this.blueprint.levels[0],
sort = {}
}) {
return this.walk.then(() => {
const baseLevel = root
? this.blueprint.levels.findIndex(l => l === root)
: 0
const levelNames = this._getLevelNames(baseLevel)
const displayLevels = (levels || levelNames)
.map(name => {
return levelNames.findIndex(level => level === name)
})
.filter(index => index > -1)
.concat(0)
const displayLevelNames = levelNames.filter((level, index) => {
return displayLevels.includes(index)
})
const endpointTree = this._getEndpointTree(baseLevel, displayLevels)
let files = {}
let queue = []
const mainResults = this._getOutputFromTree({
levelNames: displayLevelNames,
privateReadQueue: queue,
sort,
tree: endpointTree
}).results
this._createEndpointPayload({
itemsPerPage,
name: endpointPath || this._pluralise(levelNames[0]),
results: mainResults,
targetObj: files
})
let aggregatorResults = {}
aggregators.forEach(levelName => {
const level = displayLevelNames.findIndex(level => level === levelName)
let aggregatorFiles = {}
this._getAggregator({
tree: endpointTree,
targetLevel: level,
targetObject: aggregatorFiles
})
Object.keys(aggregatorFiles).forEach(file => {
if (typeof aggregatorFiles[file] === 'string') {
const inputFilePath = path.join(this.baseDirectory, aggregatorFiles[file])
const outputFilePath = this._getPaginatedFilename(file, 1)
this._getFileContents(inputFilePath, levelName, queue)
.then(fileContents => {
files[outputFilePath] = fileContents
})
} else {
aggregatorResults[file] = this._getOutputFromTree({
level: level + 1,
levelNames: displayLevelNames,
parentLevelName: levelName,
privateReadQueue: queue,
sort,
tree: aggregatorFiles[file]
})
}
})
})
return Promise.all(queue).then(() => {
Object.keys(aggregatorResults).forEach(file => {
this._createEndpointPayload({
itemsPerPage,
name: file,
results: aggregatorResults[file].results,
targetObj: files
})
})
const writes = Object.keys(files).map(name => {
console.log('** Creating file:', name)
return io.writeFile(name, files[name])
})
return Promise.all(writes)
})
})
}
module.exports = API
================================================
FILE: lib/io.js
================================================
'use strict'
const fs = require('fs')
const mkdirp = require('mkdirp-promise')
const objectPath = require('object-path')
const path = require('path')
const rimraf = require('rimraf-promise')
const walk = require('walk')
const yaml = require('js-yaml')
const IOHelpers = function () {}
IOHelpers.prototype.ensureDirectory = function (directory) {
return mkdirp(directory)
}
IOHelpers.prototype.parseFile = function (contents, extension) {
switch (extension) {
case '.yml':
case '.yaml':
return Promise.resolve(yaml.safeLoad(contents))
}
return Promise.resolve(contents)
}
IOHelpers.prototype.readFile = function (file, parse) {
const extension = path.extname(file)
return new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', (err, content) => {
if (err) return reject(err)
if (parse) {
return resolve(this.parseFile(content, extension))
}
return resolve(content)
})
})
}
IOHelpers.prototype.removeDirectory = function (directory) {
return rimraf(directory)
}
IOHelpers.prototype.walkDirectory = function (directory) {
const walker = walk.walk(directory, {})
let paths = {}
let tree = {}
return new Promise((resolve, reject) => {
walker.on('directory', (root, stat, next) => {
const basePath = root.split('/').slice(1).join('.')
if (objectPath.get(tree, basePath) === null) {
objectPath.set(tree, basePath, {})
}
const directoryPath = basePath ? `${basePath}.${stat.name}` : stat.name
objectPath.set(tree, directoryPath, null)
const newPath = path.relative(
directory,
path.join(root, stat.name)
)
paths[newPath] = {
directory: true
}
next()
})
walker.on('file', (root, stat, next) => {
if (stat.name.indexOf('.') === 0) return next()
const basePath = root.split('/').slice(1).join('.')
const filePath = path.relative(
directory,
path.join(root, stat.name)
)
objectPath.push(tree, basePath, filePath)
paths[filePath] = {
file: true
}
next()
})
walker.on('end', (root, stat, next) => {
return resolve({
paths,
tree
})
})
})
}
IOHelpers.prototype.writeFile = function (target, content) {
content = JSON.stringify(content)
return this.ensureDirectory(path.dirname(target)).then(() => {
return new Promise((resolve, reject) => {
fs.writeFile(target, content, err => {
if (err) return reject(err)
return resolve()
})
})
})
}
module.exports = new IOHelpers()
================================================
FILE: package.json
================================================
{
"name": "static-api-generator",
"version": "1.1.0",
"description": "Generate a static API layer from a tree of directories and files",
"main": "index.js",
"scripts": {
"test": "standard && jest",
"test-dev": "jest --watch"
},
"keywords": [
"api",
"static",
"json-api"
],
"author": "Eduardo Bouças <mail@eduardoboucas.com>",
"license": "MIT",
"dependencies": {
"deepmerge": "^1.5.0",
"js-yaml": "^3.9.0",
"md5": "^2.2.1",
"mkdirp-promise": "^5.0.1",
"multimatch": "^2.1.0",
"object-path": "^0.11.4",
"pluralize": "^5.0.0",
"rimraf-promise": "^2.0.0",
"walk": "^2.3.9"
},
"devDependencies": {
"jest": "^20.0.4",
"standard": "^10.0.2"
},
"standard": {
"ignore": [
"test/**/*"
]
}
}
================================================
FILE: test/api.test.js
================================================
'use strict'
const API = require('./../lib/api')
const mockBlueprint = 'data/:username/:repository/:message'
const mockOutputPath = '/Users/johndoe/Sites/movies-api/output'
beforeEach(() => {
jest.resetModules()
})
describe('API', () => {
describe('initialisation', () => {
test('parses the blueprint', () => {
const spy = jest.spyOn(API.prototype, '_parseBlueprint')
const api = new API({
blueprint: mockBlueprint
})
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][0]).toBe(mockBlueprint)
})
})
describe('`_getPaginatedURL()`', () => {
test('returns the base name + extension for page 1', () => {
const api = new API({
blueprint: mockBlueprint
})
expect(api._getPaginatedURL('foo', 1)).toBe('/foo.json')
})
test('returns the base name + page number + extension for pages >1', () => {
const api = new API({
blueprint: mockBlueprint
})
expect(api._getPaginatedURL('foo', 2)).toBe('/foo-2.json')
expect(api._getPaginatedURL('foo', 1000)).toBe('/foo-1000.json')
})
})
describe('`_getPaginatedFilename()`', () => {
test('returns the full path to the file with name + extension for page 1', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
expect(api._getPaginatedFilename('foo', 1)).toBe(mockOutputPath + '/foo.json')
})
test('returns the full path to the file with name + page number + extension for pages >1', () => {
const api = new API({
blueprint: mockBlueprint
})
api.outputPath = mockOutputPath
expect(api._getPaginatedFilename('foo', 2))
.toBe(mockOutputPath + '/foo-2.json')
expect(api._getPaginatedFilename('foo', 20000))
.toBe(mockOutputPath + '/foo-20000.json')
})
})
describe('`_createEndpointPayload()`', () => {
test('creates an object with a `results` and `metadata` properties', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const mockResults = [
{
movie_id: '1234567',
name: 'Casablanca'
},
{
movie_id: '7654321',
name: 'Gone with the Wind'
}
]
let files = {}
api._createEndpointPayload({
itemsPerPage: 10,
name: 'movies',
results: mockResults,
targetObj: files
})
expect(files[`${mockOutputPath}/movies.json`].results)
.toEqual(mockResults)
expect(files[`${mockOutputPath}/movies.json`].metadata)
.toBeDefined()
})
test('splits paginated results across multiple files and adds correct metadata block', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const mockResults = [
{
movie_id: '123',
name: 'Casablanca'
},
{
movie_id: '234',
name: 'Gone with the Wind'
},
{
movie_id: '345',
name: 'King Kong'
},
{
movie_id: '456',
name: 'Rear Window'
}
]
let files = {}
api._createEndpointPayload({
itemsPerPage: 2,
name: 'movies',
results: mockResults,
targetObj: files
})
expect(files[`${mockOutputPath}/movies.json`].results)
.toEqual(mockResults.slice(0, 2))
expect(files[`${mockOutputPath}/movies.json`].metadata.itemsPerPage)
.toBe(2)
expect(files[`${mockOutputPath}/movies.json`].metadata.pages)
.toBe(Math.ceil(mockResults.length / 2))
expect(files[`${mockOutputPath}/movies.json`].metadata.nextPage)
.toBe('/movies-2.json')
expect(files[`${mockOutputPath}/movies-2.json`].results)
.toEqual(mockResults.slice(2, 4))
expect(files[`${mockOutputPath}/movies-2.json`].metadata.itemsPerPage)
.toBe(2)
expect(files[`${mockOutputPath}/movies-2.json`].metadata.pages)
.toBe(Math.ceil(mockResults.length / 2))
expect(files[`${mockOutputPath}/movies-2.json`].metadata.previousPage)
.toBe('/movies.json')
})
})
describe('`_filterTreeLevels()`', () => {
const mockTree = {
english: {
action: {
2015: [
'movie1.yaml',
'movie2.yaml',
'movie3.yaml'
],
2016: [
'movie4.yaml',
'movie5.yaml'
]
},
horror: {
2016: [
'movie6.yaml',
'movie7.yaml'
],
2017: [
'movie8.yaml',
'movie9.yaml'
]
}
},
portuguese: {
action: {
2015: [
'movie10.yaml'
]
},
horror: {
2017: [
'movie11.yaml',
'movie12.yaml'
]
}
}
}
test('returns original tree if all levels are to be displayed', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [1, 2, 3, 4],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual(mockTree)
})
test('returns modified tree if first level is not displayed', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [2, 3, 4],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual({
action: {
2015: [
'movie1.yaml',
'movie2.yaml',
'movie3.yaml',
'movie10.yaml'
],
2016: [
'movie4.yaml',
'movie5.yaml'
]
},
horror: {
2016: [
'movie6.yaml',
'movie7.yaml'
],
2017: [
'movie8.yaml',
'movie9.yaml',
'movie11.yaml',
'movie12.yaml'
]
}
})
})
test('returns modified tree if intermediate level is not displayed', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [1, 3, 4],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual({
english: {
2015: [
'movie1.yaml',
'movie2.yaml',
'movie3.yaml'
],
2016: [
'movie4.yaml',
'movie5.yaml',
'movie6.yaml',
'movie7.yaml'
],
2017: [
'movie8.yaml',
'movie9.yaml'
]
},
portuguese: {
2015: [
'movie10.yaml'
],
2017: [
'movie11.yaml',
'movie12.yaml'
]
}
})
})
test('returns modified tree if two intermediate levels are not displayed', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [1, 4],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual({
english: [
'movie1.yaml',
'movie2.yaml',
'movie3.yaml',
'movie4.yaml',
'movie5.yaml',
'movie6.yaml',
'movie7.yaml',
'movie8.yaml',
'movie9.yaml'
],
portuguese: [
'movie10.yaml',
'movie11.yaml',
'movie12.yaml'
]
})
})
test('returns modified tree if last level is not displayed, replacing it with `null`', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [1, 2, 3],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual({
english: {
action: {
2015: null,
2016: null
},
horror: {
2016: null,
2017: null
}
},
portuguese: {
action: {
2015: null
},
horror: {
2017: null
}
}
})
})
test('returns modified tree if last two levels are not displayed, replacing penultimate level with empty objects', () => {
const api = new API({
blueprint: mockBlueprint,
outputPath: mockOutputPath
})
const filteredTree = api._filterTreeLevels({
currentLevel: 0,
displayLevels: [1, 2],
rootNode: 'movies',
tree: mockTree
})
expect(filteredTree).toEqual({
english: {
action: {},
horror: {}
},
portuguese: {
action: {},
horror: {}
}
})
})
})
})
gitextract_ggg079tt/
├── .gitignore
├── .travis.yml
├── README.md
├── index.js
├── lib/
│ ├── api.js
│ └── io.js
├── package.json
└── test/
└── api.test.js
SYMBOL INDEX (1 symbols across 1 files)
FILE: test/api.test.js
constant API (line 3) | const API = require('./../lib/api')
Condensed preview — 8 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (36K chars).
[
{
"path": ".gitignore",
"chars": 12,
"preview": "node_modules"
},
{
"path": ".travis.yml",
"chars": 101,
"preview": "language: node_js\ncache:\n directories:\n - node_modules\nnotifications:\n email: false\nnode_js:\n- '6'"
},
{
"path": "README.md",
"chars": 9980,
"preview": "<img src=\"https://raw.githubusercontent.com/eduardoboucas/static-api-generator/master/.github/logo.png\" alt=\"Pluma logo\""
},
{
"path": "index.js",
"chars": 38,
"preview": "module.exports = require('./lib/api')\n"
},
{
"path": "lib/api.js",
"chars": 11454,
"preview": "'use strict'\n\nconst deepmerge = require('deepmerge')\nconst io = require('./io')\nconst md5 = require('md5')\nconst multima"
},
{
"path": "lib/io.js",
"chars": 2626,
"preview": "'use strict'\n\nconst fs = require('fs')\nconst mkdirp = require('mkdirp-promise')\nconst objectPath = require('object-path'"
},
{
"path": "package.json",
"chars": 791,
"preview": "{\n \"name\": \"static-api-generator\",\n \"version\": \"1.1.0\",\n \"description\": \"Generate a static API layer from a tree of d"
},
{
"path": "test/api.test.js",
"chars": 9471,
"preview": "'use strict'\n\nconst API = require('./../lib/api')\nconst mockBlueprint = 'data/:username/:repository/:message'\nconst mock"
}
]
About this extraction
This page contains the full source code of the eduardoboucas/static-api-generator GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 8 files (33.7 KB), approximately 8.8k tokens, and a symbol index with 1 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.