Skip to content

Commit aa0c73f

Browse files
chore(): merge conflicts
2 parents 874344c + 6ec6f35 commit aa0c73f

8 files changed

Lines changed: 1942 additions & 8 deletions

File tree

integration/versioning/e2e/custom-versioning-fastify.spec.ts

Lines changed: 954 additions & 0 deletions
Large diffs are not rendered by default.

integration/versioning/e2e/custom-versioning.spec.ts

Lines changed: 711 additions & 0 deletions
Large diffs are not rendered by default.

packages/common/enums/version-type.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export enum VersioningType {
55
URI,
66
HEADER,
77
MEDIA_TYPE,
8+
CUSTOM,
89
}

packages/common/interfaces/version-options.interface.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ export interface MediaTypeVersioningOptions {
5858
key: string;
5959
}
6060

61+
export interface CustomVersioningOptions {
62+
type: VersioningType.CUSTOM;
63+
64+
/**
65+
* A function that accepts a request object (specific to the underlying platform, ie Express or Fastify)
66+
* and returns a single version value or an ordered array of versions, in order from HIGHEST to LOWEST.
67+
*
68+
* Ex. Returned version array = ['3.1', '3.0', '2.5', '2', '1.9']
69+
*
70+
* Use type assertion or narrowing to identify the specific request type.
71+
*/
72+
extractor: (request: unknown) => string | string[];
73+
}
74+
6175
interface VersioningCommonOptions {
6276
/**
6377
* The default version to be used as a fallback when you did not provide some
@@ -70,4 +84,9 @@ interface VersioningCommonOptions {
7084
* @publicApi
7185
*/
7286
export type VersioningOptions = VersioningCommonOptions &
73-
(HeaderVersioningOptions | UriVersioningOptions | MediaTypeVersioningOptions);
87+
(
88+
| HeaderVersioningOptions
89+
| UriVersioningOptions
90+
| MediaTypeVersioningOptions
91+
| CustomVersioningOptions
92+
);

packages/core/router/router-explorer.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,38 @@ export class RouterExplorer {
353353
if (versioningOptions.type === VersioningType.URI) {
354354
return handler(req, res, next);
355355
}
356+
357+
// Custom Extractor Versioning Handler
358+
if (versioningOptions.type === VersioningType.CUSTOM) {
359+
const extractedVersion = versioningOptions.extractor(req);
360+
361+
if (Array.isArray(version)) {
362+
if (
363+
Array.isArray(extractedVersion) &&
364+
version.filter(extractedVersion.includes).length
365+
) {
366+
return handler(req, res, next);
367+
} else if (
368+
isString(extractedVersion) &&
369+
version.includes(extractedVersion)
370+
) {
371+
return handler(req, res, next);
372+
}
373+
} else {
374+
if (
375+
Array.isArray(extractedVersion) &&
376+
extractedVersion.includes(version)
377+
) {
378+
return handler(req, res, next);
379+
} else if (
380+
isString(extractedVersion) &&
381+
version === extractedVersion
382+
) {
383+
return handler(req, res, next);
384+
}
385+
}
386+
}
387+
356388
// Media Type (Accept Header) Versioning Handler
357389
if (versioningOptions.type === VersioningType.MEDIA_TYPE) {
358390
const MEDIA_TYPE_HEADER = 'Accept';

packages/core/test/router/router-explorer.spec.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,182 @@ describe('RouterExplorer', () => {
588588
});
589589
});
590590

591+
describe('when the versioning type is CUSTOM', () => {
592+
const extractor = (request: { headers: { accept?: string } }) => {
593+
const match = request.headers.accept?.match(/v(\d+\.?\d*)\+json$/);
594+
if (match) {
595+
return match[1];
596+
}
597+
return null;
598+
};
599+
600+
it('should return next if there is no pertinent request object', () => {
601+
const version = '1';
602+
const versioningOptions: VersioningOptions = {
603+
type: VersioningType.CUSTOM,
604+
extractor,
605+
};
606+
const handler = sinon.stub();
607+
608+
const routePathMetadata: RoutePathMetadata = {
609+
methodVersion: version,
610+
versioningOptions,
611+
};
612+
const versionFilter = (routerBuilder as any).applyVersionFilter(
613+
null,
614+
routePathMetadata,
615+
handler,
616+
);
617+
618+
const req = { headers: {} };
619+
const res = {};
620+
const next = sinon.stub();
621+
622+
versionFilter(req, res, next);
623+
624+
expect(next.called).to.be.true;
625+
});
626+
627+
it('should return next if there is no version in the request object value', () => {
628+
const version = '1';
629+
const versioningOptions: VersioningOptions = {
630+
type: VersioningType.CUSTOM,
631+
extractor,
632+
};
633+
const handler = sinon.stub();
634+
635+
const routePathMetadata: RoutePathMetadata = {
636+
methodVersion: version,
637+
versioningOptions,
638+
};
639+
const versionFilter = (routerBuilder as any).applyVersionFilter(
640+
null,
641+
routePathMetadata,
642+
handler,
643+
);
644+
645+
const req = { headers: { accept: 'application/json;' } };
646+
const res = {};
647+
const next = sinon.stub();
648+
649+
versionFilter(req, res, next);
650+
651+
expect(next.called).to.be.true;
652+
});
653+
654+
describe('when the handler version is an array', () => {
655+
it('should return next if the version in the request object value does not match the handler version', () => {
656+
const version = ['1', '2'];
657+
const versioningOptions: VersioningOptions = {
658+
type: VersioningType.CUSTOM,
659+
extractor,
660+
};
661+
const handler = sinon.stub();
662+
663+
const routePathMetadata: RoutePathMetadata = {
664+
methodVersion: version,
665+
versioningOptions,
666+
};
667+
const versionFilter = (routerBuilder as any).applyVersionFilter(
668+
null,
669+
routePathMetadata,
670+
handler,
671+
);
672+
673+
const req = { headers: { accept: 'application/foo.v3+json' } };
674+
const res = {};
675+
const next = sinon.stub();
676+
677+
versionFilter(req, res, next);
678+
679+
expect(next.called).to.be.true;
680+
});
681+
682+
it('should return the handler if the version in the request object value matches the handler version', () => {
683+
const version = ['1', '2'];
684+
const versioningOptions: VersioningOptions = {
685+
type: VersioningType.CUSTOM,
686+
extractor,
687+
};
688+
const handler = sinon.stub();
689+
690+
const routePathMetadata: RoutePathMetadata = {
691+
methodVersion: version,
692+
versioningOptions,
693+
};
694+
const versionFilter = (routerBuilder as any).applyVersionFilter(
695+
null,
696+
routePathMetadata,
697+
handler,
698+
);
699+
700+
const req = { headers: { accept: 'application/foo.v2+json' } };
701+
const res = {};
702+
const next = sinon.stub();
703+
704+
versionFilter(req, res, next);
705+
706+
expect(handler.calledWith(req, res, next)).to.be.true;
707+
});
708+
});
709+
710+
describe('when the handler version is a string', () => {
711+
it('should return next if the version in the request object value does not match the handler version', () => {
712+
const version = '1';
713+
const versioningOptions: VersioningOptions = {
714+
type: VersioningType.CUSTOM,
715+
extractor,
716+
};
717+
const handler = sinon.stub();
718+
719+
const routePathMetadata: RoutePathMetadata = {
720+
methodVersion: version,
721+
versioningOptions,
722+
};
723+
const versionFilter = (routerBuilder as any).applyVersionFilter(
724+
null,
725+
routePathMetadata,
726+
handler,
727+
);
728+
729+
const req = { headers: { accept: 'application/foo.v2+json' } };
730+
const res = {};
731+
const next = sinon.stub();
732+
733+
versionFilter(req, res, next);
734+
735+
expect(next.called).to.be.true;
736+
});
737+
738+
it('should return the handler if the version in the request object value matches the handler version', () => {
739+
const version = '1';
740+
const versioningOptions: VersioningOptions = {
741+
type: VersioningType.CUSTOM,
742+
extractor,
743+
};
744+
const handler = sinon.stub();
745+
746+
const routePathMetadata: RoutePathMetadata = {
747+
methodVersion: version,
748+
versioningOptions,
749+
};
750+
const versionFilter = (routerBuilder as any).applyVersionFilter(
751+
null,
752+
routePathMetadata,
753+
handler,
754+
);
755+
756+
const req = { headers: { accept: 'application/foo.v1+json' } };
757+
const res = {};
758+
const next = sinon.stub();
759+
760+
versionFilter(req, res, next);
761+
762+
expect(handler.calledWith(req, res, next)).to.be.true;
763+
});
764+
});
765+
});
766+
591767
describe('when the versioning type is HEADER', () => {
592768
it('should return next if there is no Custom Header', () => {
593769
const version = '1';

packages/platform-express/adapters/express-adapter.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,42 @@ export class ExpressAdapter extends AbstractHttpAdapter {
211211
if (versioningOptions.type === VersioningType.URI) {
212212
return handler(req, res, next);
213213
}
214+
215+
// Custom Extractor Versioning Handler
216+
if (versioningOptions.type === VersioningType.CUSTOM) {
217+
const extractedVersion = versioningOptions.extractor(req);
218+
219+
if (Array.isArray(version)) {
220+
if (
221+
Array.isArray(extractedVersion) &&
222+
version.filter(v => extractedVersion.includes(v)).length
223+
) {
224+
return handler(req, res, next);
225+
} else if (
226+
isString(extractedVersion) &&
227+
version.includes(extractedVersion)
228+
) {
229+
return handler(req, res, next);
230+
}
231+
} else if (isString(version)) {
232+
//Known bug here - if there are multiple versions supported across separate
233+
//handlers/controllers, we can't select the highest matching handler.
234+
//Since this code is evaluated per-handler, then we can't see if the highest
235+
//specified version exists in a different handler.
236+
if (
237+
Array.isArray(extractedVersion) &&
238+
extractedVersion.includes(version)
239+
) {
240+
return handler(req, res, next);
241+
} else if (
242+
isString(extractedVersion) &&
243+
version === extractedVersion
244+
) {
245+
return handler(req, res, next);
246+
}
247+
}
248+
}
249+
214250
// Media Type (Accept Header) Versioning Handler
215251
if (versioningOptions.type === VersioningType.MEDIA_TYPE) {
216252
const MEDIA_TYPE_HEADER = 'Accept';

packages/platform-fastify/adapters/fastify-adapter.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,23 +115,24 @@ export class FastifyAdapter<
115115
}
116116
},
117117
storage() {
118-
const versions = new Map();
118+
const versions = new Map<string, unknown>();
119119
return {
120120
get(version: string | Array<string>) {
121+
if (Array.isArray(version)) {
122+
return versions.get(version.find(v => versions.has(v))) || null;
123+
}
121124
return versions.get(version) || null;
122125
},
123-
set(
124-
versionOrVersions: string | Array<string>,
125-
store: Map<string, any>,
126-
) {
127-
const storeVersionConstraint = version =>
126+
set(versionOrVersions: string | Array<string>, store: unknown) {
127+
const storeVersionConstraint = (version: string) =>
128128
versions.set(version, store);
129129
if (Array.isArray(versionOrVersions))
130130
versionOrVersions.forEach(storeVersionConstraint);
131131
else storeVersionConstraint(versionOrVersions);
132132
},
133133
del(version: string | Array<string>) {
134-
versions.delete(version);
134+
if (Array.isArray(version)) version.forEach(v => versions.delete(v));
135+
else versions.delete(version);
135136
},
136137
empty() {
137138
versions.clear();
@@ -167,6 +168,10 @@ export class FastifyAdapter<
167168
return customHeaderVersionParameter;
168169
}
169170
}
171+
// Custom Versioning Handler
172+
else if (this.versioningOptions.type === VersioningType.CUSTOM) {
173+
return this.versioningOptions.extractor(req);
174+
}
170175
return undefined;
171176
},
172177
mustMatchWhenDerived: false,

0 commit comments

Comments
 (0)