diff --git a/package.json b/package.json index 3fb23fa..a93db84 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@huolala-tech/custom-error": "^1.0.0", "@huolala-tech/hooks": "^1.0.0", "@huolala-tech/nad-builder": "^1.0.4", - "@huolala-tech/request": "^1.1.3", + "@huolala-tech/request": "^1.1.4", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", diff --git a/packages/builder/jest.config.js b/packages/builder/jest.config.js deleted file mode 100644 index 4367cd8..0000000 --- a/packages/builder/jest.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testRegex: '\\.test\\.ts$', -}; diff --git a/packages/builder/jest.config.mjs b/packages/builder/jest.config.mjs new file mode 100644 index 0000000..e923d73 --- /dev/null +++ b/packages/builder/jest.config.mjs @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testRegex: '\\.test\\.ts$', + setupFilesAfterEnv: ['./src/tests/jest.setup.ts'], +}; diff --git a/packages/builder/package.json b/packages/builder/package.json index 694129e..22585ac 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@huolala-tech/nad-builder", - "version": "1.1.0", + "version": "1.1.1", "description": "Convert the Java AST to client-side code", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/packages/builder/src/codegen/CodeGenForTs.ts b/packages/builder/src/codegen/CodeGenForTs.ts index 5f4068c..1ac2481 100644 --- a/packages/builder/src/codegen/CodeGenForTs.ts +++ b/packages/builder/src/codegen/CodeGenForTs.ts @@ -83,7 +83,7 @@ export class CodeGenForTs extends CodeGen { .map((p) => { // If a parameter is optional and has any required parameters in the right, // then change its type to `T | null` and make it requreid. - if (hasRequired && p.required === '?') return `${p.name}: ${t2s(p.type)} | null`; + if (hasRequired && p.required === '?') return `${p.name}: ${t2s(p.type)} | undefined`; // If current parameter is required, update `hasRequired` flag to `true`. hasRequired = hasRequired || p.required === ''; // Otherwise, the current parameter is requreid, or there are no required parameters to its right, @@ -103,6 +103,7 @@ export class CodeGenForTs extends CodeGen { for (const p of a.parameters) { if (p.description) this.write(`@param ${p.name} ${p.description}`); } + if (a.deprecated) this.write('@deprecated'); }); this.write(`async ${a.uniqName}(${pars.join(', ')}) {`); this.writeBlock(() => { @@ -138,6 +139,7 @@ export class CodeGenForTs extends CodeGen { this.writeComment(() => { this.write(m.description || m.moduleName); this.write(`@iface ${m.name}`); + if (m.deprecated) this.write(`@deprecated`); }); this.write(`export const ${m.moduleName} = {`); this.writeBlock(() => { @@ -203,9 +205,10 @@ export class CodeGenForTs extends CodeGen { this.write(`export interface ${defStr} {`); this.writeBlock(() => { for (const m of c.members) { - if (m.description) { + if (m.description || m.deprecated) { this.writeComment(() => { this.write(m.description); + if (m.deprecated) this.write('@deprecated'); }); } this.write(`${m.name}${m.optional}: ${t2s(m.type)};`); diff --git a/packages/builder/src/helpers/javaHelper.ts b/packages/builder/src/helpers/javaHelper.ts index 97e4cd4..37d6adc 100644 --- a/packages/builder/src/helpers/javaHelper.ts +++ b/packages/builder/src/helpers/javaHelper.ts @@ -56,12 +56,14 @@ export const isJavaPrimitiveTypes = [ export const isJavaStringTypes = [ 'java.lang.String', 'java.lang.StringBuffer', + 'java.time.LocalDateTime', 'java.time.LocalDate', 'java.time.OffsetDateTime', 'java.time.OffsetTime', 'java.time.ZonedDateTime', 'java.time.LocalTime', + 'java.lang.Class', 'java.net.URL', 'java.net.URI', diff --git a/packages/builder/src/helpers/tsHelper.ts b/packages/builder/src/helpers/tsHelper.ts index 1abfa0b..3dad72f 100644 --- a/packages/builder/src/helpers/tsHelper.ts +++ b/packages/builder/src/helpers/tsHelper.ts @@ -41,10 +41,10 @@ export const t2s = (type: Type): string => { switch (name) { case 'java.math.BigDecimal': - builder.commonDefs.BigDecimal = 'string | number'; + builder.commonDefs.BigDecimal = '`${number}` | number'; return 'BigDecimal'; case 'java.math.BigInteger': - builder.commonDefs.BigInteger = 'string | number'; + builder.commonDefs.BigInteger = '`${number}` | number'; return 'BigInteger'; case 'org.springframework.web.multipart.MultipartFile': builder.commonDefs.MultipartFile = 'Blob | File | string'; @@ -52,7 +52,7 @@ export const t2s = (type: Type): string => { default: } if (isJavaLong(name)) { - builder.commonDefs.Long = 'string | number'; + builder.commonDefs.Long = '`${number}` | number'; return 'Long'; } if (isJavaNumber(name)) return 'number'; @@ -67,7 +67,7 @@ export const t2s = (type: Type): string => { } else { keyType = 'PropertyKey'; } - return `Record<${keyType}, ${t2s(second)}>`; + return `Record<${keyType}, ${t2s(second)} | undefined>`; } if (isJavaList(name)) { return `${t2s(parameters[0])}[]`; diff --git a/packages/builder/src/models/Member.ts b/packages/builder/src/models/Member.ts index 7133b3c..185c43e 100644 --- a/packages/builder/src/models/Member.ts +++ b/packages/builder/src/models/Member.ts @@ -3,7 +3,7 @@ import { u2a, u2o, u2s } from 'u2x'; import { Annotations } from './annotations'; import type { Class } from './Class'; import { Type } from './Type'; -import { Dubious, notEmpty, toLowerCamel } from '../utils'; +import { Dubious, notEmpty, toSnake } from '../utils'; import { NadMember } from '../types/nad'; export class Member { @@ -13,14 +13,26 @@ export class Member { public readonly description; public readonly visible: boolean; public readonly optional: '' | '?'; + public readonly deprecated; constructor( raw: Dubious, public readonly owner: Class, ) { - const { name, type, annotations } = raw; - this.annotations = Annotations.create(u2a(annotations).filter(notEmpty).map(u2o).flat()); - this.name = owner.options.fixPropertyName(u2s(this.annotations.json.alias || name) || ''); - this.type = Type.create(u2s(type), owner); + this.annotations = Annotations.create(u2a(raw.annotations).filter(notEmpty).map(u2o).flat()); + + const { fixPropertyName } = owner.options; + if (this.annotations.json.alias) { + this.name = fixPropertyName(this.annotations.json.alias); + } else { + const rawName = u2s(raw.name) ?? ''; + if (owner.annotations.json.needToSnake) { + this.name = fixPropertyName(toSnake(rawName)); + } else { + this.name = fixPropertyName(rawName); + } + } + + this.type = Type.create(u2s(raw.type), owner); const amp = this.annotations.swagger.getApiModelProperty(); this.description = amp?.value; @@ -42,5 +54,7 @@ export class Member { // It is optinoal by default unless set to @NotNull or @ApiModelProperty(required = true) or JavaPrimitive types. this.optional = amp?.required === true || this.annotations.hasNonNull() || isJavaPrimitive(this.type.name) ? '' : '?'; + + this.deprecated = this.annotations.hasDeprecated(); } } diff --git a/packages/builder/src/models/Module.ts b/packages/builder/src/models/Module.ts index e02bf3e..5d12723 100644 --- a/packages/builder/src/models/Module.ts +++ b/packages/builder/src/models/Module.ts @@ -13,6 +13,7 @@ export class Module extends Annotated { public readonly moduleName; public readonly routes; public readonly description; + public readonly deprecated; constructor(raw: ModuleRaw, builder: Root, list: RouteRaw[]) { super(raw); this.builder = builder; @@ -51,5 +52,6 @@ export class Module extends Annotated { this.routes = sList.map((o) => new Route(o, this)); this.description = this.annotations.swagger.getApi()?.value || ''; + this.deprecated = this.annotations.hasDeprecated(); } } diff --git a/packages/builder/src/models/Parameter.ts b/packages/builder/src/models/Parameter.ts index 32b8068..705ff57 100644 --- a/packages/builder/src/models/Parameter.ts +++ b/packages/builder/src/models/Parameter.ts @@ -45,13 +45,15 @@ export class Parameter extends Annotated> { // 2. This parameter has any "NotNull" annotations (contains @NotNull, @NonNull, and @Nonnull). // 3. The type of this parameter is a java primitive type. this.required = - ap?.required || - rp?.required || - pv?.required || - rb?.required || - rh?.required || - this.annotations.hasNonNull() || - isJavaPrimitive(this.type.name) + (ap?.required || + rp?.required || + pv?.required || + rb?.required || + rh?.required || + this.annotations.hasNonNull() || + isJavaPrimitive(this.type.name)) && + // In fact, a parameter can be optional if a default value is provided. + rp?.defaultValue === undefined ? ('' as const) : ('?' as const); diff --git a/packages/builder/src/models/Route.ts b/packages/builder/src/models/Route.ts index b0252a3..d7275b7 100644 --- a/packages/builder/src/models/Route.ts +++ b/packages/builder/src/models/Route.ts @@ -24,6 +24,7 @@ export class Route extends Annotated { public readonly customFlags; public readonly requiredHeaders: [string, string][]; public readonly requiredParams: [string, string][]; + public readonly deprecated; constructor(raw: RouteRaw | undefined, module: Module) { super(raw); @@ -55,6 +56,7 @@ export class Route extends Annotated { this.parameters = u2a(this.raw.parameters, (i) => Parameter.create(i, this)).filter(notEmpty); this.description = this.annotations.swagger.getApiOperation()?.description || ''; + this.deprecated = this.annotations.hasDeprecated(); this.requiredHeaders = []; this.requiredParams = []; diff --git a/packages/builder/src/models/annotations/JsonAnnotations.ts b/packages/builder/src/models/annotations/JsonAnnotations.ts index 5abb263..947c348 100644 --- a/packages/builder/src/models/annotations/JsonAnnotations.ts +++ b/packages/builder/src/models/annotations/JsonAnnotations.ts @@ -15,6 +15,10 @@ export interface JSONField { serialize: boolean; } +export interface JsonNaming { + value: string; +} + export class JsonAnnotations { private annotations; constructor(annotations: Annotations) { @@ -29,6 +33,10 @@ export class JsonAnnotations { return this.getJsonIgnore()?.value === true || this.getJSONField()?.serialize === false; } + public get needToSnake() { + return this.getJsonNaming()?.value === 'com.fasterxml.jackson.databind.PropertyNamingStrategy$SnakeCaseStrategy'; + } + private getJsonProperty() { return this.annotations.find('com.fasterxml.jackson.annotation.JsonProperty'); } @@ -40,4 +48,8 @@ export class JsonAnnotations { private getJSONField() { return this.annotations.find('com.alibaba.fastjson.annotation.JSONField'); } + + private getJsonNaming() { + return this.annotations.find('com.fasterxml.jackson.databind.annotation.JsonNaming'); + } } diff --git a/packages/builder/src/models/annotations/WebAnnocations.ts b/packages/builder/src/models/annotations/WebAnnocations.ts index 2aa683e..bf7c2b3 100644 --- a/packages/builder/src/models/annotations/WebAnnocations.ts +++ b/packages/builder/src/models/annotations/WebAnnocations.ts @@ -14,7 +14,13 @@ export abstract class ValueAliasName extends AnnotationBase { * @see https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestParam.html */ export class RequestParam extends ValueAliasName { - public static iface = 'org.springframework.web.bind.annotation.RequestParam'; + public static readonly iface = 'org.springframework.web.bind.annotation.RequestParam'; + public static readonly DEFAULT_VALUE = '\n\t\t\n\t\t\n\uE000\uE001\uE002\n\t\t\t\t\n'; + get defaultValue() { + const value = u2s(this.raw.defaultValue); + if (value === RequestParam.DEFAULT_VALUE) return undefined; + return value; + } get required() { return u2b(this.raw.required) ?? true; } diff --git a/packages/builder/src/models/annotations/index.ts b/packages/builder/src/models/annotations/index.ts index e55b300..04d661a 100644 --- a/packages/builder/src/models/annotations/index.ts +++ b/packages/builder/src/models/annotations/index.ts @@ -60,6 +60,11 @@ export class Annotations { return false; } + hasDeprecated() { + if (this.find('java.lang.Deprecated')) return true; + return false; + } + find>(name: string, endsWith = false): T | null { if (!endsWith) return (this.map.get(name) as T) || null; for (const [key, value] of this.map) { diff --git a/packages/builder/src/tests/codegen/all-types.test.ts b/packages/builder/src/tests/codegen/all-types.test.ts index eb3f632..1dec6c6 100644 --- a/packages/builder/src/tests/codegen/all-types.test.ts +++ b/packages/builder/src/tests/codegen/all-types.test.ts @@ -10,7 +10,7 @@ const typeVector = describe.each([ ['java.util.List', 'unknown[]', 'NSArray*'], ['java.util.List', 'Long[]', 'NSArray*'], ['java.util.List', 'void[]', 'NSArray*'], - ['java.util.Map', 'Record', 'NSDictionary*'], + ['java.util.Map', 'Record', 'NSDictionary*'], ['groovy.lang.Tuple2', '[ string, Long ]', 'NSArray*'], ['java.util.List>', 'Optional[]', 'NSArray*' ], ['java.util.List>', 'Optional[]', 'NSArray*' ], diff --git a/packages/builder/src/tests/codegen/oc-enum.test.ts b/packages/builder/src/tests/codegen/oc-enum.test.ts index 9983dfa..9e9ea19 100644 --- a/packages/builder/src/tests/codegen/oc-enum.test.ts +++ b/packages/builder/src/tests/codegen/oc-enum.test.ts @@ -1,6 +1,5 @@ import { NadEnum, NadEnumConstant, NadRoute } from '../../types/nad'; import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { DeepPartial } from '../../utils'; const config = { base: 'test', target: 'oc' } as const; @@ -21,12 +20,12 @@ const buildEnum = (...constants: Partial[]) => { return new Builder({ ...config, defs: { routes: [foo], enums: [MyType] }, - }).code.replace(/\s+/g, ' '); + }).code; }; test('number enum', () => { const code = buildEnum({ name: 'WATER', value: 1 }, { name: 'FIRE', value: 2 }); - expect(code).toContain(mg` + expect(code).toMatchCode(` typedef NSNumber MyType; const MyType *MyType_WATER = @1; const MyType *MyType_FIRE = @2; @@ -35,7 +34,7 @@ test('number enum', () => { test('string enum', () => { const code = buildEnum({ name: 'WATER', value: 'water' }, { name: 'FIRE', value: 'fire' }); - expect(code).toContain(mg` + expect(code).toMatchCode(` typedef NSString MyType; const MyType *MyType_WATER = @"water"; const MyType *MyType_FIRE = @"fire"; @@ -44,7 +43,7 @@ test('string enum', () => { test('mixed enum', () => { const code = buildEnum({ name: 'WATER', value: 'water' }, { name: 'FIRE', value: 1 }); - expect(code).toContain(mg` + expect(code).toMatchCode(` typedef NSObject MyType; const MyType *MyType_WATER = @"WATER"; const MyType *MyType_FIRE = @"FIRE"; @@ -53,7 +52,7 @@ test('mixed enum', () => { test('enum string includes spetial characters', () => { const code = buildEnum({ name: 'WATER', value: 'wa\nter' }, { name: 'FIRE', value: 'fi\\re' }); - expect(code).toContain(mg` + expect(code).toMatchCode(` typedef NSString MyType; const MyType *MyType_WATER = @"wa\\x0ater"; const MyType *MyType_FIRE = @"fi\\x5cre"; diff --git a/packages/builder/src/tests/codegen/oc-swagger.test.ts b/packages/builder/src/tests/codegen/oc-swagger.test.ts index 609ce7a..9fd6cc2 100644 --- a/packages/builder/src/tests/codegen/oc-swagger.test.ts +++ b/packages/builder/src/tests/codegen/oc-swagger.test.ts @@ -1,11 +1,10 @@ import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { swaggerTestDefs } from '../defs/swaggerTestDefs'; -const code = new Builder({ target: 'oc', base: '', defs: swaggerTestDefs }).code.replace(/\s+/g, ' '); +const code = new Builder({ target: 'oc', base: '', defs: swaggerTestDefs }).code; test('module', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Module * @JavaClass test.Demo @@ -15,7 +14,7 @@ test('module', () => { }); test('route', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Route * @param a My A @@ -27,20 +26,22 @@ test('route', () => { }); test('class', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Model * @JavaClass test.FooModel */ @interface FooModel : NSObject - /** * My Field */ + /** + * My Field + */ @property (nonatomic, assign) FooEnum *type; @end `); }); test('enum', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Enum * @JavaClass test.FooEnum diff --git a/packages/builder/src/tests/codegen/oc-tree.test.ts b/packages/builder/src/tests/codegen/oc-tree.test.ts index 1904fc8..972ad0d 100644 --- a/packages/builder/src/tests/codegen/oc-tree.test.ts +++ b/packages/builder/src/tests/codegen/oc-tree.test.ts @@ -1,6 +1,5 @@ import { NadClass, NadRoute } from '../../types/nad'; import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { DeepPartial } from '../../utils'; const config = { base: 'test', target: 'oc' } as const; @@ -23,9 +22,9 @@ test('Tree', () => { const code = new Builder({ ...config, defs: { routes: [getTree], classes: [Node] }, - }).code.replace(/\s+/g, ' '); + }).code; - expect(code).toContain(mg` + expect(code).toMatchCode(` @interface Node : NSObject @property (nonatomic, assign) Node *parent; @property (nonatomic, assign) NSArray *children; diff --git a/packages/builder/src/tests/codegen/oc-types.test.ts b/packages/builder/src/tests/codegen/oc-types.test.ts index 6c661e3..aa72879 100644 --- a/packages/builder/src/tests/codegen/oc-types.test.ts +++ b/packages/builder/src/tests/codegen/oc-types.test.ts @@ -1,20 +1,19 @@ import { DeepPartial } from '../../utils'; import { Builder } from '../../Builder'; import { NadClass, NadRoute } from '../../types/nad'; -import { mg } from '../test-tools/mg'; import { paginitionDefs } from '../defs/paginitionTestDefs'; test('Paginition', () => { - const code = new Builder({ target: 'oc', base: '', defs: paginitionDefs }).code.replace(/\s+/g, ' '); + const code = new Builder({ target: 'oc', base: '', defs: paginitionDefs }).code; expect(code).toContain(`- (MetaPaginition*>*)foo;`); - expect(code).toContain(mg` + expect(code).toMatchCode(` @interface Paginition : NSObject @property (nonatomic, assign) T data; @property (nonatomic, assign) NSNumber *limit; @property (nonatomic, assign) NSNumber *offset; @end `); - expect(code).toContain(mg` + expect(code).toMatchCode(` @interface MetaPaginition : Paginition @property (nonatomic, assign) NSObject *meta; @end @@ -23,7 +22,7 @@ test('Paginition', () => { test('return void', () => { const routes: Partial[] = [{ bean: 'test.FooModule', name: 'foo', returnType: 'void' }]; - const code = new Builder({ target: 'oc', base: '', defs: { routes } }).code.replace(/\s+/g, ' '); + const code = new Builder({ target: 'oc', base: '', defs: { routes } }).code; expect(code).toContain('- (void)foo;'); }); @@ -32,7 +31,7 @@ test('void as the generic parameter', () => { const classes: DeepPartial[] = [ { name: 'test.A', typeParameters: ['T'], members: [{ name: 'data', type: 'T' }] }, ]; - const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code.replace(/\s+/g, ' '); + const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code; expect(code).toContain('- (A*)foo;'); }); @@ -55,9 +54,9 @@ test('extending order', () => { { name: 'test.C' }, { name: 'test.D' }, ]; - const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code.replace(/\s+/g, ' '); + const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code; - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * D * @JavaClass test.D @@ -91,9 +90,9 @@ test('extending order', () => { test('preserved keyword property name', () => { const routes: DeepPartial[] = [{ bean: 'test.Foo', name: 'foo', returnType: 'test.A' }]; const classes: DeepPartial[] = [{ name: 'test.A', members: [{ name: 'default' }] }]; - const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code.replace(/\s+/g, ' '); + const code = new Builder({ target: 'oc', base: '', defs: { routes, classes } }).code; - expect(code).toContain(mg` + expect(code).toMatchCode(` @interface A : NSObject @property (nonatomic, assign) NSObject *_default; @end diff --git a/packages/builder/src/tests/codegen/ts-comments.test.ts b/packages/builder/src/tests/codegen/ts-comments.test.ts new file mode 100644 index 0000000..20537eb --- /dev/null +++ b/packages/builder/src/tests/codegen/ts-comments.test.ts @@ -0,0 +1,33 @@ +import { Builder } from '../../Builder'; + +test('@deprecated', () => { + const Deprecated = { type: 'java.lang.Deprecated' }; + const builder = new Builder({ + target: 'ts', + base: '', + defs: { + routes: [{ bean: 'test.FooController', name: 'getFoo', annotations: [Deprecated], returnType: 'test.Foo' }], + classes: [{ name: 'test.Foo', members: [{ name: 'a', type: 'java.lang.Long', annotations: [[Deprecated]] }] }], + modules: [{ name: 'test.FooController', annotations: [Deprecated] }], + }, + }); + + expect(builder.code).toMatchCode(` + /** + * fooController + * @iface test.FooController + * @deprecated + */ + export const fooController = { + /** + * getFoo + * @deprecated + */ + async getFoo(settings?: Partial) { + return new Runtime() + .open('POST', '', settings) + .execute(); + }, + }; + `); +}); diff --git a/packages/builder/src/tests/codegen/ts-custom-flags.test.ts b/packages/builder/src/tests/codegen/ts-custom-flags.test.ts index 62f1060..41eccd4 100644 --- a/packages/builder/src/tests/codegen/ts-custom-flags.test.ts +++ b/packages/builder/src/tests/codegen/ts-custom-flags.test.ts @@ -1,5 +1,4 @@ import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; const code = new Builder({ target: 'ts', @@ -23,12 +22,12 @@ const code = new Builder({ }, ], }, -}).code.replace(/\s+/g, ' '); +}).code; test('f1', () => { - expect(code).toContain(mg`.addCustomFlags('soa')`); + expect(code).toMatchCode(`.addCustomFlags('soa')`); }); test('f2', () => { - expect(code).toContain(mg`.addCustomFlags('soa', 'hehe')`); + expect(code).toMatchCode(`.addCustomFlags('soa', 'hehe')`); }); diff --git a/packages/builder/src/tests/codegen/ts-enum.test.ts b/packages/builder/src/tests/codegen/ts-enum.test.ts index 73cdec0..0b6318d 100644 --- a/packages/builder/src/tests/codegen/ts-enum.test.ts +++ b/packages/builder/src/tests/codegen/ts-enum.test.ts @@ -1,6 +1,5 @@ import { NadEnum, NadEnumConstant, NadRoute } from '../../types/nad'; import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { DeepPartial } from '../../utils'; const config = { base: 'test', target: 'ts' } as const; @@ -21,12 +20,12 @@ const buildEnum = (...constants: DeepPartial[]) => { return new Builder({ ...config, defs: { routes: [foo], enums: [MyType] }, - }).code.replace(/\s+/g, ' '); + }).code; }; test('iota enum from zero', () => { const code = buildEnum({ name: 'WATER', value: 0 }, { name: 'FIRE', value: 1 }); - expect(code).toContain(mg` + expect(code).toMatchCode(` export enum MyType { WATER, FIRE, @@ -36,7 +35,7 @@ test('iota enum from zero', () => { test('iota enum from 1', () => { const code = buildEnum({ name: 'WATER', value: 1 }, { name: 'FIRE', value: 2 }); - expect(code).toContain(mg` + expect(code).toMatchCode(` export enum MyType { WATER = 1, FIRE, @@ -46,7 +45,7 @@ test('iota enum from 1', () => { test('string enum', () => { const code = buildEnum({ name: 'WATER', value: 'water' }, { name: 'FIRE', value: 'fire' }); - expect(code).toContain(mg` + expect(code).toMatchCode(` export enum MyType { WATER = 'water', FIRE = 'fire', @@ -56,7 +55,7 @@ test('string enum', () => { test('mixed enum', () => { const code = buildEnum({ name: 'WATER', value: 'water' }, { name: 'FIRE', value: 1 }); - expect(code).toContain(mg` + expect(code).toMatchCode(` export enum MyType { WATER = 'WATER', FIRE = 'FIRE', @@ -66,7 +65,7 @@ test('mixed enum', () => { test('enum string includes spetial characters', () => { const code = buildEnum({ name: 'WATER', value: 'wa\nter' }, { name: 'FIRE', value: 'fi\\re' }); - expect(code).toContain(mg` + expect(code).toMatchCode(` export enum MyType { WATER = 'wa\\x0ater', FIRE = 'fi\\x5cre', diff --git a/packages/builder/src/tests/codegen/ts-heading.test.ts b/packages/builder/src/tests/codegen/ts-heading.test.ts index 9d67ad1..0eb56d0 100644 --- a/packages/builder/src/tests/codegen/ts-heading.test.ts +++ b/packages/builder/src/tests/codegen/ts-heading.test.ts @@ -1,40 +1,44 @@ import { CodeGenForTs } from '../../codegen'; import { Root } from '../../models'; -import { mg } from '../test-tools/mg'; const root = new Root({}); -const HD = mg` +const HD = ` /* 该文件由 Nad CLI 生成,请勿手改 */ /* This file is generated by Nad CLI, do not edit manually. */ /* eslint-disable */ `; test('basic', () => { - const code = new CodeGenForTs(root, { base: '' }).toString().replace(/\s+/g, ' '); - expect(code).toContain(HD); - expect(code).toContain(mg` + const code = new CodeGenForTs(root, { base: '' }).toString(); + expect(code).toMatchCode(HD); + expect(code).toMatchCode(` import { NadInvoker } from '@huolala-tech/nad-runtime'; import type { Settings } from '@huolala-tech/nad-runtime'; - export class Runtime extends NadInvoker { public static base = ''; } + + export class Runtime extends NadInvoker { + public static base = ''; + } `); }); test('noHead', () => { - const code = new CodeGenForTs(root, { base: '', noHead: true }).toString().replace(/\s+/g, ' '); - expect(code).not.toContain(HD); + const code = new CodeGenForTs(root, { base: '', noHead: true }).toString(); + expect(code).not.toMatchCode(HD); }); test('custom base', () => { - const code = new CodeGenForTs(root, { base: 'http//localhost' }).toString().replace(/\s+/g, ' '); - expect(code).toContain(mg` - export class Runtime extends NadInvoker { public static base = 'http//localhost'; } + const code = new CodeGenForTs(root, { base: 'http//localhost' }).toString(); + expect(code).toMatchCode(` + export class Runtime extends NadInvoker { + public static base = 'http//localhost'; + } `); }); test('custom runtimePkgName', () => { - const code = new CodeGenForTs(root, { base: '', runtimePkgName: '@xxx/nad-runtime' }).toString().replace(/\s+/g, ' '); - expect(code).toContain(mg` + const code = new CodeGenForTs(root, { base: '', runtimePkgName: '@xxx/nad-runtime' }).toString(); + expect(code).toMatchCode(` import { NadInvoker } from '@xxx/nad-runtime'; import type { Settings } from '@xxx/nad-runtime'; `); diff --git a/packages/builder/src/tests/codegen/ts-optional-parameters.test.ts b/packages/builder/src/tests/codegen/ts-optional-parameters.test.ts index debf679..2b669cb 100644 --- a/packages/builder/src/tests/codegen/ts-optional-parameters.test.ts +++ b/packages/builder/src/tests/codegen/ts-optional-parameters.test.ts @@ -15,5 +15,33 @@ test('required parameters at first', () => { test('required parameters at second', () => { const code = buildTsMethodWithParameters({ name: 'a1', type: 'java.lang.Long' }, { name: 'a2', type: 'long' }); - expect(code).toContain(`async foo(a1: Long | null, a2: Long, settings?: Partial)`); + expect(code).toContain(`async foo(a1: Long | undefined, a2: Long, settings?: Partial)`); +}); + +test('A primitive long', () => { + const code = buildTsMethodWithParameters({ name: 'a', type: 'long' }); + expect(code).toContain(`async foo(a: Long, settings?: Partial)`); +}); + +test('A primitive long but the default value is provided', () => { + const code = buildTsMethodWithParameters({ + name: 'a', + type: 'long', + annotations: [{ type: 'org.springframework.web.bind.annotation.RequestParam', attributes: { defaultValue: '0' } }], + }); + expect(code).toContain(`async foo(a?: Long, settings?: Partial)`); +}); + +test('A primitive long but the default value is provided but the value is default', () => { + const code = buildTsMethodWithParameters({ + name: 'a', + type: 'long', + annotations: [ + { + type: 'org.springframework.web.bind.annotation.RequestParam', + attributes: { defaultValue: '\n\t\t\n\t\t\n\uE000\uE001\uE002\n\t\t\t\t\n' }, + }, + ], + }); + expect(code).toContain(`async foo(a: Long, settings?: Partial)`); }); diff --git a/packages/builder/src/tests/codegen/ts-special-map-key.test.ts b/packages/builder/src/tests/codegen/ts-special-map-key.test.ts index 7032d61..59da010 100644 --- a/packages/builder/src/tests/codegen/ts-special-map-key.test.ts +++ b/packages/builder/src/tests/codegen/ts-special-map-key.test.ts @@ -1,49 +1,17 @@ import { buildTsMethodWithParameters } from '../test-tools/buildMethodWithParameters'; -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); -}); - -test('java.util.Map', () => { - const { currentTestName: type = '' } = expect.getState(); - const code = buildTsMethodWithParameters({ name: 'map', type }); - expect(code).toContain(`map?: Record`); +test.each([ + ['test.UserType', 'UserType'], + ['test.User', 'PropertyKey'], + ['java.lang.Long', 'Long'], + ['java.lang.Integer', 'number'], + ['java.lang.Double', 'number'], + ['java.lang.String', 'string'], + ['java.math.BigDecimal', 'BigDecimal'], + ['java.math.BigInteger', 'BigInteger'], +])('java.util.Map<%s, java.lang.Long>', (keyType, tsType) => { + const code = buildTsMethodWithParameters({ name: 'map', type: `java.util.Map<${keyType}, java.lang.Long>` }); + expect(code).toMatchCode( + `async foo(map?: Record<${tsType}, Long | undefined>, settings?: Partial) {`, + ); }); diff --git a/packages/builder/src/tests/codegen/ts-swagger.test.ts b/packages/builder/src/tests/codegen/ts-swagger.test.ts index 45f94dd..4ec9761 100644 --- a/packages/builder/src/tests/codegen/ts-swagger.test.ts +++ b/packages/builder/src/tests/codegen/ts-swagger.test.ts @@ -1,11 +1,10 @@ import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { swaggerTestDefs } from '../defs/swaggerTestDefs'; -const code = new Builder({ target: 'ts', base: '', defs: swaggerTestDefs }).code.replace(/\s+/g, ' '); +const code = new Builder({ target: 'ts', base: '', defs: swaggerTestDefs }).code; test('module', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Module * @iface test.Demo @@ -15,19 +14,19 @@ test('module', () => { }); test('route', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Route * @param a My A * @param b My B * @param c My C */ - async foo(a?: Long, b?: Long, c?: Long, settings?: Partial) + async foo(a?: Long, b?: Long, c?: Long, settings?: Partial) { `); }); test('class', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Model * @iface test.FooModel @@ -42,7 +41,7 @@ test('class', () => { }); test('enum', () => { - expect(code).toContain(mg` + expect(code).toMatchCode(` /** * My Enum */ diff --git a/packages/builder/src/tests/codegen/ts-tree.test.ts b/packages/builder/src/tests/codegen/ts-tree.test.ts index 102582b..f15cbfc 100644 --- a/packages/builder/src/tests/codegen/ts-tree.test.ts +++ b/packages/builder/src/tests/codegen/ts-tree.test.ts @@ -1,6 +1,5 @@ import { NadClass, NadRoute } from '../../types/nad'; import { Builder } from '../../Builder'; -import { mg } from '../test-tools/mg'; import { DeepPartial } from '../../utils'; const config = { base: 'test', target: 'ts' } as const; @@ -23,10 +22,10 @@ test('Tree', () => { const code = new Builder({ ...config, defs: { routes: [getTree], classes: [Node] }, - }).code.replace(/\s+/g, ' '); + }).code; - expect(code).toContain(`new Runtime`); - expect(code).toContain(mg` + expect(code).toMatchCode(`return new Runtime()`); + expect(code).toMatchCode(` export interface Node { parent?: Node; children?: Node[]; diff --git a/packages/builder/src/tests/codegen/ts-types.test.ts b/packages/builder/src/tests/codegen/ts-types.test.ts index 29f61f5..e1da0b4 100644 --- a/packages/builder/src/tests/codegen/ts-types.test.ts +++ b/packages/builder/src/tests/codegen/ts-types.test.ts @@ -1,20 +1,19 @@ import { Builder } from '../../Builder'; import { NadClass, NadRoute } from '../../types/nad'; -import { mg } from '../test-tools/mg'; import { paginitionDefs } from '../defs/paginitionTestDefs'; import { DeepPartial } from '../../utils'; test('Paginition', () => { - const code = new Builder({ target: 'ts', base: '', defs: paginitionDefs }).code.replace(/\s+/g, ' '); - expect(code).toContain('new Runtime>'); - expect(code).toContain(mg` + const code = new Builder({ target: 'ts', base: '', defs: paginitionDefs }).code; + expect(code).toMatchCode('return new Runtime>()'); + expect(code).toMatchCode(` export interface Paginition { data?: T; limit?: Long; offset?: Long; } `); - expect(code).toContain(mg` + expect(code).toMatchCode(` export interface MetaPaginition extends Paginition { meta?: unknown; } @@ -27,10 +26,10 @@ test('Type alias', () => { { name: 'test.A', members: [{ name: 'data', type: 'long' }] }, { name: 'test.B', superclass: 'test.A' }, ]; - const code = new Builder({ target: 'ts', base: '', defs: { routes, classes } }).code.replace(/\s+/g, ' '); - expect(code).toContain('new Runtime'); - expect(code).toContain(`export type B = A;`); - expect(code).toContain(mg` + const code = new Builder({ target: 'ts', base: '', defs: { routes, classes } }).code; + expect(code).toMatchCode('return new Runtime()'); + expect(code).toMatchCode(`export type B = A;`); + expect(code).toMatchCode(` export interface A { data: Long; } @@ -41,12 +40,12 @@ test('special controller name', () => { const routes: Partial[] = [ { bean: 'cn.xxx.xxx.People$$EnhancerByCGLIB$$wtf23333', name: 'foo', returnType: 'void' }, ]; - const code = new Builder({ target: 'ts', base: '', defs: { routes } }).code.replace(/\s+/g, ' '); - expect(code).toContain(`export const people = {`); + const code = new Builder({ target: 'ts', base: '', defs: { routes } }).code; + expect(code).toMatchCode(`export const people = {`); }); test('empty bean', () => { const routes: Partial[] = [{ bean: '', name: 'foo', returnType: 'void' }]; - const code = new Builder({ target: 'ts', base: '', defs: { routes } }).code.replace(/\s+/g, ' '); - expect(code).toContain(`export const unknownModule = {`); + const code = new Builder({ target: 'ts', base: '', defs: { routes } }).code; + expect(code).toMatchCode(`export const unknownModule = {`); }); diff --git a/packages/builder/src/tests/jest.d.ts b/packages/builder/src/tests/jest.d.ts new file mode 100644 index 0000000..ee51539 --- /dev/null +++ b/packages/builder/src/tests/jest.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace jest { + interface Matchers { + toMatchCode(answer: string): R; + } + } +} + +export {}; diff --git a/packages/builder/src/tests/jest.setup.ts b/packages/builder/src/tests/jest.setup.ts new file mode 100644 index 0000000..edb1aaf --- /dev/null +++ b/packages/builder/src/tests/jest.setup.ts @@ -0,0 +1,42 @@ +const buildArray = (code: string) => { + return code.replace(/^(\s*\n)*|(\n\s*)*$/g, '').split(/\n/); +}; + +const compareIgnoreIndent = (a: string, b: string) => { + const ta = a.trimStart(); + const tb = b.trimStart(); + if (ta === tb) return [a.length - ta.length, b.length - tb.length]; + return null; +}; + +expect.extend({ + toMatchCode: function (this, input, expected) { + if (typeof expected !== 'string') throw TypeError('`toMatchConde` method accepts string arguments'); + if (typeof input !== 'string') return { pass: false, message: () => 'code must be string' }; + const a = buildArray(input); + const b = buildArray(expected); + let maxMatchers: [string, string][] = []; + a: for (let i = 0; i < a.length; i++) { + const d = compareIgnoreIndent(a[i], b[0]); + if (!d) continue; + const matches: [string, string][] = []; + for (let j = 1; j < b.length; j++) { + const fa = a[i + j].slice(d[0]); + const fb = b[j].slice(d[1]); + matches.push([fa, fb]); + if (fa !== fb) { + if (matches.length > maxMatchers.length) maxMatchers = matches; + continue a; + } + } + return { pass: true, message: () => 'ok' }; + } + let diff: string | null = null; + if (maxMatchers.length === 0) { + diff = this.utils.diff(input, expected); + } else { + diff = this.utils.diff(maxMatchers.map((i) => i[1]).join('\n'), maxMatchers.map((i) => i[0]).join('\n')); + } + return { pass: false, message: () => diff || 'Cannot match code' }; + }, +}); diff --git a/packages/builder/src/tests/models/Class.test.ts b/packages/builder/src/tests/models/Class.test.ts index e8c1d8f..4caf1b4 100644 --- a/packages/builder/src/tests/models/Class.test.ts +++ b/packages/builder/src/tests/models/Class.test.ts @@ -54,7 +54,7 @@ test('bad class name', () => { test('bad member name', () => { const clz = new Class( { - members: [{ name: '' }], + members: [{ name: '' }, { name: undefined }], }, root, ); @@ -95,3 +95,28 @@ test('hidden members', () => { expect(clz.members).toHaveLength(0); }); + +test('JsonNaming SnakeCaseStrategy', () => { + const clz = new Class( + { + annotations: [ + { + type: 'com.fasterxml.jackson.databind.annotation.JsonNaming', + attributes: { value: 'com.fasterxml.jackson.databind.PropertyNamingStrategy$SnakeCaseStrategy' }, + }, + ], + members: [ + { name: 'getUserInfo' }, + { name: 'getUserInfo2' }, + { name: 'legacy_get_user_info' }, + { name: 'mockXMLHttpRequest' }, + ], + }, + root, + ); + + expect(clz.members[0].name).toBe('get_user_info'); + expect(clz.members[1].name).toBe('get_user_info_2'); + expect(clz.members[2].name).toBe('legacy_get_user_info'); + expect(clz.members[3].name).toBe('mock_x_m_l_http_request'); +}); diff --git a/packages/builder/src/tests/test-tools/buildMethodWithParameters.ts b/packages/builder/src/tests/test-tools/buildMethodWithParameters.ts index 94c5d83..6defc46 100644 --- a/packages/builder/src/tests/test-tools/buildMethodWithParameters.ts +++ b/packages/builder/src/tests/test-tools/buildMethodWithParameters.ts @@ -26,7 +26,7 @@ export const buildMethodWithParameters = (target: 'oc' | 'ts', ...parameters: De }; const defs = { routes: [foo], classes: [User], enums: [UserType] }; const { code } = new Builder({ target, base: '', defs }); - return code.replace(/\s+/g, ' '); + return code; }; export const buildTsMethodWithParameters = (...parameters: DeepPartial[]) => diff --git a/packages/builder/src/tests/test-tools/mg.ts b/packages/builder/src/tests/test-tools/mg.ts deleted file mode 100644 index e6bc165..0000000 --- a/packages/builder/src/tests/test-tools/mg.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const mg = (a: TemplateStringsArray) => { - return a[0].replace(/\s+/g, ' ').trim(); -}; diff --git a/packages/builder/src/utils/index.ts b/packages/builder/src/utils/index.ts index 60d1909..dcac3d1 100644 --- a/packages/builder/src/utils/index.ts +++ b/packages/builder/src/utils/index.ts @@ -13,14 +13,30 @@ export const isOneOf = (u: string): u is T => a.indexOf(u as T) !== -1; +/** + * Convert snake to upper camel. + */ export const toUpperCamel = (n: string) => n .replace(/(?:[\W_]+|^)([a-z]?)/g, (_, z) => z.toUpperCase()) // A variable name cannot start with numbers, so add a prefix "The". .replace(/^\d+/, 'The$&'); +/** + * Convert snake to lower camel. + */ export const toLowerCamel = (n: string) => toUpperCamel(n).replace(/^[A-Z]/, (s) => s.toLowerCase()); +/** + * Convert camel to snake. + */ +export const toSnake = (n: string) => + n + .split(/(?=[^a-z])/) + .join('_') + .replace(/[\W_]+/g, '_') + .toLowerCase(); + // Remove dynamic suffixes from proxy class names, such as $$EnhancerByCGLIB*, $$FastClassByCGLIB*, and so on. export const removeDynamicSuffix = (name: string) => name.replace(/\$\$.*/, ''); diff --git a/packages/builder/tsconfig.json b/packages/builder/tsconfig.json index cfb70ea..661e8d4 100644 --- a/packages/builder/tsconfig.json +++ b/packages/builder/tsconfig.json @@ -13,5 +13,6 @@ "declaration": true, "useDefineForClassFields": false }, + "files": ["src/tests/jest.d.ts"], "include": ["src"] } diff --git a/packages/cli/package.json b/packages/cli/package.json index e87cdc1..6e20c74 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@huolala-tech/nad-cli", - "version": "1.1.0", + "version": "1.1.1", "description": "The CLI Tools of Nad Project", "license": "MIT", "author": "YanagiEiichi <576398868@qq.com>", @@ -25,8 +25,8 @@ ], "dependencies": { "@huolala-tech/custom-error": "^1.0.0", - "@huolala-tech/nad-builder": "^1.1.0", - "@huolala-tech/nad-runtime": "^1.0.7", + "@huolala-tech/nad-builder": "^1.1.1", + "@huolala-tech/nad-runtime": "^1.0.8", "axios": "^1.4.0", "capacitance": "^1.0.2", "u2x": "^1.1.0" diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 731c99b..ff69cb7 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@huolala-tech/nad-runtime", - "version": "1.0.7", + "version": "1.0.9", "description": "The runtime lib of the Nad project", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -21,7 +21,7 @@ ], "dependencies": { "@huolala-tech/custom-error": "^1.0.0", - "@huolala-tech/request": "^1.1.2" + "@huolala-tech/request": "^1.1.4" }, "repository": { "type": "git", diff --git a/packages/runtime/src/NadInvoker.ts b/packages/runtime/src/NadInvoker.ts index ecc9c2b..a3a071b 100644 --- a/packages/runtime/src/NadInvoker.ts +++ b/packages/runtime/src/NadInvoker.ts @@ -1,13 +1,25 @@ -import { InvokeParams, InvokeResult, WWW_FORM_URLENCODED, buildQs, request } from '@huolala-tech/request'; +import { + CONTENT_TYPE, + InvokeParams, + InvokeResult, + WWW_FORM_URLENCODED, + buildQs, + isApplicationJson, + isMultipartFormData, + isWwwFormUrlEncoded, + request, +} from '@huolala-tech/request'; import { HttpError } from './errors/HttpError'; import { ObjectNestingTooDeepError } from './errors'; import { joinPath } from './utils/joinPath'; import { insensitiveGet } from './utils/insensitiveGet'; -import { isSupportingPayload, isForm, isNonNullObject } from './utils/predicates'; +import { isSupportingPayload as canThisMethodTakePayload, isNonNullObject } from './utils/predicates'; import { NadRuntime, Settings, MultipartFile } from './NadRuntime'; import { buildPath } from './utils/buildPath'; import { setOrDelete } from './utils/setOrDelete'; +const prependQuestionMarkIfNotEmpty = (s: string) => (s ? '?' + s : s); + /** * An official implementation of NadRuntime. */ @@ -222,7 +234,7 @@ export class NadInvoker implements NadRuntime { /** * Actually execute this request. */ - public execute() { + public async execute() { const { constructor } = this; const runtime = constructor as typeof NadInvoker; // Get the static methods from the current constructor that may be overridden by the derived class. @@ -282,35 +294,54 @@ export class NadInvoker implements NadRuntime { // const headers = { ...runtime.headers, ...this.headers, ...settings?.headers }; - const contentType = insensitiveGet(headers, 'Content-Type'); + const contentType = insensitiveGet(headers, CONTENT_TYPE); const uri = joinPath(base, buildPath(rawPath, pathVariables)); - /** - * PRINCIPLE: Make the HTTP header as light as possible. - * - * Spring Web supports reading certain parameters from either QueryString or Form. - * - * Note: The Form includes only MULTIPART_FORM_DATA and WWW_FORM_URLENCODED. - * - * Whenever possible, we should send our parameters with FormData. - * The following are the POSSIBLE conditions: - * - * 1. The HTTP method used must support sending payloads (POST, and PUT, and PATCH methods). - * 2. No custom body can be provided as it may conflict with other parameters. - * 3. The request's Content-Type must be empty or Form. If it's empty, it can be changed to FormData. - */ - if (isSupportingPayload(method) && !body && isForm(contentType)) { + // Use "in" operator to check it. + // If a parameter is annotated with @RequestBody, + // the body slot will still be occupied even when the actual value is undefined. + const hasBody = 'body' in this; + + const hasFile = Object.keys(files).length; + const canTakePayload = canThisMethodTakePayload(method); + + // Case 1: Must to use payload. + + // Case 1.1: The backend expects to receive files using MULTIPART_FORM_DATA. + // In this case, the data will be put as other fields of FormData, this is the low layer request library logic. + const tIsMultipartFormData = contentType && isMultipartFormData(contentType); + if (hasFile) { + if (tIsMultipartFormData === false) throw new TypeError(`Cannot send file using "${contentType}"`); + if (!canTakePayload) throw new TypeError(`Cannot send file using "${method}" method`); const data = requestParams; - const hasFile = Object.keys(files).length; - // If the `files` is not empty, keep the Content-Type header empty, as it will be set by the request library. - if (!contentType && !hasFile) headers['Content-Type'] = WWW_FORM_URLENCODED; - const qs = buildQs(staticParams).replace(/^(?=.)/, '?'); // Prepend a "?" if not empty. + const qs = prependQuestionMarkIfNotEmpty(buildQs(staticParams)); return { method, url: uri + qs, timeout, headers, data, files, ...extensions }; - } else { - const qs = buildQs({ ...staticParams, ...requestParams }).replace(/^(?=.)/, '?'); // Prepend a "?" if not empty. - return { method, url: uri + qs, timeout, headers, data: Object(body), files, ...extensions }; } + + // Case 1.2: The backend expects to receive a payload such as JSON using APPLICATION_JSON. + // In this case, the data occupies the payload, forcing other parameters must be passed using QueryString. + const tIsApplicationJson = contentType && isApplicationJson(contentType); + if (hasBody || tIsApplicationJson) { + if (!canTakePayload) throw new TypeError(`Cannot send payload using "${method}" method`); + const qs = prependQuestionMarkIfNotEmpty(buildQs({ ...staticParams, ...requestParams })); + return { method, url: uri + qs, timeout, headers, data: body, ...extensions }; + } + + // Case 2: Nice to use payload. + // In this case, the data can be passed using payload. + const tIsWwwFormUrlEncoded = contentType && isWwwFormUrlEncoded(contentType); + if (canTakePayload && (!contentType || tIsWwwFormUrlEncoded || tIsMultipartFormData)) { + if (!contentType) headers[CONTENT_TYPE] = WWW_FORM_URLENCODED; + const data = requestParams; + const qs = prependQuestionMarkIfNotEmpty(buildQs(staticParams)); + return { method, url: uri + qs, timeout, headers, data, ...extensions }; + } + + // Case 3: Otherwise + // In this case, the data can only be passed using QueryString. + const qs = prependQuestionMarkIfNotEmpty(buildQs({ ...staticParams, ...requestParams })); + return { method, url: uri + qs, timeout, headers, ...extensions }; }; /** diff --git a/packages/runtime/src/tests/NadInvoker.test.ts b/packages/runtime/src/tests/NadInvoker.test.ts index 920bd4a..661f578 100644 --- a/packages/runtime/src/tests/NadInvoker.test.ts +++ b/packages/runtime/src/tests/NadInvoker.test.ts @@ -1,14 +1,28 @@ import { NadInvoker, HttpError } from '..'; -import { APPLICATION_JSON, MULTIPART_FORM_DATA, OCTET_STREAM, WWW_FORM_URLENCODED } from '@huolala-tech/request'; +import { + APPLICATION_JSON, + CONTENT_TYPE, + MULTIPART_FORM_DATA, + OCTET_STREAM, + WWW_FORM_URLENCODED, +} from '@huolala-tech/request'; import { ObjectNestingTooDeepError } from '../errors'; import './libs/mock-xhr'; +import { MULTIPART_FORM_DATA_WITH_BOUNDARY } from './libs/mock-xhr'; const base = 'http://localhost'; -class Localhost extends NadInvoker { +class Localhost extends NadInvoker<{ + method?: string; + url?: string; + headers?: Record; + files: unknown; +}> { public readonly base = base; } +const helloTxt = new File(['Hello'], 'xx.txt'); + describe('basic', () => { test('get', async () => { const res = await new Localhost().open('GET', '/test').execute(); @@ -126,7 +140,7 @@ describe('addRequestParam', () => { method: 'POST', url: `${base}/test`, data: 'id=123&name=hehe', - headers: { 'Content-Type': WWW_FORM_URLENCODED }, + headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED }, }); }); @@ -168,7 +182,7 @@ describe('addStaticParam', () => { method: 'POST', url: `${base}/test?action=wtf`, data: 'name=hehe', - headers: { 'Content-Type': WWW_FORM_URLENCODED }, + headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED }, }); }); }); @@ -185,7 +199,7 @@ describe('addRequestBody', () => { test('qs data object in body', async () => { const res = await new Localhost() - .open('POST', '/test', { headers: { 'Content-Type': WWW_FORM_URLENCODED } }) + .open('POST', '/test', { headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED } }) .addRequestBody({ a: 1 }) .execute(); expect(res).toMatchObject({ @@ -197,7 +211,7 @@ describe('addRequestBody', () => { test('form data object in body', async () => { const res = await new Localhost() - .open('POST', '/test', { headers: { 'Content-Type': MULTIPART_FORM_DATA } }) + .open('POST', '/test', { headers: { [CONTENT_TYPE]: MULTIPART_FORM_DATA } }) .addRequestBody({ a: 1 }) .execute(); expect(res).toMatchObject({ @@ -236,15 +250,14 @@ describe('addModelAttribute', () => { test('with json', async () => { const res = await new Localhost() .open('POST', '/test') - .addHeader('Content-Type', APPLICATION_JSON) + .addHeader(CONTENT_TYPE, APPLICATION_JSON) .addModelAttribute({ a: 1, b: { c: 2 } }) .execute(); expect(res).toMatchObject({ method: 'POST', url: `${base}/test?a=1&b.c=2`, - data: {}, - headers: { 'Content-Type': APPLICATION_JSON }, }); + expect(res).not.toHaveProperty('data'); }); test('add two objects', async () => { @@ -294,7 +307,7 @@ describe('addModelAttribute', () => { method: 'POST', url: `${base}/test`, data: 'user.name=hehe&user.age=18&user.v=1&user.v=2', - headers: { 'Content-Type': WWW_FORM_URLENCODED }, + headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED }, }); }); @@ -334,7 +347,7 @@ describe('addMultipartFile', () => { }); test('addCustomFlags', async () => { - class Runtime extends Localhost { + class Runtime extends Localhost { get flags() { return this.customFlags; } @@ -379,3 +392,62 @@ describe('static config', () => { }); }); }); + +describe('Nice to use payload', () => { + test('Explicitly use WWW_FORM_URLENCODED', async () => { + const res = new Localhost() + .open('POST', '/test') + .addRequestParam('a', 1) + .addHeader(CONTENT_TYPE, WWW_FORM_URLENCODED) + .execute(); + expect(res).resolves.toMatchObject({ + method: 'POST', + url: `${base}/test`, + data: 'a=1', + headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED }, + }); + }); + test('Explicitly use MULTIPART_FORM_DATA', async () => { + const res = await new Localhost() + .open('POST', '/test') + .addRequestParam('a', 1) + .addHeader(CONTENT_TYPE, MULTIPART_FORM_DATA) + .execute(); + expect(res).toMatchObject({ + method: 'POST', + url: `${base}/test`, + data: { a: '1' }, + headers: { [CONTENT_TYPE]: MULTIPART_FORM_DATA_WITH_BOUNDARY }, + }); + }); + test('By default, the WWW_FORM_URLENCODED should be used', async () => { + const res = new Localhost().open('POST', '/test').addRequestParam('a', 1).execute(); + expect(res).resolves.toMatchObject({ + method: 'POST', + url: `${base}/test`, + data: 'a=1', + headers: { [CONTENT_TYPE]: WWW_FORM_URLENCODED }, + }); + }); +}); + +describe('Assertion', () => { + test('Attempt to upload file with GET', async () => { + const res = new Localhost().open('GET', '/test').addMultipartFile('f1', helloTxt).execute(); + expect(res).rejects.toThrow(TypeError); + }); + + test('Attempt to upload file with not multipart/form-data', async () => { + const res = new Localhost() + .open('POST', '/test') + .addHeader(CONTENT_TYPE, APPLICATION_JSON) + .addMultipartFile('f1', helloTxt) + .execute(); + expect(res).rejects.toThrow(TypeError); + }); + + test('Attempt to send JSON with GET', async () => { + const res = new Localhost().open('GET', '/test').addHeader(CONTENT_TYPE, APPLICATION_JSON).execute(); + expect(res).rejects.toThrow(TypeError); + }); +}); diff --git a/packages/runtime/src/tests/libs/mock-xhr.ts b/packages/runtime/src/tests/libs/mock-xhr.ts index c672166..7359a4e 100644 --- a/packages/runtime/src/tests/libs/mock-xhr.ts +++ b/packages/runtime/src/tests/libs/mock-xhr.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'events'; import { readAsDataURL } from './readAsDataURL'; -import { APPLICATION_JSON, MULTIPART_FORM_DATA } from '@huolala-tech/request'; +import { APPLICATION_JSON, CONTENT_TYPE, MULTIPART_FORM_DATA } from '@huolala-tech/request'; + +export const MULTIPART_FORM_DATA_WITH_BOUNDARY = `${MULTIPART_FORM_DATA}; boundary=----WebKitFormBoundaryHehehehe`; /** * Get a value by a case-insensitive key @@ -61,7 +63,7 @@ global.XMLHttpRequest = class { } } else if (body instanceof FormData) { if (!Object.keys(this.headers).some((s) => /^Content-Type$/i.test(s))) { - this.headers['Content-Type'] = `${MULTIPART_FORM_DATA}; boundary=----WebKitFormBoundaryHehehehe`; + this.headers[CONTENT_TYPE] = MULTIPART_FORM_DATA_WITH_BOUNDARY; } const temp: Record = {}; const tasks = Array.from(body, async ([k, v]) => { @@ -94,7 +96,7 @@ global.XMLHttpRequest = class { this.headers[key] = value; } getResponseHeader(key: string) { - if (key === 'Content-Type') return APPLICATION_JSON; + if (key === CONTENT_TYPE) return APPLICATION_JSON; return null; } getAllResponseHeaders() { diff --git a/packages/runtime/src/tests/utils/buildPath.test.ts b/packages/runtime/src/tests/utils/buildPath.test.ts index c734686..4b089af 100644 --- a/packages/runtime/src/tests/utils/buildPath.test.ts +++ b/packages/runtime/src/tests/utils/buildPath.test.ts @@ -13,7 +13,7 @@ test('join boolean', () => { }); test('join string', () => { - expect(buildPath('/aaa/{a}', { a: "hehe" })).toBe('/aaa/hehe'); + expect(buildPath('/aaa/{a}', { a: 'hehe' })).toBe('/aaa/hehe'); }); test('with pattern', () => { diff --git a/packages/runtime/src/utils/predicates.ts b/packages/runtime/src/utils/predicates.ts index c1a15f4..3580746 100644 --- a/packages/runtime/src/utils/predicates.ts +++ b/packages/runtime/src/utils/predicates.ts @@ -1,11 +1,3 @@ -import { isMultipartFormData, isWwwFormUrlEncoded } from '@huolala-tech/request'; - -/** - * Detects a form (WWW_FORM_URLENCODED or MULTIPART_FORM_DATA) - */ -export const isForm = (mediaType: string | null) => - !mediaType || isMultipartFormData(mediaType) || isWwwFormUrlEncoded(mediaType); - /** * Detects a non-null object. */ diff --git a/yarn.lock b/yarn.lock index 0060baa..d5f496a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,10 +1452,10 @@ resolved "https://registry.yarnpkg.com/@huolala-tech/hooks/-/hooks-1.0.0.tgz#c6f6508177a7ceb4ee6d9a22fcff84c2833df96c" integrity sha512-KABHqyFtw7iBYABFOKeYowCyNsIjbnsQIGibhgUVy6sx6t6+IAmZ9rxY+m5VbyKqQmjxvmcGjn2T3kOovWAqaA== -"@huolala-tech/request@^1.1.2", "@huolala-tech/request@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@huolala-tech/request/-/request-1.1.3.tgz#96cfdfe4e0df78589c0de07b77a4e0f777bfe628" - integrity sha512-WQ38KqxgwqSDKa7XAl7VHEkkMf/GpM7PkOfOevYt6DXbz2p3zt7jS/B38IxsJ6+W32fI+JubM8zuruxtScXrUg== +"@huolala-tech/request@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@huolala-tech/request/-/request-1.1.4.tgz#f979dcccbe5bbbd12239ce45487366d09ca4cf5a" + integrity sha512-zgp4gMg63+Cchc0Y87k/NUj7jlxtRofbCwCiX8lJYQ/RomyUVKHzz9JI/u/MdZpWldLMobVVbqzL/fjyE+5auQ== dependencies: "@huolala-tech/custom-error" "^1.0.0"