Skip to content

Commit

Permalink
query decorator (#686)
Browse files Browse the repository at this point in the history
* Extract module loader

* Replace DynamicSubsetFormula with QueryDecorator

* Load query decorator module on each plywood query

* Propagate error message in module loader.

* Style fixes for decorator loading

* Add QueryDecorator options when invoking decorator

* Documentation

* Remove dynamic-subset-formula.ts

* Add plywood as explicit parameter to query decorator. Plywood internally uses a lot of `instanceof` operator so we need to ensure that plugin uses the same version of library.

* Fix some typos

Co-authored-by: Piotr Szczepanik <piter75@gmail.com>

Co-authored-by: Piotr Szczepanik <piter75@gmail.com>
  • Loading branch information
adrianmroz and piter75 committed Dec 9, 2020
1 parent 693c682 commit a19fff8
Show file tree
Hide file tree
Showing 13 changed files with 318 additions and 197 deletions.
132 changes: 132 additions & 0 deletions docs/extending-turnilo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Extending Turnilo

Turnilo lets you extend its behaviour in three ways:

* Request decorator for all Druid queries sent to Druid cluster
* Query decorator for all Plywood queries sent to Druid cluster
* Plugins for backend application

## Request decorator

In the cluster config add a key `druidRequestDecorator` with property `path` that points to a relative js file.

```yaml
druidRequestDecorator:
path: './druid-request-decorator.js'
```

You can also pass parameters to your decorator using `options` field. Content of this field will be read as json and passed
to your `druidRequestDecoratorFactory` under `options` key in second parameter.

```yaml
druidRequestDecorator:
path: './druid-request-decorator.js'
options:
keyA: valueA
keyB:
- firstElement
- secondElement
```

The contract is that your module should export a function `druidRequestDecoratorFactory` that has to return a decorator.

A decorator is a function that gets called on every request. It receives a Druid query and may return an object with the
key `headers` where you can set whatever headers you want.

Here is an example decorator:

```javascript
exports.version = 1;

exports.druidRequestDecoratorFactory = function (logger, params) {
const options = params.options;
const username = options.username;
const password = options.password;

const auth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");

return function () {
return {
headers: {
"Authorization": auth
},
};
};
};
```

You can find this example with additional comments and example config in the [./example](./example/request-decoration) folder.

This would result in all Druid requests being tagged as:

![decoration example](./example/request-decoration/result.png)

## Query decorator

In the data cube config add `queryDecorator` field with key `path` pointing to javascript file.
This file should export function named `decorator`.
This function will be called before every Plywood query is sent to Druid from Turnilo.
Function is called with four arguments:
* Plywood query
* Request object
* Decorator options
* Plywood library instance

Decorator function should return valid Plywood expression.

```javascript
exports.decorator = function (expression, request, options, plywood) {
const userId = request.headers["x-user-id"]; // get userId from header, you need to set this value before Turnilo
const userColumnName = options.userColumnName; // get value from options, defined in config
const filterClause = plywood.$(userColumnName).in([userId]); // show only rows where `userColumnName` is equal to current user id.
return expression.substitute(e => {
if (e instanceof plywood.RefExpression && e.name === "main") { // filter all main expression references
e.filter(filterClause);
}
return null;
});
}
```

And needed configuration:

```yaml
...
dataCubes:
- name: cube
queryDecorator:
path: ./decorator.js
options:
userColumnName: "user_id"
```

## Plugins

Most powerful way to extend turnilo are plugins. They are defined at top level in config and apply for whole Turnilo application.
You need to add your plugin as entry under `plugins` field.
Plugin need to have two fields:
- `name` - name for debug purposes
- `path` - path to the js file
It can define additional field `settings`. Content of this field would be passed to plugin so it is good place for additional parameters.

```yaml
plugins:
- name: example_plugin
path: ./plugin.js
settings:
favourite_number: 42
```

Plugin file need to export function named `plugin`.
This function will be called at the start of application with following parameters:
* `app` - Express instance of Turnilo application. Remember that Turnilo routes are called after your plugins so be careful.
* `pluginSettings` - object from `settings` field in configuration
* `serverSettings` - object representing server settings, like port, host, ready- and live- endpoints.
* `appSettings` - function that returns promise with application settings - definitions of clusters, data sources and customization.
* `logger` - logger object that you can use to log anything

Worth to look into !(express documentation)[https://expressjs.com/en/api.html#app].
Use `get`, `post` etc. to define new endpoints. Use `use` to define middleware.

Additionally, Turnilo defines empty object on Request object under `turniloMetadata` key.
Here you can pass values between your plugins and not pollute headers.
57 changes: 3 additions & 54 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,60 +42,9 @@ Any query asking for a column that was not explicitly defined in the dimensions
A Turnilo dataSource can define a `subsetFormula` that is a boolean Plywood filter clause that will be silently applied to all queries made to that data cube.
For example if you wanted your users to only see the data for "United States" you could add `subsetFormula: $country == "United States"` to the data cube definition.

Turnilo dataSource can also define `queryDecorator` - function that can decorate Plywood query. In this case it could additional filter clause that will be silently applied to all queries made to that cube.
This function is called at every query and have access to Request object. Read more about ![query decorator](./extending-turnilo.md).

## Authentication

Turnilo can authenticate to a Druid server via request decoration. You can utilize it as follows:

In the config add a key of `druidRequestDecorator` with property `path` that point to a relative js file.

```yaml
druidRequestDecorator:
path: './druid-request-decorator.js'
```

You can also pass parameters to your decorator using `options` field. Content of this field will be read as json and passed
to your `druidRequestDecoratorFactory` under `options` key in second parameter.

```yaml
druidRequestDecorator:
path: './druid-request-decorator.js'
options:
keyA: valueA
keyB:
- firstElement
- secondElement
```

Then the contract is that your module should export a function `druidRequestDecorator` that has to return a decorator.

A decorator is a function that gets called on every request and receives a Druid query and may return an object with the
key `headers` where you can set whatever headers you want.

Here is an example decorator:

```javascript
exports.version = 1;

exports.druidRequestDecoratorFactory = function (logger, params) {
const options = params.options;
const username = options.username;
const password = options.password;

const auth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");

return function () {
return {
headers: {
"Authorization": auth
},
};
};
};
```

You can find this example with additional comments and example config in the [./example](./example/request-decoration) folder.

This would result in all Druid requests being tagged as:

![decoration example](./example/request-decoration/result.png)
Turnilo can authenticate to a Druid server via ![request decoration](./extending-turnilo.md).
21 changes: 12 additions & 9 deletions src/common/models/data-cube/data-cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ import { Cluster } from "../cluster/cluster";
import { Dimension } from "../dimension/dimension";
import { DimensionOrGroupJS } from "../dimension/dimension-group";
import { Dimensions } from "../dimension/dimensions";
import { DynamicSubsetFormula, DynamicSubsetFormulaDef } from "../dynamic-subset-formula/dynamic-subset-formula";
import { RelativeTimeFilterClause, TimeFilterPeriod } from "../filter-clause/filter-clause";
import { EMPTY_FILTER, Filter } from "../filter/filter";
import { Measure } from "../measure/measure";
import { MeasureOrGroupJS } from "../measure/measure-group";
import { Measures } from "../measure/measures";
import { QueryDecoratorDefinition, QueryDecoratorDefinitionJS } from "../query-decorator/query-decorator";
import { RefreshRule, RefreshRuleJS } from "../refresh-rule/refresh-rule";
import { EMPTY_SPLITS, Splits } from "../splits/splits";
import { Timekeeper } from "../timekeeper/timekeeper";
Expand Down Expand Up @@ -106,7 +106,7 @@ export interface DataCubeValue {
refreshRule?: RefreshRule;
maxSplits?: number;
maxQueries?: number;
dynamicSubsetFormula?: DynamicSubsetFormula;
queryDecorator?: QueryDecoratorDefinition;

cluster?: Cluster;
executor?: Executor;
Expand Down Expand Up @@ -141,7 +141,7 @@ export interface DataCubeJS {
refreshRule?: RefreshRuleJS;
maxSplits?: number;
maxQueries?: number;
dynamicSubsetFormula?: DynamicSubsetFormulaDef;
queryDecorator?: QueryDecoratorDefinitionJS;
}

export interface DataCubeOptions {
Expand Down Expand Up @@ -277,7 +277,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
defaultPinnedDimensions: parameters.defaultPinnedDimensions ? OrderedSet(parameters.defaultPinnedDimensions) : null,
maxSplits: parameters.maxSplits,
maxQueries: parameters.maxQueries,
dynamicSubsetFormula: parameters.dynamicSubsetFormula ? DynamicSubsetFormula.fromJS(parameters.dynamicSubsetFormula) : null,
queryDecorator: parameters.queryDecorator ? QueryDecoratorDefinition.fromJS(parameters.queryDecorator) : null,
refreshRule
};
if (cluster) {
Expand Down Expand Up @@ -316,7 +316,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
public refreshRule: RefreshRule;
public maxSplits: number;
public maxQueries: number;
public dynamicSubsetFormula?: DynamicSubsetFormula;
public queryDecorator?: QueryDecoratorDefinition;

public cluster: Cluster;
public executor: Executor;
Expand Down Expand Up @@ -349,7 +349,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
this.defaultPinnedDimensions = parameters.defaultPinnedDimensions;
this.maxSplits = parameters.maxSplits;
this.maxQueries = parameters.maxQueries;
this.dynamicSubsetFormula = parameters.dynamicSubsetFormula;
this.queryDecorator = parameters.queryDecorator;

const { description, extendedDescription } = this.parseDescription(parameters);
this.description = description;
Expand Down Expand Up @@ -399,7 +399,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
refreshRule: this.refreshRule,
maxSplits: this.maxSplits,
maxQueries: this.maxQueries,
dynamicSubsetFormula: this.dynamicSubsetFormula
queryDecorator: this.queryDecorator
};
if (this.cluster) value.cluster = this.cluster;
if (this.executor) value.executor = this.executor;
Expand Down Expand Up @@ -431,7 +431,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
if (this.rollup) js.rollup = true;
if (this.maxSplits) js.maxSplits = this.maxSplits;
if (this.maxQueries) js.maxQueries = this.maxQueries;
if (this.dynamicSubsetFormula) js.dynamicSubsetFormula = this.dynamicSubsetFormula.toJS();
if (this.queryDecorator) js.queryDecorator = this.queryDecorator.toJS();
if (this.timeAttribute) js.timeAttribute = this.timeAttribute.name;
if (this.attributeOverrides.length) js.attributeOverrides = AttributeInfo.toJSs(this.attributeOverrides);
if (this.attributes.length) js.attributes = AttributeInfo.toJSs(this.attributes);
Expand Down Expand Up @@ -484,7 +484,7 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
(!this.defaultPinnedDimensions || this.defaultPinnedDimensions.equals(other.defaultPinnedDimensions)) &&
this.maxSplits === other.maxSplits &&
this.maxQueries === other.maxQueries &&
safeEquals(this.dynamicSubsetFormula, other.dynamicSubsetFormula) &&
safeEquals(this.queryDecorator, other.queryDecorator) &&
this.refreshRule.equals(other.refreshRule);
}

Expand Down Expand Up @@ -650,6 +650,9 @@ export class DataCube implements Instance<DataCubeValue, DataCubeJS> {
// Do not reveal the subset filter to the client
value.subsetFormula = null;

// Do not reveal query decorator to the client
value.queryDecorator = null;

// No need for any introspection information on the client
value.introspection = null;

Expand Down
60 changes: 0 additions & 60 deletions src/common/models/dynamic-subset-formula/dynamic-subset-formula.ts

This file was deleted.

0 comments on commit a19fff8

Please sign in to comment.