Skip to content

Commit

Permalink
fetch: switch to simplet undici fetcher
Browse files Browse the repository at this point in the history
  • Loading branch information
vivedo committed Jul 10, 2024
1 parent 41a1013 commit 94676ba
Show file tree
Hide file tree
Showing 10 changed files with 130 additions and 61 deletions.
10 changes: 0 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,6 @@ app.register(opaAuthPlugin, {
})
```

## OPA Options

This plugins uses Styra's `@styra/opa` SDK to perform queries to the OPA server. Its options can be changed via configuration.
See the [official documentation](https://styrainc.github.io/opa-typescript/)

```typescript
app.register(opaAuthPlugin, {
// ...
Expand All @@ -94,11 +89,6 @@ app.register(opaAuthPlugin, {
})
```

## Native Node.js fetch

This plugin relies on `@styra/opa` which internally relies on _native Node.js fetch_ which is available as an **experimental**
feature from _Node.js 16_ and as a **stable** feature in _Node.js 22_

## 🌳 Join Us in Making a Difference! 🌳

We invite all developers who use Treedom's open-source code to support our mission of sustainability by planting a tree with us. By contributing to reforestation efforts, you help create a healthier planet and give back to the environment. Visit our [Treedom Open Source Forest](https://www.treedom.net/en/organization/treedom/event/treedom-open-source) to plant your tree today and join our community of eco-conscious developers.
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@treedom/mercurius-auth-opa",
"version": "1.1.0",
"version": "2.0.0-rc0",
"main": "lib/index.js",
"scripts": {
"test": "borp",
Expand All @@ -24,9 +24,10 @@
"license": "MIT",
"description": "Mercurius OPA authentication directive plugin based on mercurius-auth",
"dependencies": {
"@styra/opa": "1.0.0",
"mercurius": ">=12.0.0",
"mercurius-auth": ">=4.0.0"
"mercurius-auth": ">=4.0.0",
"object-hash": ">=3.0.0",
"undici": ">=6.0.0"
},
"devDependencies": {
"@types/node": "20.14.0",
Expand All @@ -46,8 +47,7 @@
"pino-pretty": "11.1.0",
"prettier": "3.3.0",
"sinon": "18.0.0",
"typescript": "5.4.5",
"undici": "6.18.2"
"typescript": "5.4.5"
},
"files": [
"lib"
Expand Down
70 changes: 70 additions & 0 deletions src/OpenPolicyAgentClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Cache } from './types'
import { request } from 'undici'
import { getCacheKey } from './getCacheKey'
import { OpenPolicyAgentClientProps } from './types/openPolicyAgentClientProps'

export class OpenPolicyAgentClient<TCache extends Cache> {
public readonly cache: TCache | undefined
private readonly url: string

constructor(
config: OpenPolicyAgentClientProps<TCache> | string,
private readonly opaVersion = 'v1',
private readonly method: 'POST' | 'GET' = 'POST'
) {
/* istanbul ignore next */
if (typeof config === 'object') {
this.url = config.url

if (config.opaVersion) {
this.opaVersion = config.opaVersion
}

if (config.method) {
this.method = config.method
}

if (config.cache) {
this.cache = config.cache
}
} else {
this.url = config
}
}

/**
* Query the requested OPA resource.
*
* @param resource Can be expressed both in dot notation or slash notation.
* @param input OPA Query input.
*/
public async query<TResponse = { result: boolean }>(
resource: string,
input?: unknown
): Promise<TResponse> {
const resourcePath = resource.replace(/\./gi, '/')

const cacheKey = getCacheKey(resourcePath, input)
const cached = this.cache?.get(cacheKey) as TResponse | undefined

if (cached) {
return cached
}

const res = await request(
`${this.url}/${this.opaVersion}/data/${resourcePath}`,
{
method: this.method,
body: input ? JSON.stringify({ input }) : undefined,
}
)

const body = (await res.body.json()) as TResponse

if (this.cache) {
this.cache.set(cacheKey, body)
}

return body
}
}
8 changes: 8 additions & 0 deletions src/getCacheKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { sha1 } from 'object-hash'

export const getCacheKey = (resource: string, input?: unknown) => {
return sha1({
resource,
input,
})
}
22 changes: 15 additions & 7 deletions src/opaAuthPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { FastifyInstance, FastifyPluginCallback } from 'fastify'
import type { PluginProps } from './types/pluginProps'
import type { Cache, PluginProps } from './types'
import fp from 'fastify-plugin'
import { OPAClient } from '@styra/opa'
import mercuriusAuth, { MercuriusAuthOptions } from 'mercurius-auth'
import mercurius from 'mercurius'
import { parseDirectiveArgumentsAST } from './parseDirectiveArgumentsAST'
import { OpenPolicyAgentClient } from './OpenPolicyAgentClient'

export const opaAuthPlugin: FastifyPluginCallback<PluginProps> = fp(
async (app: FastifyInstance, props: PluginProps) => {
const opa = new OPAClient(props.opaEndpoint, props.opaOptions)
const opa = new OpenPolicyAgentClient(props.opaOptions)

app.decorate('opa', opa)

app.register<MercuriusAuthOptions>(mercuriusAuth, {
/**
Expand All @@ -19,14 +21,14 @@ export const opaAuthPlugin: FastifyPluginCallback<PluginProps> = fp(
/**
* Validate directive
*/
async applyPolicy(ast, parent, args, context, info) {
async applyPolicy(ast, parent, args, context) {
const { path, options } = parseDirectiveArgumentsAST(ast.arguments) as {
path: string
options?: object
}

const allowed = await opa
.evaluate(path, {
const allowed = await app.opa
.query(path, {
headers: context.reply.request.headers,
parent,
args,
Expand All @@ -40,7 +42,7 @@ export const opaAuthPlugin: FastifyPluginCallback<PluginProps> = fp(
})
})

if (!allowed) {
if (!allowed.result) {
throw new mercurius.ErrorWithProps('Not authorized', {
code: 'NOT_AUTHORIZED',
})
Expand All @@ -55,3 +57,9 @@ export const opaAuthPlugin: FastifyPluginCallback<PluginProps> = fp(
},
{ name: 'opa-auth', dependencies: ['mercurius'] }
)

declare module 'fastify' {
interface FastifyInstance {
opa: OpenPolicyAgentClient<Cache>
}
}
4 changes: 4 additions & 0 deletions src/types/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Cache = {
get(key: string): any
set(key: string, value: any): void
}
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './cache'
export * from './pluginProps'
8 changes: 8 additions & 0 deletions src/types/openPolicyAgentClientProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Cache } from './cache'

export type OpenPolicyAgentClientProps<TCache extends Cache> = {
url: string
opaVersion?: string
method?: 'POST' | 'GET'
cache?: TCache
}
11 changes: 7 additions & 4 deletions src/types/pluginProps.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { AuthContextHandler } from 'mercurius-auth'
import type { MercuriusContext } from 'mercurius'
import type { Options } from '@styra/opa'
import { OpenPolicyAgentClientProps } from './openPolicyAgentClientProps'
import { Cache } from './cache'

export type PluginProps<TContext = MercuriusContext> = {
opaEndpoint?: string
opaOptions?: Options
export type PluginProps<
TContext = MercuriusContext,
TCache extends Cache = Cache,
> = {
opaOptions?: OpenPolicyAgentClientProps<TCache>

/**
* @default opa
Expand Down
46 changes: 11 additions & 35 deletions test/opaAuthPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { before, beforeEach, mock, test } from 'node:test'
import { beforeEach, test } from 'node:test'
import { deepStrictEqual } from 'node:assert'
import fastify from 'fastify'
import { testLogger } from './helpers/testLogger'
Expand All @@ -7,44 +7,14 @@ import mercurius from 'mercurius'
import { createMercuriusTestClient } from 'mercurius-integration-testing'
import fs from 'node:fs'
import path from 'node:path'
import { MockAgent, setGlobalDispatcher, fetch } from 'undici'
import { MockAgent, setGlobalDispatcher } from 'undici'
import sinon from 'sinon'
import { MockInterceptor } from 'undici-types/mock-interceptor'

const mockAgent = new MockAgent()
mockAgent.disableNetConnect()
setGlobalDispatcher(mockAgent)

before(() => {
// Replacing node builtin fetch with undici to be able to use undici's mock functionality
mock.method(global, 'fetch', async (request: Request) => {
/*
* Styra's OPA client validates the response type with Zod, ensuring it is an instance of the node built-in Response
* object.
* However, undici's Response is not actually as an instance of the node built-in Response, which causes
* Zod to raise an error. This workaround is implemented to satisfy Zod's requirements while still utilizing
* undici's mock functionality in this test suite
*/
// eslint-disable-next-line
// @ts-ignore
return fetch(request.url, {
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.body
.getReader()
.read()
.then(({ value }) => Buffer.from(value).toString('utf-8')),
}).then(
(response) =>
new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
)
})
})

beforeEach(() => {
mockAgent.removeAllListeners()
})
Expand All @@ -68,7 +38,9 @@ ping(message: String!): String!
})

app.register(opaAuthPlugin, {
opaEndpoint: 'http://opa.test:3000',
opaOptions: {
url: 'http://opa.test:3000',
},
})

const testClient = createMercuriusTestClient(app)
Expand Down Expand Up @@ -98,7 +70,9 @@ ping(message: String!): String! @opa(path: "query/ping", options: { bar: "foo",
})

app.register(opaAuthPlugin, {
opaEndpoint: 'http://opa.test:3000',
opaOptions: {
url: 'http://opa.test:3000',
},
})

const opaPolicyMock = sinon
Expand Down Expand Up @@ -157,7 +131,9 @@ type Query {
})

app.register(opaAuthPlugin, {
opaEndpoint: 'http://opa.test:3000',
opaOptions: {
url: 'http://opa.test:3000',
},
})

mockAgent
Expand Down

0 comments on commit 94676ba

Please sign in to comment.