Skip to content

Commit 9e8890a

Browse files
authored
[Core v2] Return null when the response is empty and nullable (Azure#14366)
Fixes Azure#12009. # Scenario When the service does not return a body ([Example in our test server](https://github.com/Azure/autorest.testserver/blob/4b994be5fd68b3ac009af30399ad6b817630dbc8/legacy/routes/dictionary.js#L24)). # Expectation Response will be `null` if the mapper says it is `nullable`. # Current State Response is an empty object or array ([Example](https://github.com/Azure/autorest.typescript/blob/c8e68b845ccc3780d8c4be3787eb2c5e74272697/test/integration/bodyArray.spec.ts#L21)). # Fix Implements the expectation by doing case analysis and adds test cases.
1 parent 5bf75f6 commit 9e8890a

File tree

3 files changed

+362
-20
lines changed

3 files changed

+362
-20
lines changed

sdk/core/core-client/src/utils.ts

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,61 @@ export function isValidUuid(uuid: string): boolean {
3737
return validUuidRegex.test(uuid);
3838
}
3939

40+
/**
41+
* Representation of parsed response headers and body coupled with information
42+
* about how to map them:
43+
* - whether the response body should be wrapped (typically if its type is primitive).
44+
* - whether the response is nullable so it can be null if the combination of
45+
* the headers and the body is empty.
46+
*/
47+
interface ResponseObjectWithMetadata {
48+
/** whether the mapper allows nullable body */
49+
hasNullableType: boolean;
50+
/** whether the response's body should be wrapped */
51+
shouldWrapBody: boolean;
52+
/** parsed headers of the response */
53+
headers:
54+
| {
55+
[key: string]: unknown;
56+
}
57+
| undefined;
58+
/** parsed body of the response */
59+
body: any;
60+
}
61+
62+
/**
63+
* Maps the response as follows:
64+
* - wraps the response body if needed (typically if its type is primitive).
65+
* - returns null if the combination of the headers and the body is empty.
66+
* - otherwise, returns the combination of the headers and the body.
67+
*
68+
* @param responseObject - a representation of the parsed response
69+
* @returns the response that will be returned to the user which can be null and/or wrapped
70+
*
71+
* @internal
72+
*/
73+
function handleNullableResponseAndWrappableBody(
74+
responseObject: ResponseObjectWithMetadata
75+
): unknown | null {
76+
const combinedHeadersAndBody = {
77+
...responseObject.headers,
78+
...responseObject.body
79+
};
80+
if (
81+
responseObject.hasNullableType &&
82+
Object.getOwnPropertyNames(combinedHeadersAndBody).length === 0
83+
) {
84+
return responseObject.shouldWrapBody ? { body: null } : null;
85+
} else {
86+
return responseObject.shouldWrapBody
87+
? {
88+
...responseObject.headers,
89+
body: responseObject.body
90+
}
91+
: combinedHeadersAndBody;
92+
}
93+
}
94+
4095
/**
4196
* Take a `FullOperationResponse` and turn it into a flat
4297
* response object to hand back to the consumer.
@@ -50,8 +105,19 @@ export function flattenResponse(
50105
responseSpec: OperationResponseMap | undefined
51106
): unknown {
52107
const parsedHeaders = fullResponse.parsedHeaders;
108+
109+
/**
110+
* If body is not asked for, we return the response headers only. If the response
111+
* has a body anyway, that body must be ignored.
112+
*/
113+
if (fullResponse.request.method === "HEAD") {
114+
return parsedHeaders;
115+
}
116+
53117
const bodyMapper = responseSpec && responseSpec.bodyMapper;
118+
const isNullable = Boolean(bodyMapper?.nullable);
54119

120+
/** If the body is asked for, we look at the mapper to handle it */
55121
if (bodyMapper) {
56122
const typeName = bodyMapper.type.name;
57123
if (typeName === "Stream") {
@@ -82,30 +148,28 @@ export function flattenResponse(
82148
arrayResponse[key] = parsedHeaders[key];
83149
}
84150
}
85-
return arrayResponse;
151+
return isNullable &&
152+
!fullResponse.parsedBody &&
153+
!parsedHeaders &&
154+
Object.getOwnPropertyNames(modelProperties).length === 0
155+
? null
156+
: arrayResponse;
86157
}
87158

88159
if (typeName === "Composite" || typeName === "Dictionary") {
89-
return {
90-
...parsedHeaders,
91-
...fullResponse.parsedBody
92-
};
160+
return handleNullableResponseAndWrappableBody({
161+
body: fullResponse.parsedBody,
162+
headers: parsedHeaders,
163+
hasNullableType: isNullable,
164+
shouldWrapBody: false
165+
});
93166
}
94167
}
95168

96-
if (
97-
bodyMapper ||
98-
fullResponse.request.method === "HEAD" ||
99-
isPrimitiveType(fullResponse.parsedBody)
100-
) {
101-
return {
102-
...parsedHeaders,
103-
body: fullResponse.parsedBody
104-
};
105-
}
106-
107-
return {
108-
...parsedHeaders,
109-
...fullResponse.parsedBody
110-
};
169+
return handleNullableResponseAndWrappableBody({
170+
body: fullResponse.parsedBody,
171+
headers: parsedHeaders,
172+
hasNullableType: isNullable,
173+
shouldWrapBody: isPrimitiveType(fullResponse.parsedBody)
174+
});
111175
}

