Skip to content

Commit 9a1eda6

Browse files
committed
Add support for JSON-RPC specs in our public API list
1 parent 1153c28 commit 9a1eda6

File tree

9 files changed

+134
-75
lines changed

9 files changed

+134
-75
lines changed

src/components/settings/api-settings-card.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { SettingsButton, SettingsExplanation } from './settings-components';
2424
import { TextInput } from '../common/inputs';
2525
import { ApiStore } from '../../model/api/api-store';
2626
import { ContentLabel } from '../common/text-content';
27+
import { ApiMetadata } from '../../model/api/api-interfaces';
2728

2829
const UploadSpecButton = styled(SettingsButton).attrs(() => ({
2930
type: 'submit'
@@ -278,7 +279,7 @@ export class ApiSettingsCard extends React.Component<
278279
saveApi = flow(function * (this: ApiSettingsCard) {
279280
const baseUrl = this.enteredBaseUrl.replace(/https?:\/\//, '');
280281

281-
const api = yield buildApiMetadataAsync(
282+
const api: ApiMetadata = yield buildApiMetadataAsync(
282283
this.selectedSpec!,
283284
['http://' + baseUrl, 'https://' + baseUrl]
284285
);

src/model/api/api-interfaces.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import type * as querystring from 'querystring';
21
import type {
2+
OpenAPIObject,
33
SchemaObject
44
} from 'openapi-directory';
55

66
import type {
77
HtkResponse,
88
Html
99
} from "../../types";
10-
import { OpenApiMetadata } from './build-openapi';
10+
11+
import type { OpenApiMetadata } from './build-api-metadata';
12+
import type { OpenRpcDocument, OpenRpcMetadata } from './jsonrpc';
1113

1214
export type ApiMetadata =
13-
| OpenApiMetadata;
15+
| OpenApiMetadata
16+
| OpenRpcMetadata;
1417

15-
export interface ApiRequestMatcher {
16-
pathMatcher: RegExp;
17-
queryMatcher: querystring.ParsedUrlQuery;
18-
}
18+
export type ApiSpec =
19+
| OpenAPIObject
20+
| OpenRpcDocument;
1921

2022
export interface ApiExchange {
2123
readonly service: ApiService;

src/model/api/api-store.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@ import * as _ from 'lodash';
22
import { observable, observe, computed } from "mobx";
33
import * as localForage from 'localforage';
44
import * as serializr from 'serializr';
5-
import { findApi as findPublicOpenApi, OpenAPIObject } from 'openapi-directory';
5+
import { findApi as findPublicOpenApi } from 'openapi-directory';
66

77
import { HtkRequest } from '../../types';
88
import { reportError } from '../../errors';
99
import { lazyObservablePromise } from "../../util/observable";
1010
import { hydrate, persist } from "../../util/mobx-persist/persist";
1111

1212
import { AccountStore } from "../account/account-store";
13-
import { ApiMetadata } from "./api-interfaces";
13+
import { ApiMetadata, ApiSpec } from "./api-interfaces";
1414
import { buildApiMetadataAsync } from '../../services/ui-worker-api';
15-
import { findBestMatchingApi } from './openapi';
15+
import { matchOpenApiOperation } from './openapi';
1616
import { serializeRegex, serializeMap } from '../serialization';
1717

18-
async function fetchApiMetadata(specId: string): Promise<OpenAPIObject> {
18+
async function fetchApiMetadata(specId: string): Promise<ApiSpec> {
1919
const specResponse = await fetch(`/api/${specId}.json`);
2020
return specResponse.json();
2121
}
@@ -125,12 +125,12 @@ export class ApiStore {
125125
: undefined;
126126
}
127127

128-
private publicOpenApiCache: _.Dictionary<Promise<ApiMetadata>> = {};
128+
private publicApiCache: _.Dictionary<Promise<ApiMetadata>> = {};
129129

130130
private getPublicApi(specId: string) {
131-
const { publicOpenApiCache } = this;
132-
if (!publicOpenApiCache[specId]) {
133-
publicOpenApiCache[specId] = fetchApiMetadata(specId)
131+
const { publicApiCache } = this;
132+
if (!publicApiCache[specId]) {
133+
publicApiCache[specId] = fetchApiMetadata(specId)
134134
.then(buildApiMetadataAsync)
135135
.catch((e) => {
136136
console.log(`Failed to build API ${specId}`);
@@ -140,11 +140,11 @@ export class ApiStore {
140140
throw e;
141141
});
142142
}
143-
return publicOpenApiCache[specId];
143+
return publicApiCache[specId];
144144
}
145145

146146
async getApi(request: HtkRequest): Promise<ApiMetadata | undefined> {
147-
const { parsedUrl, url } = request;
147+
const { parsedUrl } = request;
148148

149149
// Is this a configured private API? I.e. has the user explicitly given
150150
// us a spec to use for requests like these.
@@ -154,10 +154,9 @@ export class ApiStore {
154154
}
155155

156156
// If not, is this a known public API? (note that private always has precedence)
157-
const requestUrl = `${parsedUrl.hostname}${parsedUrl.pathname}`;
157+
const requestUrl = `${parsedUrl.host}${parsedUrl.pathname}`;
158158

159159
let publicSpecId = findPublicOpenApi(requestUrl);
160-
161160
if (!publicSpecId) return;
162161
if (!Array.isArray(publicSpecId)) publicSpecId = [publicSpecId];
163162

@@ -179,4 +178,34 @@ export class ApiStore {
179178
return findBestMatchingApi(publicSpecs, request);
180179
}
181180

181+
}
182+
183+
// If we match multiple APIs, build all of them, try to match each one
184+
// against the request, and return only the matching one. This happens if
185+
// multiple services have the exact same base request URL (rds.amazonaws.com)
186+
export function findBestMatchingApi(
187+
apis: ApiMetadata[],
188+
request: HtkRequest
189+
): ApiMetadata | undefined {
190+
const matchingApis = apis.filter((api) =>
191+
api.type == 'openrpc' || // Since we have so few JSON-RPC APIs, we assume they match
192+
matchOpenApiOperation(api, request).matched // For OpenAPI, we check for a matching op
193+
);
194+
195+
// If we've successfully found one matching API, return it
196+
if (matchingApis.length === 1) return matchingApis[0];
197+
198+
// If this is a non-matching request to one of these APIs, but we're not
199+
// sure which, return the one with the most paths (as a best guess metric of
200+
// which is the most popular service)
201+
if (matchingApis.length === 0) return _.maxBy(apis, a => a.spec.paths.length)!;
202+
203+
// Otherwise we've matched multiple APIs which all define operations that
204+
// could be this one request. Does exist right now (e.g. AWS RDS vs DocumentDB)
205+
206+
// Report this so we can try to improve & avoid in future.
207+
reportError('Overlapping APIs', matchingApis);
208+
209+
// Return our guess of the most popular service, from the matching services only
210+
return _.maxBy(matchingApis, a => a.spec.paths.length)!;
182211
}

src/model/api/build-openapi.ts renamed to src/model/api/build-api-metadata.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,23 @@ import * as _ from 'lodash';
22
import * as querystring from 'querystring';
33

44
import { OpenAPIObject, PathItemObject } from 'openapi-directory';
5+
import { MethodObject } from '@open-rpc/meta-schema';
56
import * as Ajv from 'ajv';
67

7-
import type { ApiRequestMatcher } from './api-interfaces';
88
import { openApiSchema } from './openapi-schema';
99
import { dereference } from '../../util/json-schema';
10-
10+
import { OpenRpcDocument, OpenRpcMetadata } from './jsonrpc';
1111

1212
export interface OpenApiMetadata {
1313
type: 'openapi';
1414
spec: OpenAPIObject;
1515
serverMatcher: RegExp;
16-
requestMatchers: Map<ApiRequestMatcher, Path>;
16+
requestMatchers: Map<OpenApiRequestMatcher, Path>;
17+
}
18+
19+
interface OpenApiRequestMatcher {
20+
pathMatcher: RegExp;
21+
queryMatcher: querystring.ParsedUrlQuery;
1722
}
1823

1924
interface Path {
@@ -25,7 +30,9 @@ const filterSpec = new Ajv({
2530
removeAdditional: 'failing'
2631
}).compile(openApiSchema);
2732

28-
function templateStringToRegexString(template: string): string {
33+
// Note that OpenAPI template strings are not the same as JSON template language templates,
34+
// which use ${...} instead of {...}.
35+
function openApiTemplateStringToRegexString(template: string): string {
2936
return template
3037
// Escape all regex chars *except* { }
3138
.replace(/[\^$\\.*+?()[\]|]/g, '\\$&')
@@ -35,7 +42,7 @@ function templateStringToRegexString(template: string): string {
3542
.replace(/\/$/, '');
3643
}
3744

38-
export async function buildApiMetadata(
45+
export async function buildOpenApiMetadata(
3946
spec: OpenAPIObject,
4047
baseUrlOverrides?: string[]
4148
): Promise<OpenApiMetadata> {
@@ -58,18 +65,18 @@ export async function buildApiMetadata(
5865
}
5966

6067
// Now it's relatively small & tidy, dereference everything.
61-
spec = <OpenAPIObject> dereference(spec);
68+
spec = dereference(spec);
6269

6370
const serverUrlRegexSources = baseUrlOverrides && baseUrlOverrides.length
6471
// Look for one of the given base URLs, ignoring trailing slashes
6572
? baseUrlOverrides.map(url => _.escapeRegExp(url).replace(/\/$/, ''))
6673
// Look for any URL of the base servers in the spec
67-
: spec.servers!.map(s => templateStringToRegexString(s.url));
74+
: spec.servers!.map(s => openApiTemplateStringToRegexString(s.url));
6875

6976
// Build a regex that matches any of these at the start of a URL
70-
const serverMatcher = new RegExp(`^(${serverUrlRegexSources.join('|')})`, 'i')
77+
const serverMatcher = new RegExp(`^(${serverUrlRegexSources.join('|')})`, 'i');
7178

72-
const requestMatchers = new Map<ApiRequestMatcher, Path>();
79+
const requestMatchers = new Map<OpenApiRequestMatcher, Path>();
7380
_.entries(spec.paths)
7481
// Sort path & pathspec pairs to ensure that more specific paths are
7582
// always listed first, so that later on we can always use the first match
@@ -115,7 +122,7 @@ export async function buildApiMetadata(
115122
requestMatchers.set({
116123
// Build a regex that matches this path on any of those base servers
117124
pathMatcher: new RegExp(
118-
serverMatcher.source + templateStringToRegexString(realPath) + '/?$',
125+
serverMatcher.source + openApiTemplateStringToRegexString(realPath) + '/?$',
119126
'i'
120127
),
121128
// Some specs (AWS) also match requests by specific params
@@ -132,4 +139,38 @@ export async function buildApiMetadata(
132139
serverMatcher,
133140
requestMatchers
134141
};
142+
}
143+
144+
// Approximately transform a JSON template string into a regex string that will match
145+
// values, using wildcards for each template. This is based on this draft RFC:
146+
// https://datatracker.ietf.org/doc/draft-jonas-json-template-language/
147+
// Only seems relevant to OpenRPC, doesn't seem widely used elsewhere.
148+
function jsonTemplateStringToRegexString(template: string): string {
149+
return template
150+
// Escape all regex chars *except* $, {, }
151+
.replace(/[\^\\.*+?()[\]|]/g, '\\$&')
152+
// Replace templates with wildcards
153+
.replace(/\$\{([^/}]+)}/g, '([^\/]*)')
154+
// Drop trailing slashes
155+
.replace(/\/$/, '');
156+
}
157+
158+
export function buildOpenRpcMetadata(spec: OpenRpcDocument, baseUrlOverrides?: string[]): OpenRpcMetadata {
159+
spec = dereference(spec);
160+
161+
const serverUrlRegexSources = baseUrlOverrides && baseUrlOverrides.length
162+
// Look for one of the given base URLs, ignoring trailing slashes
163+
? baseUrlOverrides.map(url => _.escapeRegExp(url).replace(/\/$/, ''))
164+
// Look for any URL of the base servers in the spec
165+
: spec.servers!.map(s => jsonTemplateStringToRegexString(s.url));
166+
167+
// Build a regex that matches any of these at the start of a URL
168+
const serverMatcher = new RegExp(`^(${serverUrlRegexSources.join('|')})`, 'i');
169+
170+
return {
171+
type: 'openrpc',
172+
spec,
173+
serverMatcher,
174+
requestMatchers: _.keyBy(spec.methods, 'name') as _.Dictionary<MethodObject> // Dereferenced
175+
};
135176
}

src/model/api/openapi.ts

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,43 +29,17 @@ import {
2929
ApiOperation,
3030
ApiRequest,
3131
ApiResponse,
32-
ApiParameter
32+
ApiParameter,
33+
ApiMetadata
3334
} from './api-interfaces';
34-
import { OpenApiMetadata } from './build-openapi';
35+
import { OpenApiMetadata } from './build-api-metadata';
3536
import { fromMarkdown } from '../markdown';
3637

3738
const paramValidator = new Ajv({
3839
coerceTypes: 'array',
3940
unknownFormats: 'ignore' // OpenAPI uses some non-standard formats
4041
});
4142

42-
// If we match multiple APIs, build all of them, try to match each one
43-
// against the request, and return only the matching one. This happens if
44-
// multiple services have the exact same base request URL (rds.amazonaws.com)
45-
export function findBestMatchingApi(
46-
apis: OpenApiMetadata[],
47-
request: HtkRequest
48-
): OpenApiMetadata | undefined {
49-
const matchingApis = apis.filter((api) => matchOperation(api, request).matched);
50-
51-
// If we've successfully found one matching API, return it
52-
if (matchingApis.length === 1) return matchingApis[0];
53-
54-
// If this is a non-matching request to one of these APIs, but we're not
55-
// sure which, return the one with the most paths (as a best guess metric of
56-
// which is the most popular service)
57-
if (matchingApis.length === 0) return _.maxBy(apis, a => a.spec.paths.length)!;
58-
59-
// Otherwise we've matched multiple APIs which all define operations that
60-
// could be this one request. Does exist right now (e.g. AWS RDS vs DocumentDB)
61-
62-
// Report this so we can try to improve & avoid in future.
63-
reportError('Overlapping APIs', matchingApis);
64-
65-
// Return our guess of the most popular service, from the matching services only
66-
return _.maxBy(matchingApis, a => a.spec.paths.length)!;
67-
}
68-
6943
function getPath(api: OpenApiMetadata, request: HtkRequest): {
7044
pathSpec: PathObject,
7145
path: string
@@ -308,7 +282,7 @@ export class OpenApiExchange implements ApiExchange {
308282
this.service = new OpenApiService(api.spec);
309283

310284
this._spec = api.spec;
311-
this._opSpec = matchOperation(api, request);
285+
this._opSpec = matchOpenApiOperation(api, request);
312286

313287
this.operation = new OpenApiOperation(this._opSpec);
314288
this.request = new OpenApiRequest(api.spec, this._opSpec, request);
@@ -342,7 +316,7 @@ class OpenApiService implements ApiService {
342316
const { info: service } = spec;
343317

344318
this.name = service.title;
345-
this.logoUrl = service['x-logo']?.url
319+
this.logoUrl = service['x-logo']?.url;
346320
this.description = fromMarkdown(service.description);
347321
this.docsUrl = spec?.externalDocs?.url;
348322
}
@@ -353,7 +327,7 @@ class OpenApiService implements ApiService {
353327
public readonly docsUrl?: string;
354328
}
355329

356-
function matchOperation(api: OpenApiMetadata, request: HtkRequest) {
330+
export function matchOpenApiOperation(api: OpenApiMetadata, request: HtkRequest) {
357331
const matchingPath = getPath(api, request);
358332

359333
const { pathSpec, path } = matchingPath || {
@@ -376,7 +350,7 @@ function matchOperation(api: OpenApiMetadata, request: HtkRequest) {
376350
};
377351
}
378352

379-
type MatchedOperation = ReturnType<typeof matchOperation>;
353+
type MatchedOperation = ReturnType<typeof matchOpenApiOperation>;
380354

381355
class OpenApiOperation implements ApiOperation {
382356
constructor(

src/model/http/exchange.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
asHeaderArray,
2323
lastHeader,
2424
} from '../../util';
25+
import { UnreachableCheck } from '../../util/error';
2526
import { lazyObservablePromise, ObservablePromise, observablePromise } from "../../util/observable";
2627

2728
import { reportError } from '../../errors';
@@ -33,6 +34,7 @@ import { HTKEventBase } from '../events/event-base';
3334
import { ApiStore } from '../api/api-store';
3435
import { ApiExchange } from '../api/api-interfaces';
3536
import { OpenApiExchange } from '../api/openapi';
37+
import { parseRpcApiExchange } from '../api/jsonrpc';
3638
import { ApiMetadata } from '../api/api-interfaces';
3739
import { decodeBody } from '../../services/ui-worker-api';
3840
import {
@@ -326,7 +328,13 @@ export class HttpExchange extends HTKEventBase {
326328

327329
if (apiMetadata) {
328330
try {
329-
return new OpenApiExchange(apiMetadata, this);
331+
if (apiMetadata.type === 'openapi') {
332+
return new OpenApiExchange(apiMetadata, this);
333+
} else if (apiMetadata.type === 'openrpc') {
334+
return await parseRpcApiExchange(apiMetadata, this);
335+
} else {
336+
throw new UnreachableCheck(apiMetadata, m => m.type);
337+
}
330338
} catch (e) {
331339
reportError(e);
332340
throw e;

0 commit comments

Comments
 (0)