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

[do not merge] fix: support different query parameter encoding #1654

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 11 additions & 1 deletion fern/apis/fdr/definition/api/latest/endpoint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ types:
defaultEnvironment: optional<rootCommons.EnvironmentId>
environments: optional<list<commons.Environment>>
pathParameters: optional<list<type.ObjectProperty>>
queryParameters: optional<list<type.ObjectProperty>>
queryParameters: optional<list<QueryParameter>>
requestHeaders: optional<list<type.ObjectProperty>>
responseHeaders: optional<list<type.ObjectProperty>> # this is not being used currently
request: optional<HttpRequest>
Expand All @@ -37,6 +37,16 @@ types:
examples: optional<list<ExampleEndpointCall>>
snippetTemplates: optional<EndpointSnippetTemplates>

QueryParameter:
extends: type.ObjectProperty
properties:
arrayEncoding: optional<QueryParameterArrayEncoding>

QueryParameterArrayEncoding:
enum:
- comma
- exploded

EndpointSnippetTemplates:
properties:
typescript: optional<snippetTemplate.VersionedSnippetTemplate>
Expand Down
6 changes: 6 additions & 0 deletions fern/apis/fdr/definition/api/v1/read/endpoint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ types:
properties:
key: string
type: type.TypeReference
arrayEncoding: optional<QueryParameterArrayEncoding>
Copy link
Contributor

@abvthecity abvthecity Oct 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mark this as default: comma? or do we treat everything as basic for now?


QueryParameterArrayEncoding:
enum:
- comma
- exploded

Header:
extends:
Expand Down
6 changes: 6 additions & 0 deletions fern/apis/fdr/definition/api/v1/register/endpoint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ types:
properties:
key: string
type: type.TypeReference
arrayEncoding: optional<QueryParameterArrayEncoding>

QueryParameterArrayEncoding:
enum:
- comma
- exploded

Header:
extends:
Expand Down
20 changes: 12 additions & 8 deletions packages/fdr-sdk/src/__test__/fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,27 @@ import { readFixture } from "./readFixtures";

const fixturesDir = path.join(__dirname, "fixtures");

