Skip to content

Commit

Permalink
Bugfix: optional types should respect original json key during serial…
Browse files Browse the repository at this point in the history
…ization (#114)

* use fieldname when serializing optional types

* add integration tests for case transformations

* add integration tests to go server
  • Loading branch information
joshmossas authored Nov 22, 2024
1 parent c100d38 commit cb1659b
Show file tree
Hide file tree
Showing 11 changed files with 1,407 additions and 14 deletions.
21 changes: 13 additions & 8 deletions languages/go/go-server/decode_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -605,14 +605,19 @@ func structFromJSON(data *gjson.Result, target reflect.Value, c *ValidationConte
if !fieldMeta.IsExported() {
continue
}
switch c.KeyCasing {
case KeyCasingCamelCase:
fieldName = strcase.ToLowerCamel(fieldName)
case KeyCasingPascalCase:
case KeyCasingSnakeCase:
fieldName = strcase.ToSnake(fieldName)
default:
fieldName = strcase.ToLowerCamel(fieldName)
keyTag := fieldMeta.Tag.Get("key")
if len(keyTag) > 0 {
fieldName = keyTag
} else {
switch c.KeyCasing {
case KeyCasingCamelCase:
fieldName = strcase.ToLowerCamel(fieldName)
case KeyCasingPascalCase:
case KeyCasingSnakeCase:
fieldName = strcase.ToSnake(fieldName)
default:
fieldName = strcase.ToLowerCamel(fieldName)
}
}
jsonResult := data.Get(fieldName)
enumValues := None[[]string]()
Expand Down
12 changes: 6 additions & 6 deletions languages/ts/ts-codegen/src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function tsObjectFromSchema(
rpcGenerators: context.rpcGenerators,
});
if (prop.content) subContentParts.push(prop.content);
const fieldName = validVarName(camelCase(key));
const fieldName = validVarName(camelCase(key, { normalize: true }));
fieldParts.push(
`${getJsDocComment(subSchema.metadata)}${fieldName}: ${prop.typeName},`,
);
Expand Down Expand Up @@ -143,7 +143,7 @@ export function tsObjectFromSchema(
rpcGenerators: context.rpcGenerators,
});
if (prop.content) subContentParts.push(prop.content);
const fieldName = validVarName(camelCase(key));
const fieldName = validVarName(camelCase(key, { normalize: true }));
fieldParts.push(
`${getJsDocComment(subSchema.metadata)}${fieldName}?: ${prop.typeName},`,
);
Expand All @@ -153,15 +153,15 @@ export function tsObjectFromSchema(
${prop.fromJsonTemplate(`input.${key}`, tempKey)}
}`);
if (hasKey) {
toJsonParts.push(`if (typeof input.${key} !== 'undefined') {
toJsonParts.push(`if (typeof input.${fieldName} !== 'undefined') {
json += \`,"${key}":\`;
${prop.toJsonTemplate(`input.${key}`, "json", key)}
${prop.toJsonTemplate(`input.${fieldName}`, "json", key)}
}`);
} else {
toJsonParts.push(`if (typeof input.${key} !== 'undefined') {
toJsonParts.push(`if (typeof input.${fieldName} !== 'undefined') {
if (_hasKey) json += ',';
json += '"${key}":';
${prop.toJsonTemplate(`input.${key}`, "json", key)}
${prop.toJsonTemplate(`input.${fieldName}`, "json", key)}
_hasKey = true;
}`);
}
Expand Down
250 changes: 250 additions & 0 deletions tests/clients/dart/lib/test_client.rpc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ class TestClientTestsService {
);
}

Future<ObjectWithPascalCaseKeys> sendObjectWithPascalCaseKeys(
ObjectWithPascalCaseKeys params) async {
return parsedArriRequest(
"$_baseUrl/rpcs/tests/send-object-with-pascal-case-keys",
method: HttpMethod.post,
httpClient: _httpClient,
headers: _headers,
clientVersion: _clientVersion,
params: params.toJson(),
parser: (body) => ObjectWithPascalCaseKeys.fromJsonString(body),
);
}

Future<ObjectWithSnakeCaseKeys> sendObjectWithSnakeCaseKeys(
ObjectWithSnakeCaseKeys params) async {
return parsedArriRequest(
"$_baseUrl/rpcs/tests/send-object-with-snake-case-keys",
method: HttpMethod.post,
httpClient: _httpClient,
headers: _headers,
clientVersion: _clientVersion,
params: params.toJson(),
parser: (body) => ObjectWithSnakeCaseKeys.fromJsonString(body),
);
}

Future<ObjectWithEveryOptionalType> sendPartialObject(
ObjectWithEveryOptionalType params) async {
return parsedArriRequest(
Expand Down Expand Up @@ -2734,6 +2760,230 @@ class ObjectWithEveryNullableTypeNestedArrayElementElement
}
}

class ObjectWithPascalCaseKeys implements ArriModel {
final DateTime createdAt;
final String displayName;
final String? phoneNumber;
final String? emailAddress;
final bool? isAdmin;
const ObjectWithPascalCaseKeys({
required this.createdAt,
required this.displayName,
required this.phoneNumber,
this.emailAddress,
this.isAdmin,
});

factory ObjectWithPascalCaseKeys.empty() {
return ObjectWithPascalCaseKeys(
createdAt: DateTime.now(),
displayName: "",
phoneNumber: null,
);
}

factory ObjectWithPascalCaseKeys.fromJson(Map<String, dynamic> _input_) {
final createdAt = dateTimeFromDynamic(_input_["CreatedAt"], DateTime.now());
final displayName = typeFromDynamic<String>(_input_["DisplayName"], "");
final phoneNumber = nullableTypeFromDynamic<String>(_input_["PhoneNumber"]);
final emailAddress =
nullableTypeFromDynamic<String>(_input_["EmailAddress"]);
final isAdmin = nullableTypeFromDynamic<bool>(_input_["IsAdmin"]);
return ObjectWithPascalCaseKeys(
createdAt: createdAt,
displayName: displayName,
phoneNumber: phoneNumber,
emailAddress: emailAddress,
isAdmin: isAdmin,
);
}

factory ObjectWithPascalCaseKeys.fromJsonString(String input) {
return ObjectWithPascalCaseKeys.fromJson(json.decode(input));
}

@override
Map<String, dynamic> toJson() {
final _output_ = <String, dynamic>{
"CreatedAt": createdAt.toUtc().toIso8601String(),
"DisplayName": displayName,
"PhoneNumber": phoneNumber,
};
if (emailAddress != null) _output_["EmailAddress"] = emailAddress;
if (isAdmin != null) _output_["IsAdmin"] = isAdmin;
return _output_;
}

@override
String toJsonString() {
return json.encode(toJson());
}

@override
String toUrlQueryParams() {
final _queryParts_ = <String>[];
_queryParts_.add("CreatedAt=${createdAt.toUtc().toIso8601String()}");
_queryParts_.add("DisplayName=$displayName");
_queryParts_.add("PhoneNumber=$phoneNumber");
if (emailAddress != null) _queryParts_.add("EmailAddress=$emailAddress");
if (isAdmin != null) _queryParts_.add("IsAdmin=$isAdmin");
return _queryParts_.join("&");
}

@override
ObjectWithPascalCaseKeys copyWith({
DateTime? createdAt,
String? displayName,
String? Function()? phoneNumber,
String? Function()? emailAddress,
bool? Function()? isAdmin,
}) {
return ObjectWithPascalCaseKeys(
createdAt: createdAt ?? this.createdAt,
displayName: displayName ?? this.displayName,
phoneNumber: phoneNumber != null ? phoneNumber() : this.phoneNumber,
emailAddress: emailAddress != null ? emailAddress() : this.emailAddress,
isAdmin: isAdmin != null ? isAdmin() : this.isAdmin,
);
}

@override
List<Object?> get props => [
createdAt,
displayName,
phoneNumber,
emailAddress,
isAdmin,
];

@override
bool operator ==(Object other) {
return other is ObjectWithPascalCaseKeys &&
listsAreEqual(props, other.props);
}

@override
int get hashCode => listToHashCode(props);

@override
String toString() {
return "ObjectWithPascalCaseKeys ${toJsonString()}";
}
}

class ObjectWithSnakeCaseKeys implements ArriModel {
final DateTime createdAt;
final String displayName;
final String? phoneNumber;
final String? emailAddress;
final bool? isAdmin;
const ObjectWithSnakeCaseKeys({
required this.createdAt,
required this.displayName,
required this.phoneNumber,
this.emailAddress,
this.isAdmin,
});

factory ObjectWithSnakeCaseKeys.empty() {
return ObjectWithSnakeCaseKeys(
createdAt: DateTime.now(),
displayName: "",
phoneNumber: null,
);
}

factory ObjectWithSnakeCaseKeys.fromJson(Map<String, dynamic> _input_) {
final createdAt =
dateTimeFromDynamic(_input_["created_at"], DateTime.now());
final displayName = typeFromDynamic<String>(_input_["display_name"], "");
final phoneNumber =
nullableTypeFromDynamic<String>(_input_["phone_number"]);
final emailAddress =
nullableTypeFromDynamic<String>(_input_["email_address"]);
final isAdmin = nullableTypeFromDynamic<bool>(_input_["is_admin"]);
return ObjectWithSnakeCaseKeys(
createdAt: createdAt,
displayName: displayName,
phoneNumber: phoneNumber,
emailAddress: emailAddress,
isAdmin: isAdmin,
);
}

factory ObjectWithSnakeCaseKeys.fromJsonString(String input) {
return ObjectWithSnakeCaseKeys.fromJson(json.decode(input));
}

@override
Map<String, dynamic> toJson() {
final _output_ = <String, dynamic>{
"created_at": createdAt.toUtc().toIso8601String(),
"display_name": displayName,
"phone_number": phoneNumber,
};
if (emailAddress != null) _output_["email_address"] = emailAddress;
if (isAdmin != null) _output_["is_admin"] = isAdmin;
return _output_;
}

@override
String toJsonString() {
return json.encode(toJson());
}

@override
String toUrlQueryParams() {
final _queryParts_ = <String>[];
_queryParts_.add("created_at=${createdAt.toUtc().toIso8601String()}");
_queryParts_.add("display_name=$displayName");
_queryParts_.add("phone_number=$phoneNumber");
if (emailAddress != null) _queryParts_.add("email_address=$emailAddress");
if (isAdmin != null) _queryParts_.add("is_admin=$isAdmin");
return _queryParts_.join("&");
}

@override
ObjectWithSnakeCaseKeys copyWith({
DateTime? createdAt,
String? displayName,
String? Function()? phoneNumber,
String? Function()? emailAddress,
bool? Function()? isAdmin,
}) {
return ObjectWithSnakeCaseKeys(
createdAt: createdAt ?? this.createdAt,
displayName: displayName ?? this.displayName,
phoneNumber: phoneNumber != null ? phoneNumber() : this.phoneNumber,
emailAddress: emailAddress != null ? emailAddress() : this.emailAddress,
isAdmin: isAdmin != null ? isAdmin() : this.isAdmin,
);
}

@override
List<Object?> get props => [
createdAt,
displayName,
phoneNumber,
emailAddress,
isAdmin,
];

@override
bool operator ==(Object other) {
return other is ObjectWithSnakeCaseKeys &&
listsAreEqual(props, other.props);
}

@override
int get hashCode => listToHashCode(props);

@override
String toString() {
return "ObjectWithSnakeCaseKeys ${toJsonString()}";
}
}

class ObjectWithEveryOptionalType implements ArriModel {
final dynamic any;
final bool? boolean;
Expand Down
Loading

0 comments on commit cb1659b

Please sign in to comment.