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

WIP Working nodejs implementation #70

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
## Jump straight in
+ **.NET Core** - We have a [.net core middleware](docs/dotnet/DotNetDocumentation.md) that you can drop in to enable Popcorn on Web Apis.
Feel free to grab it on [nuget](https://www.nuget.org/packages/Skyward.Api.Popcorn.DotNetCore).

+ [![nodejs](https://img.shields.io/badge/-new-success?style=for-the-badge&logo=npm)![npm](https://img.shields.io/npm/v/@skywardapps/popcorn-api.svg?style=for-the-badge)](https://www.npmjs.com/package/@skywardapps/popcorn-api)
**NodeJS** - We have a brand new nodejs + express + inversify implementation, now available on [npm](https://www.npmjs.com/package/@skywardapps/popcorn-api).
+ **Other implementations** - See our [Roadmap](docs/Roadmap.md) or issues on GitHub for coming work

## What is Popcorn?
Popcorn is a communication protocol on top of a RESTful API that allows requesting clients to
identify individual fields of resources to include when retrieving the resource or resource
collection.
Popcorn is a communication protocol on top of a RESTful API that allows requesting clients to identify individual fields of resources to include when retrieving the resource or resource collection.

It allows for a recursive selection of fields, allowing multiple calls to be condensed
into one.
It allows for a recursive selection of fields, allowing multiple calls to be condensed into one.

### Features
+ Selective inclusion from a RESTful API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public WeatherForecastController(ILogger<WeatherForecastController> logger, IPop
[HttpGet, ExpandResult]
public IEnumerable<WeatherForecast> Get()
{
throw new ArgumentOutOfRangeException("Hello Error");
var rng = new Random();
var range = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Expand Down
Binary file added media/skyward.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions nodejs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
34 changes: 34 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Popcorn
![numget](../media/PopcornLogo.png)
[![npm](https://img.shields.io/npm/v/@skywardapps/popcorn-api.svg?style=for-the-badge)](https://www.npmjs.com/package/@skywardapps/popcorn-api)

## Overview

Popcorn is a communication protocol on top of a RESTful API that allows requesting clients to identify individual fields of resources to include when retrieving the resource or resource collection.

It allows for a recursive selection of fields, allowing multiple calls to be condensed into one.

See specifications for the call-response model on [GitHub](https://github.com/SkywardApps/popcorn)


## Getting Started

This presumes you have a RESTful web-api you are building, utilizing Typescript for typing and inversify-express-utils to scaffold your API endpoints on top of express.

### Requirements

* InversifyJS & inversify-express-utils
* Typescript
* Express

## Authors

![Skyward](../media/skyward.jpg)
[Skyward App Company, LLC](https://skywardapps.com)


## Technologies

![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)

![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white) ![NPM](https://img.shields.io/badge/NPM-%23000000.svg?style=for-the-badge&logo=npm&logoColor=white)
41 changes: 41 additions & 0 deletions nodejs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@skywardapps/popcorn-api",
"version": "0.9.0",
"description": "Popcorn is a communication protocol on top of a RESTful API that allows requesting clients to identify individual fields of resources to include when retrieving the resource or resource collection.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"repository": {
"type": "github",
"url": "https://github.com/SkywardApps/popcorn"
},
"bugs": {
"url": "https://github.com/SkywardApps/popcorn/issues"
},
"scripts": {
"build": "tsc --declaration --declarationMap --sourceMap"
},
"homepage": "https://github.com/SkywardApps/popcorn#readme",
"author": "Skyward App Company, LLC",
"license": "MIT",
"devDependencies": {
"@types/express": "^4.17.14",
"@types/express-serve-static-core": "^4.17.31",
"@types/node": "^18.7.18",
"@types/reflect-metadata": "^0.1.0",
"@typescript-eslint/eslint-plugin": "^5.38.0",
"@typescript-eslint/parser": "^5.38.0",
"eslint": "^8.23.1",
"typescript": "^4.8.3"
},
"keywords": [
"express",
"inversify",
"typescript",
"ts",
"rest",
"restful"
],
"dependencies": {
"inversify-express-utils": "^6.4.3"
}
}
142 changes: 142 additions & 0 deletions nodejs/src/Internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { IPropertyReferenceMap, IPropertyReference } from './PopcornDecorator';
import { PopcornController } from './PopcornController';

export function expand(data: any, requestedProperties: IPropertyReferenceMap, defaultProperties: IPropertyReferenceMap) : any
{
if(data === undefined || data === null)
{
return data;
}

if(Array.isArray(data))
{
return data.map(v => expand(v, requestedProperties, defaultProperties));
}

if (typeof data === 'object')
{
const expandedObject: any = {};
for (const property of Object.keys(data))
{
const cursor = isPropertyRequested([String(property)],
{
requested: { name: '', optional: false, children: requestedProperties },
default: { name: '', optional: false, children: defaultProperties }
});

// If the property is explicitly requested, expand it with any child requests
if (cursor)
{
expandedObject[property] = expand(
data[property],
cursor.requested.children,
// We need to handle when default is 'all' here as well
cursor.default.children
); // TODO sort out better types here
}
}
return expandedObject;
}

return data;
}

export function parseIncludeString(includes: string): IPropertyReferenceMap
{
const stack: IPropertyReference[] = [];
stack.push({ name: '', optional: false, children: {} });
for (const c of includes)
{
switch (c)
{
// Starting a child list, so add a new item on the stack
case '[':
stack.push({ name: '', optional: false, children: {} });
break;
// finised a reference, add it as a child and start a new peer
case ',':
addNode();
stack.push({ name: '', optional: false, children: {} });
break;
// Completed a child list, so add the last item as a child and pop up the stack
case ']':
addNode();
break;
// building a property name
default:
stack[stack.length - 1].name += c;
break;
}
}

return stack.pop()!.children;

function addNode()
{
const child = stack.pop()!;
let propName = child.name.trim();
if (propName.length)
{
if (propName[0] == '?')
{
child.optional = true;
propName = propName.substring(1);
}
child.name = propName;
// If no children were requested, make this an explicit request for defaults
if (Object.keys(child.children).length == 0)
{
child.children = PopcornController.DefaultProperties;
}
stack[stack.length - 1].children[propName] = child;
}
}
}

export function isPropertyRequested(propertyPath: string[], includeCursor: { requested: IPropertyReference; default: IPropertyReference; })
{
for (const propertyCursor of propertyPath)
{
if (includeCursor.requested.children[propertyCursor])
{
includeCursor = {
requested: includeCursor.requested.children[propertyCursor],
default: includeCursor.default.children[propertyCursor]
?? includeCursor.default.children['!all']
? PopcornController.AllDeepProperty
: PopcornController.DefaultProperty,
};
}

// Otherwise if everything was requested, expand it with default child requests
else if (includeCursor.requested.children['!all'])
{
includeCursor = {
requested: PopcornController.AllProperty,
default: includeCursor.default.children[propertyCursor]
?? includeCursor.default.children['!all']
? PopcornController.AllDeepProperty
: PopcornController.DefaultProperty,
};
}

// If defaults were requested and it is default expanded, expand it
else if (includeCursor.requested.children['!default']
&& (includeCursor.default.children[propertyCursor] || includeCursor.default.children['!all']))
{
includeCursor = {
requested: PopcornController.DefaultProperty,
default: includeCursor.default.children[propertyCursor]
?? includeCursor.default.children['!all']
? PopcornController.AllDeepProperty
: PopcornController.DefaultProperty,
};
}

else
{
return undefined;
}
}
return includeCursor;
}
75 changes: 75 additions & 0 deletions nodejs/src/PopcornController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { OutgoingHttpHeaders } from 'http2';
import { BaseHttpController, HttpContext } from 'inversify-express-utils';
import { expand, isPropertyRequested } from './Internals';
import { IPropertyReferenceMap, IPropertyReference } from './PopcornDecorator';

export interface IPopcornControllerInternals {
requestHttpContext() : HttpContext;
setRequestedProperties(value: IPropertyReferenceMap) : void;
setDefaultProperties(value: IPropertyReferenceMap) : IPropertyReferenceMap;
}

export class PopcornController extends BaseHttpController
{
private requestedProperties: IPropertyReferenceMap = PopcornController.DefaultProperties;
private defaultProperties: IPropertyReferenceMap = PopcornController.DefaultProperties;

// These items implement the private interface IPopcornControllerInternals
private requestHttpContext() { return this.httpContext; }
private setRequestedProperties(value: IPropertyReferenceMap) { this.requestedProperties = value; }
private setDefaultProperties(value: IPropertyReferenceMap) { this.defaultProperties = value; }

protected getRequestedProperties() { this.requestedProperties; }

public isPropertyRequested(property: string): { requested: IPropertyReference; default: IPropertyReference; } | undefined
{
const propertyPath = property.split('.');
return isPropertyRequested(propertyPath,
{
requested: { name: '', optional: false, children: this.requestedProperties },
default: { name: '', optional: false, children: this.defaultProperties }
});
}

protected constructor()
{
super();
}

protected popcorn<T>(data: T, headers?: OutgoingHttpHeaders, status?: number): T
{
const expanded = expand(data, this.requestedProperties, this.defaultProperties) as any;
expanded.__headers = headers;
expanded.__popcorn = true;
expanded.__status = status;
return expanded;
}

public static readonly DefaultProperties: IPropertyReferenceMap = {
'!default': { name: '!default', optional: false, children: {} }
};

public static readonly DefaultProperty: IPropertyReference = {
name: '!default',
optional: true,
children: this.DefaultProperties
};

public static readonly AllProperties: IPropertyReferenceMap = {
'!all': { name: '!all', optional: false, children: {} }
};

public static readonly AllProperty: IPropertyReference = {
name: '!all',
optional: true,
children: this.DefaultProperties
};

public static readonly AllDeepProperty: IPropertyReference = {
name: '!all',
optional: true,
children: this.AllProperties
};
}


63 changes: 63 additions & 0 deletions nodejs/src/PopcornDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { HttpResponseMessage, JsonContent } from 'inversify-express-utils';
import { IPopcornControllerInternals, PopcornController } from './PopcornController';
import { parseIncludeString } from './Internals';

export interface IPropertyReference
{
name: string;
children: IPropertyReferenceMap;
optional: boolean;
}

export interface IPropertyReferenceMap
{
[key: string]: IPropertyReference;
}

export function popcorn(includes: string | IPropertyReferenceMap)
{
const defaultIncludes = typeof includes === 'string' ? parseIncludeString(includes) : includes;

return (target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) =>
{
const method = descriptor.value!;
if (!(target instanceof PopcornController))
{
throw new Error(`Target ${target} for attribute is not a PopcornController`);
}

descriptor.value = async function (...rest: any[])
{
const self = this as IPopcornControllerInternals;
self.setDefaultProperties(defaultIncludes);

// in some fashion, we need to inject the request 'include' parameter to the class
const requestedIncludeStatement = self.requestHttpContext().request.query['include'];
if (!requestedIncludeStatement?.length || requestedIncludeStatement === '[]')
{
self.setRequestedProperties(PopcornController.DefaultProperties);
}

else
{
// parse the string
self.setRequestedProperties(parseIncludeString(String(requestedIncludeStatement)));
}

const content = await method.apply(this, rest);
const { __headers, __popcorn, __status, ...payload } = content;
if (!__popcorn)
{
return content;
}

const response = new HttpResponseMessage(__status || 200);
response.content = new JsonContent(payload);
response.headers = {
...response.headers,
...__headers
};
return response;
};
};
}
3 changes: 3 additions & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { PopcornController } from './PopcornController';
import { popcorn, IPropertyReference, IPropertyReferenceMap } from './PopcornDecorator';
export { PopcornController, popcorn, IPropertyReference, IPropertyReferenceMap };
Loading