function testNavigationConfigConverter(fixtureName: string): void {
async function testNavigationConfigConverter(fixtureName: string): Promise<void> {
const fixture = readFixture(fixtureName);
const v1 = FernNavigation.V1.toRootNode(fixture);
const latest = FernNavigationV1ToLatest.create().root(v1);

const v2Apis = Object.values(fixture.definition.apis).map((api) =>
ApiDefinitionV1ToLatest.from(api, {
useJavaScriptAsTypeScript: false,
alwaysEnableJavaScriptFetch: false,
usesApplicationJsonInFormDataValue: false,
}).migrate(),
const v2Apis = await Promise.all(
Object.values(fixture.definition.apis).map(
async (api) =>
await ApiDefinitionV1ToLatest.from(api, {
useJavaScriptAsTypeScript: false,
alwaysEnableJavaScriptFetch: false,
usesApplicationJsonInFormDataValue: false,
}).migrate(),
),
);

// eslint-disable-next-line vitest/valid-title
describe(fixtureName, () => {
const collector = new NodeCollector(latest);

it("gets all urls from docs config", () => {
it("gets all urls from docs config", async () => {
expect(JSON.stringify(sortObject(latest), undefined, 2)).toMatchFileSnapshot(
`output/${fixtureName}/node.json`,
);
Expand Down Expand Up @@ -98,6 +101,7 @@ function testNavigationConfigConverter(fixtureName: string): void {
const node = collector.slugMap.get(slug)!;
expect(node).toBeDefined();
if (!FernNavigation.isPage(node)) {
// eslint-disable-next-line no-console
console.log(node);
}
expect(FernNavigation.isPage(node), `${slug} is a page`).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const endpoint1: Latest.EndpointDefinition = {
default: undefined,
},
},
arrayEncoding: "comma",
description: undefined,
availability: undefined,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const endpoint1: Latest.EndpointDefinition = {
queryParameters: [
{
key: Latest.PropertyKey("query"),
arrayEncoding: "exploded",
valueShape: {
type: "alias",
value: {
Expand Down
196 changes: 109 additions & 87 deletions packages/fdr-sdk/src/api-definition/migrators/v1ToV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class ApiDefinitionV1ToLatest {
private webhooks: Record<V2.WebhookId, V2.WebhookDefinition> = {};
private subpackages: Record<V2.SubpackageId, V2.SubpackageMetadata> = {};
private types: Record<string, V2.TypeDefinition> = {};
public migrate = (): V2.ApiDefinition => {
public migrate = async (): Promise<V2.ApiDefinition> => {
Object.entries(this.v1.types).forEach(([id, type]) => {
this.types[V2.TypeId(id)] = {
name: type.name,
Expand All @@ -84,21 +84,25 @@ export class ApiDefinitionV1ToLatest {
};
});

[this.v1.rootPackage, ...Object.values(this.v1.subpackages)].forEach((pkg) => {
const [subpackageId, namespace] = this.collectNamespace(pkg, this.v1.subpackages);
pkg.endpoints.forEach((endpoint) => {
const id = ApiDefinitionV1ToLatest.createEndpointId(endpoint, subpackageId);
this.endpoints[id] = this.migrateEndpoint(id, endpoint, namespace);
});
pkg.websockets.forEach((webSocket) => {
const id = ApiDefinitionV1ToLatest.createWebSocketId(webSocket, subpackageId);
this.websockets[id] = this.migrateWebSocket(id, webSocket, namespace);
});
pkg.webhooks.forEach((webhook) => {
const id = ApiDefinitionV1ToLatest.createWebhookId(webhook, subpackageId);
this.webhooks[id] = this.migrateWebhook(id, webhook, namespace);
});
});
await Promise.all(
[this.v1.rootPackage, ...Object.values(this.v1.subpackages)].map(async (pkg) => {
const [subpackageId, namespace] = this.collectNamespace(pkg, this.v1.subpackages);
await Promise.all(
pkg.endpoints.map(async (endpoint) => {
const id = ApiDefinitionV1ToLatest.createEndpointId(endpoint, subpackageId);
this.endpoints[id] = await this.migrateEndpoint(id, endpoint, namespace);
}),
);
pkg.websockets.forEach((webSocket) => {
const id = ApiDefinitionV1ToLatest.createWebSocketId(webSocket, subpackageId);
this.websockets[id] = this.migrateWebSocket(id, webSocket, namespace);
});
pkg.webhooks.forEach((webhook) => {
const id = ApiDefinitionV1ToLatest.createWebhookId(webhook, subpackageId);
this.webhooks[id] = this.migrateWebhook(id, webhook, namespace);
});
}),
);

Object.values(this.v1.subpackages).forEach((subpackage) => {
this.subpackages[subpackage.subpackageId] = this.migrateSubpackage(subpackage);
Expand Down Expand Up @@ -144,11 +148,11 @@ export class ApiDefinitionV1ToLatest {
return [pkg.subpackageId, namespace];
};

migrateEndpoint = (
migrateEndpoint = async (
id: V2.EndpointId,
v1: APIV1Read.EndpointDefinition,
namespace: V2.SubpackageId[],
): V2.EndpointDefinition => {
): Promise<V2.EndpointDefinition> => {
const toRet: V2.EndpointDefinition = {
id,
namespace,
Expand All @@ -160,7 +164,7 @@ export class ApiDefinitionV1ToLatest {
defaultEnvironment: v1.defaultEnvironment,
environments: v1.environments,
pathParameters: this.migrateParameters(v1.path.pathParameters),
queryParameters: this.migrateParameters(v1.queryParameters),
queryParameters: this.migrateQueryParameters(v1.queryParameters),
requestHeaders: this.migrateParameters(v1.headers),
responseHeaders: undefined,
request: this.migrateHttpRequest(v1.request),
Expand All @@ -170,7 +174,7 @@ export class ApiDefinitionV1ToLatest {
snippetTemplates: v1.snippetTemplates,
};

toRet.examples = this.migrateHttpExamples(v1.examples, toRet);
toRet.examples = await this.migrateHttpExamples(v1.examples, toRet);

return toRet;
};
Expand Down Expand Up @@ -229,7 +233,7 @@ export class ApiDefinitionV1ToLatest {
};

migrateParameters = (
v1: APIV1Read.PathParameter[] | APIV1Read.QueryParameter[] | APIV1Read.Header[] | undefined,
v1: APIV1Read.PathParameter[] | APIV1Read.Header[] | undefined,
): V2.ObjectProperty[] | undefined => {
if (v1 == null || v1.length === 0) {
return undefined;
Expand All @@ -245,6 +249,22 @@ export class ApiDefinitionV1ToLatest {
}));
};

migrateQueryParameters = (v1: APIV1Read.QueryParameter[] | undefined): V2.QueryParameter[] | undefined => {
if (v1 == null || v1.length === 0) {
return undefined;
}
return v1.map((parameter) => ({
key: V2.PropertyKey(parameter.key),
valueShape: {
type: "alias",
value: this.migrateTypeReference(parameter.type),
},
arrayEncoding: parameter.arrayEncoding,
description: parameter.description,
availability: parameter.availability,
}));
};

migrateTypeReference = (typeRef: APIV1Read.TypeReference): V2.TypeReference => {
return visitDiscriminatedUnion(typeRef)._visit<V2.TypeReference>({
map: (value) => ({
Expand Down Expand Up @@ -399,77 +419,79 @@ export class ApiDefinitionV1ToLatest {
}));
};

migrateHttpExamples = (
migrateHttpExamples = async (
examples: APIV1Read.ExampleEndpointCall[],
endpoint: V2.EndpointDefinition,
): V2.ExampleEndpointCall[] | undefined => {
): Promise<Promise<V2.ExampleEndpointCall[]> | undefined> => {
if (examples.length === 0) {
return undefined;
}
return examples.map((example): V2.ExampleEndpointCall => {
const toRet: V2.ExampleEndpointCall = {
path: example.path,
responseStatusCode: example.responseStatusCode,
name: example.name,
description: example.description,
pathParameters: example.pathParameters,
queryParameters: example.queryParameters,
headers: example.headers,
requestBody: example.requestBodyV3,
responseBody: example.responseBodyV3,
snippets: undefined,
};

if (example.requestBodyV3) {
toRet.requestBody = visitDiscriminatedUnion(
example.requestBodyV3,
)._visit<APIV1Read.ExampleEndpointRequest>({
bytes: (value) => value,
json: (value) => ({
type: "json",
value: sortKeysByShape(value.value, endpoint.request?.body, this.types),
}),
form: (value) => ({
type: "form",
value: mapValues(value.value, (formValue, key): APIV1Read.FormValue => {
if (formValue.type === "json") {
const shape =
endpoint.request?.body.type === "formData"
? endpoint.request.body.fields.find(
(field): field is V2.FormDataField.Property =>
field.key === key && field.type === "property",
)?.valueShape
: undefined;
return {
type: "json",
value: sortKeysByShape(formValue.value, shape, this.types),
};
} else {
return formValue;
}
return await Promise.all(
examples.map(async (example): Promise<V2.ExampleEndpointCall> => {
const toRet: V2.ExampleEndpointCall = {
path: example.path,
responseStatusCode: example.responseStatusCode,
name: example.name,
description: example.description,
pathParameters: example.pathParameters,
queryParameters: example.queryParameters,
headers: example.headers,
requestBody: example.requestBodyV3,
responseBody: example.responseBodyV3,
snippets: undefined,
};

if (example.requestBodyV3) {
toRet.requestBody = visitDiscriminatedUnion(
example.requestBodyV3,
)._visit<APIV1Read.ExampleEndpointRequest>({
bytes: (value) => value,
json: (value) => ({
type: "json",
value: sortKeysByShape(value.value, endpoint.request?.body, this.types),
}),
}),
});
}

if (toRet.responseBody) {
toRet.responseBody.value = sortKeysByShape(
toRet.responseBody.value,
endpoint.response?.body,
this.types,
form: (value) => ({
type: "form",
value: mapValues(value.value, (formValue, key): APIV1Read.FormValue => {
if (formValue.type === "json") {
const shape =
endpoint.request?.body.type === "formData"
? endpoint.request.body.fields.find(
(field): field is V2.FormDataField.Property =>
field.key === key && field.type === "property",
)?.valueShape
: undefined;
return {
type: "json",
value: sortKeysByShape(formValue.value, shape, this.types),
};
} else {
return formValue;
}
}),
}),
});
}

if (toRet.responseBody) {
toRet.responseBody.value = sortKeysByShape(
toRet.responseBody.value,
endpoint.response?.body,
this.types,
);
}

toRet.snippets = await this.migrateEndpointSnippets(
endpoint,
toRet,
example.codeSamples,
example.codeExamples,
this.flags,
);
}

toRet.snippets = this.migrateEndpointSnippets(
endpoint,
toRet,
example.codeSamples,
example.codeExamples,
this.flags,
);

return toRet;
});
return toRet;
}),
);
};

migrateHttpErrors = (errors: APIV1Read.ErrorDeclarationV2[] | undefined): V2.ErrorResponse[] | undefined => {
Expand Down Expand Up @@ -613,13 +635,13 @@ export class ApiDefinitionV1ToLatest {
);
};

migrateEndpointSnippets(
async migrateEndpointSnippets(
endpoint: V2.EndpointDefinition,
example: V2.ExampleEndpointCall,
codeSamples: APIV1Read.CustomCodeSample[],
codeExamples: APIV1Read.CodeExamples,
flags: Flags,
): Record<string, V2.CodeSnippet[]> {
): Promise<Record<string, V2.CodeSnippet[]>> {
const toRet: Record<string, V2.CodeSnippet[]> = {};
function push(language: string, snippet: V2.CodeSnippet) {
(toRet[language] ??= []).push(snippet);
Expand All @@ -643,7 +665,7 @@ export class ApiDefinitionV1ToLatest {
});

if (!userProvidedLanguages.has(SupportedLanguage.Curl)) {
const code = convertToCurl(toSnippetHttpRequest(endpoint, example, this.auth), flags);
const code = await convertToCurl(toSnippetHttpRequest(endpoint, example, this.auth), flags);
push(SupportedLanguage.Curl, {
language: SupportedLanguage.Curl,
code,
Expand Down
Loading
Loading