diff --git a/packages/core/package.json b/packages/core/package.json index 9d043d1a0..103a4e704 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/core", - "version": "2.2.0-dev.20231008", + "version": "2.2.0-dev.20231010", "description": "Super-fast validation decorators of NestJS", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -34,20 +34,20 @@ }, "homepage": "https://nestia.io", "dependencies": { - "@nestia/fetcher": "^2.2.0-dev.20231008", - "@nestjs/common": ">= 7.0.1", - "@nestjs/core": ">= 7.0.1", - "@nestjs/platform-express": ">= 7.0.1", - "@nestjs/platform-fastify": ">= 7.0.1", + "@nestia/fetcher": "^2.2.0-dev.20231010", + "@nestjs/common": ">=7.0.1", + "@nestjs/core": ">=7.0.1", + "@nestjs/platform-express": ">=7.0.1", + "@nestjs/platform-fastify": ">=7.0.1", "detect-ts-node": "^1.0.5", "glob": "^7.2.0", - "raw-body": ">= 2.0.0", - "reflect-metadata": ">= 0.1.12", - "rxjs": ">= 6.0.0", - "typia": "^5.2.0" + "raw-body": ">=2.0.0", + "reflect-metadata": ">=0.1.12", + "rxjs": ">=6.0.0", + "typia": ">=5.2.0 <6.0.0" }, "peerDependencies": { - "@nestia/fetcher": ">=2.2.0-dev.20231008", + "@nestia/fetcher": ">=2.2.0-dev.20231010", "@nestjs/common": ">=7.0.1", "@nestjs/core": ">=7.0.1", "@nestjs/platform-express": ">=7.0.1", diff --git a/packages/core/src/decorators/TypedQuery.ts b/packages/core/src/decorators/TypedQuery.ts index 7254cb7f9..e47d5669b 100644 --- a/packages/core/src/decorators/TypedQuery.ts +++ b/packages/core/src/decorators/TypedQuery.ts @@ -1,14 +1,28 @@ import { BadRequestException, + CallHandler, + Delete, ExecutionContext, + Get, + NestInterceptor, + Patch, + Post, + Put, + UseInterceptors, + applyDecorators, createParamDecorator, } from "@nestjs/common"; +import { HttpArgumentsHost } from "@nestjs/common/interfaces"; import type express from "express"; import type { FastifyRequest } from "fastify"; +import { catchError, map } from "rxjs"; import typia from "typia"; import { IRequestQueryValidator } from "../options/IRequestQueryValidator"; +import { IResponseBodyQuerifier } from "../options/IResponseBodyQuerifier"; +import { get_path_and_querify } from "./internal/get_path_and_querify"; +import { route_error } from "./internal/route_error"; import { validate_request_query } from "./internal/validate_request_query"; /** @@ -77,6 +91,81 @@ export namespace TypedQuery { Object.assign(Body, typia.http.assertQuery); Object.assign(Body, typia.http.isQuery); Object.assign(Body, typia.http.validateQuery); + + /** + * Router decorator function for the GET method. + * + * @param path Path of the HTTP request + * @returns Method decorator + */ + export const Get = Generator("Get"); + + /** + * Router decorator function for the POST method. + * + * @param path Path of the HTTP request + * @returns Method decorator + */ + export const Post = Generator("Post"); + + /** + * Router decorator function for the PATH method. + * + * @param path Path of the HTTP request + * @returns Method decorator + */ + export const Patch = Generator("Patch"); + + /** + * Router decorator function for the PUT method. + * + * @param path Path of the HTTP request + * @returns Method decorator + */ + export const Put = Generator("Put"); + + /** + * Router decorator function for the DELETE method. + * + * @param path Path of the HTTP request + * @returns Method decorator + */ + export const Delete = Generator("Delete"); + + /** + * @internal + */ + function Generator(method: "Get" | "Post" | "Put" | "Patch" | "Delete") { + function route(path?: string | string[]): MethodDecorator; + function route( + stringify?: IResponseBodyQuerifier, + ): MethodDecorator; + function route( + path: string | string[], + stringify?: IResponseBodyQuerifier, + ): MethodDecorator; + + function route(...args: any[]): MethodDecorator { + const [path, stringify] = get_path_and_querify( + `TypedQuery.${method}`, + )(...args); + return applyDecorators( + ROUTERS[method](path), + UseInterceptors(new TypedQueryRouteInterceptor(stringify)), + ); + } + return route; + } + for (const method of [typia.assert, typia.is, typia.validate]) + for (const [key, value] of Object.entries(method)) + for (const deco of [ + TypedQuery.Get, + TypedQuery.Delete, + TypedQuery.Post, + TypedQuery.Put, + TypedQuery.Patch, + ]) + (deco as any)[key] = value; } Object.assign(TypedQuery, typia.http.assertQuery); Object.assign(TypedQuery, typia.http.isQuery); @@ -90,6 +179,9 @@ function tail(url: string): string { return index === -1 ? "" : url.substring(index + 1); } +/** + * @internal + */ function isApplicationQuery(text?: string): boolean { return ( text !== undefined && @@ -100,6 +192,9 @@ function isApplicationQuery(text?: string): boolean { ); } +/** + * @internal + */ class FakeURLSearchParams { public constructor(private readonly target: Record) {} @@ -125,3 +220,34 @@ class FakeURLSearchParams { : [value]; } } + +/** + * @internal + */ +class TypedQueryRouteInterceptor implements NestInterceptor { + public constructor( + private readonly toSearchParams: (input: any) => URLSearchParams, + ) {} + + public intercept(context: ExecutionContext, next: CallHandler) { + const http: HttpArgumentsHost = context.switchToHttp(); + const response: express.Response = http.getResponse(); + response.header("Content-Type", "application/x-www-form-urlencoded"); + + return next.handle().pipe( + map((value) => this.toSearchParams(value).toString()), + catchError((err) => route_error(http.getRequest(), err)), + ); + } +} + +/** + * @internal + */ +const ROUTERS = { + Get, + Post, + Patch, + Put, + Delete, +}; diff --git a/packages/core/src/decorators/internal/get_path_and_querify.ts b/packages/core/src/decorators/internal/get_path_and_querify.ts new file mode 100644 index 000000000..9f856b387 --- /dev/null +++ b/packages/core/src/decorators/internal/get_path_and_querify.ts @@ -0,0 +1,104 @@ +import { InternalServerErrorException } from "@nestjs/common"; + +import typia, { IValidation, TypeGuardError } from "typia"; + +import { IResponseBodyQuerifier } from "../../options/IResponseBodyQuerifier"; +import { NoTransformConfigureError } from "./NoTransformConfigureError"; + +/** + * @internal + */ +export const get_path_and_querify = + (method: string) => + ( + ...args: any[] + ): [string | string[] | undefined, (input: any) => URLSearchParams] => { + const path: string | string[] | null | undefined = + args[0] === undefined || + typeof args[0] === "string" || + Array.isArray(args[0]) + ? args[0] + : null; + const functor: IResponseBodyQuerifier | undefined = + path === null ? args[0] : args[1]; + return [path ?? undefined, take(method)(functor)]; + }; + +/** + * @internal + */ +const take = + (method: string) => + (functor?: IResponseBodyQuerifier | null) => { + if (functor === undefined) throw NoTransformConfigureError(method); + else if (functor === null) return querify; + else if (functor.type === "stringify") return functor.stringify; + else if (functor.type === "assert") return assert(functor.assert); + else if (functor.type === "is") return is(functor.is); + else if (functor.type === "validate") return validate(functor.validate); + throw new Error( + `Error on nestia.core.${method}(): invalid typed stringify function.`, + ); + }; + +const querify = (input: Record): URLSearchParams => { + const output: URLSearchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(input)) + if (key === undefined) continue; + else if (Array.isArray(value)) + for (const elem of value) output.append(key, String(elem)); + else output.append(key, String(value)); + return output; +}; + +/** + * @internal + */ +const assert = + (closure: (data: T) => URLSearchParams) => + (data: T) => { + try { + return closure(data); + } catch (exp) { + if (typia.is(exp)) + throw new InternalServerErrorException({ + path: exp.path, + reason: exp.message, + expected: exp.expected, + value: exp.value, + message: MESSAGE, + }); + throw exp; + } + }; + +/** + * @internal + */ +const is = + (closure: (data: T) => URLSearchParams | null) => + (data: T) => { + const result: URLSearchParams | null = closure(data); + if (result === null) throw new InternalServerErrorException(MESSAGE); + return result; + }; + +/** + * @internal + */ +const validate = + (closure: (data: T) => IValidation) => + (data: T) => { + const result: IValidation = closure(data); + if (result.success === false) + throw new InternalServerErrorException({ + errors: result.errors, + message: MESSAGE, + }); + return result.data; + }; + +/** + * @internal + */ +const MESSAGE = "Response body data is not following the promised type."; diff --git a/packages/core/src/options/IResponseBodyQuerifier.ts b/packages/core/src/options/IResponseBodyQuerifier.ts new file mode 100644 index 000000000..9bd1df5d9 --- /dev/null +++ b/packages/core/src/options/IResponseBodyQuerifier.ts @@ -0,0 +1,25 @@ +import { IValidation } from "typia"; + +export type IResponseBodyQuerifier = + | IResponseBodyquerifier.IStringify + | IResponseBodyquerifier.IIs + | IResponseBodyquerifier.IAssert + | IResponseBodyquerifier.IValidate; +export namespace IResponseBodyquerifier { + export interface IStringify { + type: "stringify"; + stringify: (input: T) => URLSearchParams; + } + export interface IIs { + type: "is"; + is: (input: T) => URLSearchParams | null; + } + export interface IAssert { + type: "assert"; + assert: (input: T) => URLSearchParams; + } + export interface IValidate { + type: "validate"; + validate: (input: T) => IValidation; + } +} diff --git a/packages/core/src/programmers/TypedQueryRouteProgrammer.ts b/packages/core/src/programmers/TypedQueryRouteProgrammer.ts new file mode 100644 index 000000000..36f542459 --- /dev/null +++ b/packages/core/src/programmers/TypedQueryRouteProgrammer.ts @@ -0,0 +1,55 @@ +import ts from "typescript"; + +import { IProject } from "typia/lib/transformers/IProject"; + +import { INestiaTransformProject } from "../options/INestiaTransformProject"; +import { HttpAssertQuerifyProgrammer } from "./http/HttpAssertQuerifyProgrammer"; +import { HttpIsQuerifyProgrammer } from "./http/HttpIsQuerifyProgrammer"; +import { HttpQuerifyProgrammer } from "./http/HttpQuerifyProgrammer"; +import { HttpValidateQuerifyProgrammer } from "./http/HttpValidateQuerifyProgrammer"; + +export namespace TypedQueryRouteProgrammer { + export const generate = + (project: INestiaTransformProject) => + (modulo: ts.LeftHandSideExpression) => + (type: ts.Type): ts.Expression => { + // GENERATE STRINGIFY PLAN + const parameter = ( + key: string, + programmer: ( + project: IProject, + ) => ( + modulo: ts.LeftHandSideExpression, + ) => (type: ts.Type) => ts.ArrowFunction, + ) => + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier("type"), + ts.factory.createStringLiteral(key), + ), + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier(key), + programmer({ + ...project, + options: {}, // use default option + })(modulo)(type), + ), + ]); + + // RETURNS + if (project.options.stringify === "is") + return parameter("is", HttpIsQuerifyProgrammer.write); + else if (project.options.stringify === "validate") + return parameter( + "validate", + HttpValidateQuerifyProgrammer.write, + ); + else if (project.options.stringify === "stringify") + return parameter("stringify", HttpQuerifyProgrammer.write); + else if (project.options.stringify === null) + return ts.factory.createNull(); + + // ASSERT IS DEFAULT + return parameter("assert", HttpAssertQuerifyProgrammer.write); + }; +} diff --git a/packages/core/src/programmers/http/HttpAssertQuerifyProgrammer.ts b/packages/core/src/programmers/http/HttpAssertQuerifyProgrammer.ts new file mode 100644 index 000000000..e0cc0e311 --- /dev/null +++ b/packages/core/src/programmers/http/HttpAssertQuerifyProgrammer.ts @@ -0,0 +1,59 @@ +import ts from "typescript"; + +import { IdentifierFactory } from "typia/lib/factories/IdentifierFactory"; +import { StatementFactory } from "typia/lib/factories/StatementFactory"; +import { AssertProgrammer } from "typia/lib/programmers/AssertProgrammer"; +import { IProject } from "typia/lib/transformers/IProject"; + +import { HttpQuerifyProgrammer } from "./HttpQuerifyProgrammer"; + +export namespace HttpAssertQuerifyProgrammer { + export const write = + (project: IProject) => + (modulo: ts.LeftHandSideExpression) => + (type: ts.Type, name?: string): ts.ArrowFunction => + ts.factory.createArrowFunction( + undefined, + undefined, + [IdentifierFactory.parameter("input")], + undefined, + undefined, + ts.factory.createBlock([ + StatementFactory.constant( + "assert", + AssertProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: false, + }, + })(modulo)(false)(type, name), + ), + StatementFactory.constant( + "stringify", + HttpQuerifyProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: false, + }, + })(modulo)(type), + ), + ts.factory.createReturnStatement( + ts.factory.createCallExpression( + ts.factory.createIdentifier("stringify"), + undefined, + [ + ts.factory.createCallExpression( + ts.factory.createIdentifier("assert"), + undefined, + [ts.factory.createIdentifier("input")], + ), + ], + ), + ), + ]), + ); +} diff --git a/packages/core/src/programmers/http/HttpIsQuerifyProgrammer.ts b/packages/core/src/programmers/http/HttpIsQuerifyProgrammer.ts new file mode 100644 index 000000000..99a5e0771 --- /dev/null +++ b/packages/core/src/programmers/http/HttpIsQuerifyProgrammer.ts @@ -0,0 +1,63 @@ +import ts from "typescript"; + +import { IdentifierFactory } from "typia/lib/factories/IdentifierFactory"; +import { StatementFactory } from "typia/lib/factories/StatementFactory"; +import { IsProgrammer } from "typia/lib/programmers/IsProgrammer"; +import { IProject } from "typia/lib/transformers/IProject"; + +import { HttpQuerifyProgrammer } from "./HttpQuerifyProgrammer"; + +export namespace HttpIsQuerifyProgrammer { + export const write = + (project: IProject) => + (modulo: ts.LeftHandSideExpression) => + (type: ts.Type): ts.ArrowFunction => + ts.factory.createArrowFunction( + undefined, + undefined, + [IdentifierFactory.parameter("input")], + undefined, + undefined, + ts.factory.createBlock([ + StatementFactory.constant( + "is", + IsProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: false, + }, + })(modulo)(false)(type), + ), + StatementFactory.constant( + "stringify", + HttpQuerifyProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: false, + }, + })(modulo)(type), + ), + ts.factory.createReturnStatement( + ts.factory.createConditionalExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier("is"), + undefined, + [ts.factory.createIdentifier("input")], + ), + undefined, + ts.factory.createCallExpression( + ts.factory.createIdentifier("stringify"), + undefined, + [ts.factory.createIdentifier("input")], + ), + undefined, + ts.factory.createNull(), + ), + ), + ]), + ); +} diff --git a/packages/core/src/programmers/http/HttpQuerifyProgrammer.ts b/packages/core/src/programmers/http/HttpQuerifyProgrammer.ts new file mode 100644 index 000000000..439d39990 --- /dev/null +++ b/packages/core/src/programmers/http/HttpQuerifyProgrammer.ts @@ -0,0 +1,105 @@ +import ts from "typescript"; + +import { IdentifierFactory } from "typia/lib/factories/IdentifierFactory"; +import { MetadataCollection } from "typia/lib/factories/MetadataCollection"; +import { MetadataFactory } from "typia/lib/factories/MetadataFactory"; +import { StatementFactory } from "typia/lib/factories/StatementFactory"; +import { FunctionImporter } from "typia/lib/programmers/helpers/FunctionImporeter"; +import { HttpQueryProgrammer } from "typia/lib/programmers/http/HttpQueryProgrammer"; +import { Metadata } from "typia/lib/schemas/metadata/Metadata"; +import { MetadataObject } from "typia/lib/schemas/metadata/MetadataObject"; +import { IProject } from "typia/lib/transformers/IProject"; +import { TransformerError } from "typia/lib/transformers/TransformerError"; + +export namespace HttpQuerifyProgrammer { + export const write = + (project: IProject) => + (modulo: ts.LeftHandSideExpression) => + (type: ts.Type): ts.ArrowFunction => { + // GET OBJECT TYPE + const importer: FunctionImporter = new FunctionImporter( + modulo.getText(), + ); + const collection: MetadataCollection = new MetadataCollection(); + const result = MetadataFactory.analyze(project.checker)({ + escape: false, + constant: true, + absorb: true, + validate: HttpQueryProgrammer.validate, + })(collection)(type); + if (result.success === false) + throw TransformerError.from( + `@nestia.core.TypedQuery.${importer.method}`, + )(result.errors); + + const object: MetadataObject = result.data.objects[0]!; + return ts.factory.createArrowFunction( + undefined, + undefined, + [IdentifierFactory.parameter("input")], + undefined, + undefined, + ts.factory.createBlock( + [ + ...importer.declare(modulo), + StatementFactory.constant( + "output", + ts.factory.createNewExpression( + ts.factory.createIdentifier("URLSearchParams"), + undefined, + [], + ), + ), + ...object.properties.map((p) => + ts.factory.createExpressionStatement( + decode(p.key.constants[0]!.values[0] as string)( + p.value, + ), + ), + ), + ts.factory.createReturnStatement( + ts.factory.createIdentifier("output"), + ), + ], + true, + ), + ); + }; + + const decode = + (key: string) => + (value: Metadata): ts.CallExpression => + !!value.arrays.length + ? ts.factory.createCallExpression( + IdentifierFactory.access( + IdentifierFactory.access( + ts.factory.createIdentifier("input"), + )(key), + )("forEach"), + undefined, + [ + ts.factory.createArrowFunction( + undefined, + undefined, + [IdentifierFactory.parameter("elem")], + undefined, + undefined, + append(key)(ts.factory.createIdentifier("elem")), + ), + ], + ) + : append(key)( + IdentifierFactory.access( + ts.factory.createIdentifier("input"), + )(key), + ); + + const append = (key: string) => (elem: ts.Expression) => + ts.factory.createCallExpression( + IdentifierFactory.access(ts.factory.createIdentifier("output"))( + "append", + ), + undefined, + [ts.factory.createStringLiteral(key), elem], + ); +} diff --git a/packages/core/src/programmers/http/HttpValidateQuerifyProgrammer.ts b/packages/core/src/programmers/http/HttpValidateQuerifyProgrammer.ts new file mode 100644 index 000000000..1591232c6 --- /dev/null +++ b/packages/core/src/programmers/http/HttpValidateQuerifyProgrammer.ts @@ -0,0 +1,64 @@ +import ts from "typescript"; + +import { IdentifierFactory } from "typia/lib/factories/IdentifierFactory"; +import { StatementFactory } from "typia/lib/factories/StatementFactory"; +import { ValidateProgrammer } from "typia/lib/programmers/ValidateProgrammer"; +import { IProject } from "typia/lib/transformers/IProject"; + +import { HttpQuerifyProgrammer } from "./HttpQuerifyProgrammer"; + +export namespace HttpValidateQuerifyProgrammer { + export const write = + (project: IProject) => + (modulo: ts.LeftHandSideExpression) => + (type: ts.Type, name?: string): ts.ArrowFunction => + ts.factory.createArrowFunction( + undefined, + undefined, + [IdentifierFactory.parameter("input")], + undefined, + undefined, + ts.factory.createBlock([ + StatementFactory.constant( + "validate", + ValidateProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: true, + }, + })(modulo)(false)(type, name), + ), + StatementFactory.constant( + "query", + HttpQuerifyProgrammer.write({ + ...project, + options: { + ...project.options, + functional: false, + numeric: false, + }, + })(modulo)(type), + ), + StatementFactory.constant( + "output", + ts.factory.createCallExpression( + ts.factory.createIdentifier("query"), + undefined, + [ts.factory.createIdentifier("input")], + ), + ), + ts.factory.createReturnStatement( + ts.factory.createAsExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier("validate"), + undefined, + [ts.factory.createIdentifier("output")], + ), + ts.factory.createTypeReferenceNode("any"), + ), + ), + ]), + ); +} diff --git a/packages/core/src/transformers/TypedRouteTransformer.ts b/packages/core/src/transformers/TypedRouteTransformer.ts index 34534e780..69e9f0b92 100644 --- a/packages/core/src/transformers/TypedRouteTransformer.ts +++ b/packages/core/src/transformers/TypedRouteTransformer.ts @@ -2,6 +2,7 @@ import path from "path"; import ts from "typescript"; import { INestiaTransformProject } from "../options/INestiaTransformProject"; +import { TypedQueryRouteProgrammer } from "../programmers/TypedQueryRouteProgrammer"; import { TypedRouteProgrammer } from "../programmers/TypedRouteProgrammer"; export namespace TypedRouteTransformer { @@ -17,7 +18,7 @@ export namespace TypedRouteTransformer { if (!signature || !signature.declaration) return decorator; // CHECK TO BE TRANSFORMED - const done: boolean = (() => { + const modulo = (() => { // CHECK FILENAME const location: string = path.resolve( signature.declaration.getSourceFile().fileName, @@ -26,7 +27,7 @@ export namespace TypedRouteTransformer { LIB_PATHS.every((str) => location.indexOf(str) === -1) && SRC_PATHS.every((str) => location !== str) ) - return false; + return null; // CHECK DUPLICATE BOOSTER if (decorator.expression.arguments.length >= 2) return false; @@ -39,9 +40,12 @@ export namespace TypedRouteTransformer { project.checker.getTypeAtLocation(last); if (isObject(project.checker)(type)) return false; } - return true; + return location.split(path.sep).at(-1)?.split(".")[0] === + "TypedQuery" + ? "TypedQuery" + : "TypedRoute"; })(); - if (done === false) return decorator; + if (modulo === null) return decorator; // CHECK TYPE NODE const typeNode: ts.TypeNode | undefined = @@ -56,9 +60,12 @@ export namespace TypedRouteTransformer { decorator.expression.typeArguments, [ ...decorator.expression.arguments, - TypedRouteProgrammer.generate(project)( - decorator.expression.expression, - )(type), + (modulo === "TypedQuery" + ? TypedQueryRouteProgrammer + : TypedRouteProgrammer + ).generate(project)(decorator.expression.expression)( + type, + ), ], ), ); @@ -72,7 +79,7 @@ export namespace TypedRouteTransformer { !(checker as any).isArrayType(type) && !(checker as any).isArrayLikeType(type); - const CLASSES = ["EncryptedRoute", "TypedRoute"]; + const CLASSES = ["EncryptedRoute", "TypedRoute", "TypedQuery"]; const LIB_PATHS = CLASSES.map((cla) => path.join( "node_modules", diff --git a/packages/fetcher/package.json b/packages/fetcher/package.json index e999e1193..18a04b4f0 100644 --- a/packages/fetcher/package.json +++ b/packages/fetcher/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/fetcher", - "version": "2.2.0-dev.20231008", + "version": "2.2.0-dev.20231010", "description": "Fetcher library of Nestia SDK", "main": "lib/index.js", "typings": "lib/index.d.ts", diff --git a/packages/fetcher/src/internal/FetcherBase.ts b/packages/fetcher/src/internal/FetcherBase.ts index 2947efa9d..1fdf94cf7 100644 --- a/packages/fetcher/src/internal/FetcherBase.ts +++ b/packages/fetcher/src/internal/FetcherBase.ts @@ -160,6 +160,15 @@ export namespace FetcherBase { else if (route.response?.type === "application/json") { const text: string = await response.text(); result.data = text.length ? JSON.parse(text) : undefined; + } else if ( + route.response?.type === "application/x-www-form-urlencoded" + ) { + const query: URLSearchParams = new URLSearchParams( + await response.text(), + ); + result.data = route.parseQuery + ? route.parseQuery(query) + : query; } else result.data = props.decode( await response.text(), diff --git a/packages/fetcher/src/internal/IFetchRoute.ts b/packages/fetcher/src/internal/IFetchRoute.ts index 2ec468108..7d8e29d27 100644 --- a/packages/fetcher/src/internal/IFetchRoute.ts +++ b/packages/fetcher/src/internal/IFetchRoute.ts @@ -27,6 +27,18 @@ export interface IFetchRoute< * When special status code being used. */ status: number | null; + + /** + * Parser of the query string. + * + * If content type of response body is `application/x-www-form-urlencoded`, + * then this `query` function would be called. + * + * If you've forgotten to configuring this property about the + * `application/x-www-form-urlencoded` typed response body data, + * then `URLSearchParams` instance would be returned instead. + */ + parseQuery?(input: URLSearchParams): any; } export namespace IRoute { export interface IBody { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6c053f76b..be86941c9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@nestia/sdk", - "version": "2.2.0-dev.20231008", + "version": "2.2.0-dev.20231010", "description": "Nestia SDK and Swagger generator", "main": "lib/index.js", "typings": "lib/index.d.ts", @@ -35,7 +35,7 @@ }, "homepage": "https://nestia.io", "dependencies": { - "@nestia/fetcher": "^2.2.0-dev.20231008", + "@nestia/fetcher": "^2.2.0-dev.20231010", "cli": "^1.0.1", "glob": "^7.2.0", "path-to-regexp": "^6.2.1", @@ -47,7 +47,7 @@ "typia": "^5.2.0" }, "peerDependencies": { - "@nestia/fetcher": ">=2.2.0-dev.20231008", + "@nestia/fetcher": ">=2.2.0-dev.20231010", "@nestjs/common": ">=7.0.1", "@nestjs/core": ">=7.0.1", "reflect-metadata": ">=0.1.12", diff --git a/packages/sdk/src/analyses/ReflectAnalyzer.ts b/packages/sdk/src/analyses/ReflectAnalyzer.ts index f028a0601..0d72f997a 100644 --- a/packages/sdk/src/analyses/ReflectAnalyzer.ts +++ b/packages/sdk/src/analyses/ReflectAnalyzer.ts @@ -179,6 +179,9 @@ export namespace ReflectAnalyzer { const encrypted: boolean = Reflect.getMetadata(Constants.INTERCEPTORS_METADATA, proto)?.[0] ?.constructor?.name === "EncryptedRouteInterceptor"; + const query: boolean = + Reflect.getMetadata(Constants.INTERCEPTORS_METADATA, proto)?.[0] + ?.constructor?.name === "TypedQueryRouteInterceptor"; const method: string = METHODS[Reflect.getMetadata(Constants.METHOD_METADATA, proto)]; if (method === undefined || method === "OPTIONS") return null; @@ -223,6 +226,8 @@ export namespace ReflectAnalyzer { encrypted, contentType: encrypted ? "text/plain" + : query + ? "application/x-www-form-urlencoded" : Reflect.getMetadata(Constants.HEADERS_METADATA, proto)?.find( (h: Record) => typeof h?.name === "string" && diff --git a/packages/sdk/src/generates/internal/SdkFunctionProgrammer.ts b/packages/sdk/src/generates/internal/SdkFunctionProgrammer.ts index cbab9f099..0032e0b06 100644 --- a/packages/sdk/src/generates/internal/SdkFunctionProgrammer.ts +++ b/packages/sdk/src/generates/internal/SdkFunctionProgrammer.ts @@ -412,6 +412,16 @@ export namespace SdkFunctionProgrammer { ...(route.status ? [` status: ${route.status},`] : [" status: null,"]), + ...(route.output.contentType === + "application/x-www-form-urlencoded" + ? [ + ` parseQuery: (input: URLSearchParams) => ${SdkImportWizard.typia( + importer, + )}.http.assertQuery<${ + route.output.typeName + }>(input),`, + ] + : []), "} as const;", ] .map((line) => ` ${line}`) diff --git a/packages/sdk/src/generates/internal/SdkTypeDefiner.ts b/packages/sdk/src/generates/internal/SdkTypeDefiner.ts index 476641014..6b663ef88 100644 --- a/packages/sdk/src/generates/internal/SdkTypeDefiner.ts +++ b/packages/sdk/src/generates/internal/SdkTypeDefiner.ts @@ -65,12 +65,14 @@ export namespace SdkTypeDefiner { const type: string = name(config)(importer)(route.output); if (type === "void" || config.primitive === false) return type; - const primitive: string = importer.external({ + const wrapper: string = importer.external({ type: true, library: "@nestia/fetcher", - instance: "Primitive", + instance: route.output.contentType === "application/x-www-form-urlencoded" + ? "Resolved" + : "Primitive", }); - return `${primitive}<${type}>`; + return `${wrapper}<${type}>`; } const propagation: string = importer.external({ diff --git a/test/features/query/src/api/functional/query/index.ts b/test/features/query/src/api/functional/query/index.ts index 95da96300..01c5cd401 100644 --- a/test/features/query/src/api/functional/query/index.ts +++ b/test/features/query/src/api/functional/query/index.ts @@ -1,11 +1,12 @@ /** * @packageDocumentation * @module api.functional.query - * @nestia Generated by Nestia - https://github.com/samchon/nestia + * @nestia Generated by Nestia - https://github.com/samchon/nestia */ //================================================================ import type { IConnection, Primitive, Resolved } from "@nestia/fetcher"; import { PlainFetcher } from "@nestia/fetcher/lib/PlainFetcher"; +import typia from "typia"; import type { INestQuery } from "../../structures/INestQuery"; import type { IQuery } from "../../structures/IQuery"; @@ -19,13 +20,10 @@ export async function typed( connection: IConnection, query: typed.Query, ): Promise { - return PlainFetcher.fetch( - connection, - { - ...typed.METADATA, - path: typed.path(query), - } as const, - ); + return PlainFetcher.fetch(connection, { + ...typed.METADATA, + path: typed.path(query), + } as const); } export namespace typed { export type Query = Resolved; @@ -49,11 +47,10 @@ export namespace typed { if (value === undefined) continue; else if (Array.isArray(value)) value.forEach((elem) => search.append(key, String(elem))); - else - search.set(key, String(value)); + else search.set(key, String(value)); const encoded: string = search.toString(); - return `/query/typed${encoded.length ? `?${encoded}` : ""}`;; - } + return `/query/typed${encoded.length ? `?${encoded}` : ""}`; + }; } /** @@ -65,13 +62,10 @@ export async function nest( connection: IConnection, query: nest.Query, ): Promise { - return PlainFetcher.fetch( - connection, - { - ...nest.METADATA, - path: nest.path(query), - } as const, - ); + return PlainFetcher.fetch(connection, { + ...nest.METADATA, + path: nest.path(query), + } as const); } export namespace nest { export type Query = Resolved; @@ -95,11 +89,10 @@ export namespace nest { if (value === undefined) continue; else if (Array.isArray(value)) value.forEach((elem) => search.append(key, String(elem))); - else - search.set(key, String(value)); + else search.set(key, String(value)); const encoded: string = search.toString(); - return `/query/nest${encoded.length ? `?${encoded}` : ""}`;; - } + return `/query/nest${encoded.length ? `?${encoded}` : ""}`; + }; } /** @@ -111,13 +104,10 @@ export async function individual( connection: IConnection, id: string, ): Promise { - return PlainFetcher.fetch( - connection, - { - ...individual.METADATA, - path: individual.path(id), - } as const, - ); + return PlainFetcher.fetch(connection, { + ...individual.METADATA, + path: individual.path(id), + } as const); } export namespace individual { export type Output = Primitive; @@ -134,20 +124,18 @@ export namespace individual { } as const; export const path = (id: string): string => { - const variables: Record = - { - id + const variables: Record = { + id, } as any; const search: URLSearchParams = new URLSearchParams(); for (const [key, value] of Object.entries(variables)) if (value === undefined) continue; else if (Array.isArray(value)) value.forEach((elem) => search.append(key, String(elem))); - else - search.set(key, String(value)); + else search.set(key, String(value)); const encoded: string = search.toString(); - return `/query/individual${encoded.length ? `?${encoded}` : ""}`;; - } + return `/query/individual${encoded.length ? `?${encoded}` : ""}`; + }; } /** @@ -160,13 +148,10 @@ export async function composite( atomic: string, query: composite.Query, ): Promise { - return PlainFetcher.fetch( - connection, - { - ...composite.METADATA, - path: composite.path(atomic, query), - } as const, - ); + return PlainFetcher.fetch(connection, { + ...composite.METADATA, + path: composite.path(atomic, query), + } as const); } export namespace composite { export type Query = Resolved>; @@ -184,8 +169,7 @@ export namespace composite { } as const; export const path = (atomic: string, query: composite.Query): string => { - const variables: Record = - { + const variables: Record = { ...query, atomic, } as any; @@ -194,11 +178,10 @@ export namespace composite { if (value === undefined) continue; else if (Array.isArray(value)) value.forEach((elem) => search.append(key, String(elem))); - else - search.set(key, String(value)); + else search.set(key, String(value)); const encoded: string = search.toString(); - return `/query/composite${encoded.length ? `?${encoded}` : ""}`;; - } + return `/query/composite${encoded.length ? `?${encoded}` : ""}`; + }; } /** @@ -227,23 +210,25 @@ export async function body( } export namespace body { export type Input = Primitive; - export type Output = Primitive; + export type Output = Resolved; export const METADATA = { method: "POST", path: "/query/body", request: { type: "application/x-www-form-urlencoded", - encrypted: false + encrypted: false, }, response: { - type: "application/json", + type: "application/x-www-form-urlencoded", encrypted: false, }, status: null, + parseQuery: (input: URLSearchParams) => + typia.http.assertQuery(input), } as const; export const path = (): string => { return `/query/body`; - } -} \ No newline at end of file + }; +} diff --git a/test/features/query/src/controllers/QueryController.ts b/test/features/query/src/controllers/QueryController.ts index c26f47219..0f962e150 100644 --- a/test/features/query/src/controllers/QueryController.ts +++ b/test/features/query/src/controllers/QueryController.ts @@ -37,7 +37,7 @@ export class QueryController { }; } - @TypedRoute.Post("body") + @TypedQuery.Post("body") public async body(@TypedQuery.Body() query: IQuery): Promise { return query; } diff --git a/test/features/query/swagger.json b/test/features/query/swagger.json index d3f3b5528..ff5b96aa2 100644 --- a/test/features/query/swagger.json +++ b/test/features/query/swagger.json @@ -171,7 +171,7 @@ "201": { "description": "", "content": { - "application/json": { + "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/IQuery" } diff --git a/test/package.json b/test/package.json index ffb15a44d..05fc1c751 100644 --- a/test/package.json +++ b/test/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@nestia/test", - "version": "2.2.0-dev.20231008", + "version": "2.2.0-dev.20231010", "description": "Test program of Nestia", "main": "index.js", "scripts": { @@ -37,9 +37,9 @@ "typia": "^5.2.0", "uuid": "^9.0.0", "nestia": "^4.5.0", - "@nestia/core": "^2.2.0-dev.20231008", + "@nestia/core": "^2.2.0-dev.20231010", "@nestia/e2e": "^0.3.6", - "@nestia/fetcher": "^2.2.0-dev.20231008", - "@nestia/sdk": "^2.2.0-dev.20231008" + "@nestia/fetcher": "^2.2.0-dev.20231010", + "@nestia/sdk": "^2.2.0-dev.20231010" } } \ No newline at end of file