Skip to content

Commit

Permalink
feat: Integrate OpenAPI client into data connector (#206)
Browse files Browse the repository at this point in the history
* feat: Integrate OpenAPI client into data connector module for enhanced API support and flexibility. Update dev script for dotenv support. Add OpenAPI client class with initialization and request execution capabilities. Modify config to support OpenAPI connector type. Update index.ts to handle OpenAPI connections. Create open-api.ts for OpenAPI client implementation. Adjust package.json dev script to use tsx with dotenv. PR Description: - Added OpenAPI client class to handle API requests. - Updated config.json to support OpenAPI connector type. - Modified index.ts to integrate OpenAPI connections. - Created open-api.ts for OpenAPI client implementation. - Adjusted package.json dev script to use tsx with dotenv.

* feat: Enhance data-connector with OpenAPI operations and initialization improvements

* update

* Update data-connector/README.md

Co-authored-by: John Smith <[email protected]>

* update

* update

---------

Co-authored-by: John Smith <[email protected]>
  • Loading branch information
nadeesha and johnjcsmith authored Dec 3, 2024
1 parent 6acda22 commit f9a3c01
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 17 deletions.
7 changes: 7 additions & 0 deletions data-connector/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ INFERABLE_API_SECRET=your_api_secret_here # Get one from https://app.inferable.a
#
POSTGRES_URL=postgresql://postgres:postgres@db:5432/postgres
POSTGRES_SCHEMA=public

#
# OpenAPI config example.
#
# OPENAPI_SPEC_URL=https://api.inferable.ai/public/oas.json
# SERVER_URL=https://api.inferable.ai
# SERVER_AUTH_HEADER=Authorization: Bearer your_api_key_here
37 changes: 27 additions & 10 deletions data-connector/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Inferable Data Connector
![Inferable Data Connector](./assets/hero.png)

Inferable Data Connector is a bridge between your data systems and Inferable. Configure your data sources in a json file and start conversing with your data in natural language.

Works locally, and in any dockerized environment without exposing your database to the public internet.
Inferable Data Connector is a bridge between your data systems and Inferable. Configure your data sources in a json file and start conversing with your data in natural language. Works locally, and in any dockerized environment allowing connection to private resources (DB connection / API endpoints) without exposing them to the public internet.

## Features

Expand All @@ -13,6 +11,14 @@ Works locally, and in any dockerized environment without exposing your database
- 🤿 **Optional Privacy Mode**: Query outputs are never sent to the model. Instead, the function returns results directly to the end user without any model involvement.
- 🔍 **Optional Paranoid Mode**: Adds an additional safety layer by requiring manual approval before executing any query so you can review the query and data before it is executed.

## Connectors

- [x] [Postgres](./src/postgres.ts)
- [x] [OpenAPI](./src/open-api.ts)
- [ ] [GraphQL](./src/graphql.ts)
- [ ] [MySQL](./src/mysql.ts)
- [ ] [SQLite](./src/sqlite.ts)

## Quick Start

### Running with your own Postgres DB
Expand Down Expand Up @@ -88,14 +94,25 @@ Example configuration:

Each connector is defined in the `config.connectors` array.

- `type`: The type of connector. Currently only `postgres` is supported.
- `name`: The name of the connector. This is the Inferable service name. One will be generated if not provided.
- `config.connectors[].type`: The type of connector. Currently only `postgres` is supported.
- `config.connectors[].name`: The name of the connector. This is the Inferable service name. One will be generated if not provided.

<details>
<summary>Postgres Connector Configuration</summary>

- `config.connectors[].connectionString`: The connection string to your database. (e.g. `postgresql://postgres:postgres@localhost:5432/postgres`)
- `config.connectors[].schema`: The schema to use. (e.g. `public`)

</details>

<details>
<summary>OpenAPI Connector Configuration</summary>

### Connector-specific configuration
- `config.connectors[].specUrl`: The URL to your OpenAPI spec. Must be publicly accessible.
- `config.connectors[].endpoint`: The endpoint to use. (e.g. `https://api.inferable.ai`)
- `config.connectors[].defaultHeaders`: The default headers to use. (e.g. `{"Authorization": "Bearer <token>"}`)

| Connector | Configuration |
| --------- | ------------------------------- |
| Postgres | `connectionString` and `schema` |
</details>

### config.privacyMode

Expand Down
Binary file added data-connector/assets/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion data-connector/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
"name": "myPostgres",
"connectionString": "process.env.POSTGRES_URL",
"schema": "process.env.POSTGRES_SCHEMA"
},
{
"type": "open-api",
"specUrl": "process.env.OPENAPI_SPEC_URL",
"endpoint": "process.env.SERVER_URL",
"defaultHeaders": {
"Authorization": "process.env.SERVER_AUTH_HEADER"
}
}
]
}
}
2 changes: 1 addition & 1 deletion data-connector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"dev": "nodemon src/index.ts",
"dev": "tsx --watch -r dotenv/config src/index.ts",
"docker": "docker-compose up --build",
"docker:push": "docker build . -t inferable/data-connector && docker push inferable/data-connector:latest",
"seed": "tsx example_data/seed-postgres.ts"
Expand Down
11 changes: 11 additions & 0 deletions data-connector/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "dotenv/config";
import { Inferable } from "inferable";
import { PostgresClient } from "./postgres";
import { RegisteredService } from "inferable/bin/types";
import { OpenAPIClient } from "./open-api";