sdk/core/core-client/test/serviceClient.spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { deserializationPolicy } from "../src/deserializationPolicy";
3232
import { TokenCredential } from "@azure/core-auth";
3333
import { getCachedDefaultHttpClient } from "../src/httpClientCache";
34+
import { assertServiceClientResponse } from "./utils/serviceClient";
3435

3536
describe("ServiceClient", function() {
3637
describe("Auth scopes", () => {
@@ -382,6 +383,198 @@ describe("ServiceClient", function() {
382383
assert.deepStrictEqual(res.slice(), [1, 2, 3]);
383384
});
384385

386+
it("should deserialize array response as null if it is empty and nullable", async function() {
387+
await assertServiceClientResponse(
388+
{
389+
responseBodyAsText: "",
390+
responseMapper: {
391+
bodyMapper: {
392+
nullable: true,
393+
type: {
394+
name: MapperTypeNames.Sequence,
395+
element: {
396+
type: {
397+
name: "Number"
398+
}
399+
}
400+
}
401+
}
402+
}
403+
},
404+
null
405+
);
406+
});
407+
408+
it("should deserialize array response as empty array if it is empty and not nullable", async function() {
409+
await assertServiceClientResponse(
410+
{
411+
responseBodyAsText: "",
412+
responseMapper: {
413+
bodyMapper: {
414+
nullable: false,
415+
type: {
416+
name: MapperTypeNames.Sequence,
417+
element: {
418+
type: {
419+
name: "Number"
420+
}
421+
}
422+
}
423+
}
424+
}
425+
},
426+
[]
427+
);
428+
});
429+
430+
it("should deserialize dictionary response as null if it is empty and nullable", async function() {
431+
await assertServiceClientResponse(
432+
{
433+
responseBodyAsText: "",
434+
responseMapper: {
435+
bodyMapper: {
436+
nullable: true,
437+
type: {
438+
name: MapperTypeNames.Dictionary,
439+
value: {
440+
type: {
441+
name: "String"
442+
}
443+
}
444+
}
445+
}
446+
}
447+
},
448+
null
449+
);
450+
});
451+
452+
it("should deserialize dictionary response as empty if it is empty and not nullable", async function() {
453+
await assertServiceClientResponse(
454+
{
455+
responseBodyAsText: "",
456+
responseMapper: {
457+
bodyMapper: {
458+
nullable: false,
459+
type: {
460+
name: MapperTypeNames.Dictionary,
461+
value: {
462+
type: {
463+
name: "String"
464+
}
465+
}
466+
}
467+
}
468+
}
469+
},
470+
{}
471+
);
472+
});
473+
474+
it("should deserialize object response as null if it is empty and nullable", async function() {
475+
await assertServiceClientResponse(
476+
{
477+
responseBodyAsText: "",
478+
responseMapper: {
479+
bodyMapper: {
480+
nullable: true,
481+
type: {
482+
name: MapperTypeNames.Composite,
483+
modelProperties: {
484+
message: {
485+
type: {
486+
name: "String"
487+
}
488+
}
489+
}
490+
}
491+
}
492+
}
493+
},
494+
null
495+
);
496+
});
497+
498+
it("should deserialize object response as empty if it is empty and not nullable", async function() {
499+
await assertServiceClientResponse(
500+
{
501+
responseBodyAsText: "",
502+
responseMapper: {
503+
bodyMapper: {
504+
nullable: false,
505+
type: {
506+
name: MapperTypeNames.Composite,
507+
modelProperties: {
508+
message: {
509+
type: {
510+
name: "String"
511+
}
512+
}
513+
}
514+
}
515+
}
516+
}
517+
},
518+
{}
519+
);
520+
});
521+
522+
it("should deserialize primitive response as null if it is empty and nullable", async function() {
523+
await assertServiceClientResponse(
524+
{
525+
responseBodyAsText: "",
526+
responseMapper: {
527+
bodyMapper: {
528+
nullable: true,
529+
type: {
530+
name: MapperTypeNames.Boolean
531+
}
532+
}
533+
}
534+
},
535+
{
536+
body: null
537+
}
538+
);
539+
});
540+
541+
it("should deserialize primitive response as undefined if it is empty and not nullable", async function() {
542+
await assertServiceClientResponse(
543+
{
544+
responseBodyAsText: "",
545+
responseMapper: {
546+
bodyMapper: {
547+
nullable: false,
548+
type: {
549+
name: MapperTypeNames.Boolean
550+
}
551+
}
552+
}
553+
},
554+
{
555+
body: undefined
556+
}
557+
);
558+
});
559+
560+
it("should deserialize a head request with no body even if the mapper says the body is nullable", async function() {
561+
await assertServiceClientResponse(
562+
{
563+
requestMethod: "HEAD",
564+
responseBodyAsText: "",
565+
responseMapper: {
566+
bodyMapper: {
567+
nullable: true,
568+
type: {
569+
name: MapperTypeNames.Boolean
570+
}
571+
}
572+
}
573+
},
574+
undefined
575+
);
576+
});
577+
385578
describe("getOperationArgumentValueFromParameter()", () => {
386579
it("should return undefined when the parameter path isn't found in the operation arguments or service client", () => {
387580
const operationArguments: OperationArguments = {};

0 commit comments

Comments
 (0)