const parseConfig = () => {
const config = require("../config.json");
Expand Down Expand Up @@ -41,8 +42,18 @@ const parseConfig = () => {
paranoidMode: config.paranoidMode === 1,
privacyMode: config.privacyMode === 1,
});
await postgresClient.initialize();
const service = postgresClient.createService(client);
services.push(service);
} else if (connector.type === "open-api") {
const openAPIClient = new OpenAPIClient({
...connector,
paranoidMode: config.paranoidMode === 1,
privacyMode: config.privacyMode === 1,
});
await openAPIClient.initialize();
const service = openAPIClient.createService(client);
services.push(service);
}
}

Expand Down
258 changes: 258 additions & 0 deletions data-connector/src/open-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { approvalRequest, blob, ContextInput, Inferable } from "inferable";
import { z } from "zod";
import fetch from "node-fetch";
import type { DataConnector } from "./types";
import { OpenAPIV3 } from "openapi-types";
import crypto from "crypto";
import { FunctionRegistrationInput } from "inferable/bin/types";
import assert from "assert";

export class OpenAPIClient implements DataConnector {
private spec: OpenAPIV3.Document | null = null;
private initialized = false;
private operations: ReturnType<
typeof this.openApiOperationToInferableFunction
>[] = [];

constructor(
private params: {
name?: string;
specUrl: string;
endpoint?: string;
defaultHeaders?: Record<string, string>;
privacyMode: boolean;
paranoidMode: boolean;
},
) {}

public initialize = async () => {
try {
const response = await fetch(this.params.specUrl);
this.spec = (await response.json()) as OpenAPIV3.Document;
console.log(
`OpenAPI spec loaded successfully from ${this.params.specUrl}`,
);

// Convert paths and their operations into functions
for (const [path, pathItem] of Object.entries(this.spec.paths)) {
if (!pathItem) continue;

const operations = ["get", "post", "put", "delete", "patch"] as const;

for (const method of operations) {
const operation = pathItem[method];
if (!operation || !operation.operationId) continue;

const inferableFunction = this.openApiOperationToInferableFunction(
operation,
path,
method,
);

this.operations.push(inferableFunction);
}
}

console.log(
`Loaded ${this.operations.length} operations from OpenAPI spec`,
);

if (this.params.privacyMode) {
console.log(
"Privacy mode is enabled, response data will not be sent to the model.",
);
}

this.initialized = true;
} catch (error) {
console.error("Failed to initialize OpenAPI connection:", error);
throw error;
}
};

private openApiOperationToInferableFunction = (
operation: OpenAPIV3.OperationObject,
path: string,
method: string,
): FunctionRegistrationInput<any> => {
// Build input parameters schema
const parameters = operation.parameters || [];
const parameterSchemas: Record<string, any> = {};

// Group parameters by their location (path, query, header)
const parametersByLocation = {
path: [] as string[],
query: [] as string[],
header: [] as string[],
};

parameters.forEach((param) => {
if ("name" in param && "in" in param) {
parametersByLocation[
param.in as keyof typeof parametersByLocation
]?.push(param.name);
if (param.schema) {
parameterSchemas[param.name] = param.schema;
}
}
});

// Handle request body if it exists
let bodySchema:
| OpenAPIV3.ReferenceObject
| OpenAPIV3.SchemaObject
| undefined = undefined;
if (operation.requestBody && "content" in operation.requestBody) {
const content = operation.requestBody.content["application/json"];
if (content?.schema) {
bodySchema = content.schema;
}
}

assert(operation.operationId, "Operation ID is required");
assert(path, "Path is required");

const hasParameters = Object.keys(parameterSchemas).length > 0;

const summary =
operation.summary ||
operation.description ||
`${method.toUpperCase()} ${path}`;

return {
name: operation.operationId,
description: `${summary}. Ask the user to provide values for any required parameters.`,
func: this.executeRequest,
schema: {
input: z.object({
path: path.includes(":")
? z
.string()
.describe(
`Must be ${path} with values substituted for any path parameters.`,
)
: z.literal(path),
method: z.literal(method.toUpperCase()),
parameters: hasParameters
? z
.record(z.any())
.optional()
.describe(
`URL and query parameters. Must match the following: ${JSON.stringify(
parametersByLocation,
)}`,
)
: z.undefined(),
body: bodySchema
? z
.any()
.optional()
.describe(
`Request body. Must match: ${JSON.stringify(bodySchema)}`,
)
: z.undefined(),
}),
},
};
};

executeRequest = async (
input: {
path: string;
method: string;
parameters?: Record<string, any>;
body?: any;
},
ctx: ContextInput,
) => {
if (this.params.paranoidMode) {
if (!ctx.approved) {
console.log("Request requires approval");
return approvalRequest();
} else {
console.log("Request approved");
}
}

if (!this.initialized) throw new Error("OpenAPI spec not initialized");
if (!this.spec) throw new Error("OpenAPI spec not initialized");

// Use the provided endpoint or fall back to the spec's server URL
let url = (
this.params.endpoint ||
this.spec.servers?.[0]?.url ||
""
).toString();
let finalPath = input.path;

if (input.parameters) {
// Replace path parameters
Object.entries(input.parameters).forEach(([key, value]) => {
finalPath = finalPath.replace(
`{${key}}`,
encodeURIComponent(String(value)),
);
});
}

url += finalPath;

// Merge default headers with the Content-Type header
const headers = {
"Content-Type": "application/json",
...this.params.defaultHeaders,
};

const response = await fetch(url, {
method: input.method,
headers,
body: input.body ? JSON.stringify(input.body) : undefined,
});

const data = await response.text();

let parsed: object;

try {
parsed = JSON.parse(data);
} catch (error) {
parsed = {
data,
};
}

if (this.params.privacyMode) {
return {
message:
"This request was executed in privacy mode. Data was returned to the user directly.",
blob: blob({
name: "Results",
type: "application/json",
data: parsed,
}),
};
}

return parsed;
};

private connectionStringHash = () => {
return crypto
.createHash("sha256")
.update(this.params.specUrl)
.digest("hex")
.substring(0, 8);
};

createService = (client: Inferable) => {
const service = client.service({
name: this.params.name ?? `openapi${this.connectionStringHash()}`,
});

this.operations.forEach((operation) => {
service.register(operation);
});

return service;
};
}
3 changes: 1 addition & 2 deletions data-connector/src/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ export class PostgresClient implements DataConnector {
},
) {
assert(params.schema, "Schema parameter is required");
this.initialized = this.initialize();
}

private initialize = async () => {
public initialize = async () => {
try {
const client = await this.getClient();
const res = await client.query(`SELECT NOW() as now`);
Expand Down
Loading

0 comments on commit f9a3c01

Please sign in to comment.