Skip to content

Commit 10693d8

Browse files
authored
feat(google_cloud): expand BadRequestException for AIP-193 compliance (#226)
Expand BadRequestException with status and details fields, and add toJson() for standard JSON error reporting. Update badRequestMiddleware to leverage toJson() and update tests and changelog.
1 parent c97eff6 commit 10693d8

File tree

4 files changed

+290
-7
lines changed

4 files changed

+290
-7
lines changed

pkgs/google_cloud/CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
## 0.4.1-wip
2+
3+
### `bad_request_exception.dart`
4+
5+
- Expanded `BadRequestException` to support structured error reporting as
6+
per AIP-193.
7+
- Added `status` (`String?`) and `details` (`List<Map<String, Object?>>?`)
8+
fields.
9+
- Added `toJson()` method to serialize the error into a standard Google Cloud
10+
error payload.
11+
- Updated `toString()` to include `status` and `details` when non-null.
12+
- Added factory constructors for common HTTP 4XX status codes: `badRequest`
13+
(400), `unauthorized` (401), `forbidden` (403), `notFound` (404),
14+
`conflict` (409), and `tooManyRequests` (429).
15+
- Added default `status` values for factories that map 1:1 to gRPC status
16+
codes (e.g., `unauthorized` defaults to `'UNAUTHENTICATED'`).
17+
18+
### `http_serving.dart` (and internal files)
19+
20+
- Updated `badRequestMiddleware` to leverage `BadRequestException.toJson()` for
21+
JSON responses, returning a standard Google Cloud error payload.
22+
- Updated plain text errors to use `BadRequestException.toString()`.
23+
124
## 0.4.0
225

326
### `constants.dart`

pkgs/google_cloud/lib/src/serving/bad_request_exception.dart

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,37 @@ import 'http_logging.dart';
2727
/// debugging information which is included in logs, but not sent to the
2828
/// requester.
2929
class BadRequestException implements Exception {
30+
/// The HTTP status code for the response.
31+
///
32+
/// Must be between 400 and 499.
3033
final int statusCode;
34+
35+
/// The message sent to the requester.
3136
final String message;
37+
38+
/// The error that caused this exception.
3239
final Object? innerError;
40+
41+
/// The stack trace of the error that caused this exception.
3342
final StackTrace? innerStack;
3443

44+
/// An explicit error status string (e.g., `INVALID_ARGUMENT`).
45+
///
46+
/// See https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
47+
final String? status;
48+
49+
/// Structured error details.
50+
///
51+
/// See https://google.aip.dev/193#statusdetails
52+
final List<Map<String, Object?>>? details;
53+
3554
BadRequestException(
3655
this.statusCode,
3756
this.message, {
3857
this.innerError,
3958
this.innerStack,
59+
this.status,
60+
this.details,
4061
}) : assert(message.isNotEmpty) {
4162
if (statusCode < 400 || statusCode > 499) {
4263
throw ArgumentError.value(
@@ -47,6 +68,143 @@ class BadRequestException implements Exception {
4768
}
4869
}
4970

71+
/// Creates a new [BadRequestException] with status code 400.
72+
factory BadRequestException.badRequest(
73+
String message, {
74+
Object? innerError,
75+
StackTrace? innerStack,
76+
String? status,
77+
List<Map<String, Object?>>? details,
78+
}) => BadRequestException(
79+
400,
80+
message,
81+
innerError: innerError,
82+
innerStack: innerStack,
83+
status: status,
84+
details: details,
85+
);
86+
87+
/// Creates a new [BadRequestException] with status code 401.
88+
///
89+
/// The request does not have valid authentication credentials for the
90+
/// operation.
91+
factory BadRequestException.unauthorized(
92+
String message, {
93+
Object? innerError,
94+
StackTrace? innerStack,
95+
String? status = 'UNAUTHENTICATED',
96+
List<Map<String, Object?>>? details,
97+
}) => BadRequestException(
98+
401,
99+
message,
100+
innerError: innerError,
101+
innerStack: innerStack,
102+
status: status,
103+
details: details,
104+
);
105+
106+
/// Creates a new [BadRequestException] with status code 403.
107+
///
108+
/// The caller does not have permission to execute the specified
109+
/// operation. `PERMISSION_DENIED` must not be used for rejections
110+
/// caused by exhausting some resource (use `RESOURCE_EXHAUSTED`
111+
/// instead for those errors). `PERMISSION_DENIED` must not be
112+
/// used if the caller can not be identified (use `UNAUTHENTICATED`
113+
/// instead for those errors). This error code does not imply the
114+
/// request is valid or the requested entity exists or satisfies
115+
/// other pre-conditions.
116+
factory BadRequestException.forbidden(
117+
String message, {
118+
Object? innerError,
119+
StackTrace? innerStack,
120+
String? status = 'PERMISSION_DENIED',
121+
List<Map<String, Object?>>? details,
122+
}) => BadRequestException(
123+
403,
124+
message,
125+
innerError: innerError,
126+
innerStack: innerStack,
127+
status: status,
128+
details: details,
129+
);
130+
131+
/// Creates a new [BadRequestException] with status code 404.
132+
///
133+
/// Some requested entity (e.g., file or directory) was not found.
134+
///
135+
/// Note to server developers: if a request is denied for an entire class
136+
/// of users, such as gradual feature rollout or undocumented allowlist,
137+
/// `NOT_FOUND` may be used. If a request is denied for some users within
138+
/// a class of users, such as user-based access control, `PERMISSION_DENIED`
139+
/// must be used.
140+
factory BadRequestException.notFound(
141+
String message, {
142+
Object? innerError,
143+
StackTrace? innerStack,
144+
String? status = 'NOT_FOUND',
145+
List<Map<String, Object?>>? details,
146+
}) => BadRequestException(
147+
404,
148+
message,
149+
innerError: innerError,
150+
innerStack: innerStack,
151+
status: status,
152+
details: details,
153+
);
154+
155+
/// Creates a new [BadRequestException] with status code 409.
156+
factory BadRequestException.conflict(
157+
String message, {
158+
Object? innerError,
159+
StackTrace? innerStack,
160+
String? status,
161+
List<Map<String, Object?>>? details,
162+
}) => BadRequestException(
163+
409,
164+
message,
165+
innerError: innerError,
166+
innerStack: innerStack,
167+
status: status,
168+
details: details,
169+
);
170+
171+
/// Creates a new [BadRequestException] with status code 429.
172+
///
173+
/// Some resource has been exhausted, perhaps a per-user quota, or
174+
/// perhaps the entire file system is out of space.
175+
factory BadRequestException.tooManyRequests(
176+
String message, {
177+
Object? innerError,
178+
StackTrace? innerStack,
179+
String? status = 'RESOURCE_EXHAUSTED',
180+
List<Map<String, Object?>>? details,
181+
}) => BadRequestException(
182+
429,
183+
message,
184+
innerError: innerError,
185+
innerStack: innerStack,
186+
status: status,
187+
details: details,
188+
);
189+
50190
@override
51-
String toString() => '$message ($statusCode)';
191+
String toString() {
192+
final buffer = StringBuffer('$message ($statusCode)');
193+
if (status != null && status!.isNotEmpty) buffer.write(' [$status]');
194+
if (details != null && details!.isNotEmpty) {
195+
buffer.write(' Details: $details');
196+
}
197+
return buffer.toString();
198+
}
199+
200+
/// Returns a JSON representation of the error, suitable for including in a
201+
/// response body.
202+
Map<String, Object?> toJson() => {
203+
'error': {
204+
'code': statusCode,
205+
'message': message,
206+
if (status != null && status!.isNotEmpty) 'status': status,
207+
if (details != null && details!.isNotEmpty) 'details': details,
208+
},
209+
};
52210
}

pkgs/google_cloud/lib/src/serving/http_logging.dart

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Handler _handleBadRequest(Handler innerHandler) => (request) async {
5252
final response = await innerHandler(request);
5353
return response;
5454
} on BadRequestException catch (error, stack) {
55-
return _responseFromBadRequest(error, stack);
55+
return _responseFromBadRequest(error, stack, request.headers);
5656
}
5757
};
5858

@@ -80,15 +80,40 @@ Handler _errorWriter(Handler innerHandler) => (request) async {
8080
return response;
8181
};
8282

83-
Response _responseFromBadRequest(BadRequestException e, StackTrace stack) =>
84-
Response(
83+
Response _responseFromBadRequest(
84+
BadRequestException e,
85+
StackTrace stack, [
86+
Map<String, String>? requestHeaders,
87+
]) {
88+
if (_isJsonRequest(requestHeaders)) {
89+
return Response(
8590
e.statusCode,
86-
body: 'Bad request. ${e.message}',
91+
body: jsonEncode(e.toJson()),
92+
headers: {HttpHeaders.contentTypeHeader: _jsonMimeType},
8793
context: {
8894
_badRequestExceptionContextKey: e,
8995
_badStackTraceContextKey: stack,
9096
},
9197
);
98+
}
99+
100+
return Response(
101+
e.statusCode,
102+
body: e.toString(),
103+
context: {
104+
_badRequestExceptionContextKey: e,
105+
_badStackTraceContextKey: stack,
106+
},
107+
);
108+
}
109+
110+
const _jsonMimeType = 'application/json';
111+
112+
bool _isJsonRequest(Map<String, String>? headers) {
113+
final accept = headers?[HttpHeaders.acceptHeader] ?? '';
114+
final contentType = headers?[HttpHeaders.contentTypeHeader] ?? '';
115+
return accept.contains(_jsonMimeType) || contentType.contains(_jsonMimeType);
116+
}
92117

93118
/// Return [Middleware] that logs errors using Google Cloud structured logs and
94119
/// returns the correct response.
@@ -159,7 +184,7 @@ Middleware cloudLoggingMiddleware(String projectId) {
159184
}
160185

161186
final response = error is BadRequestException
162-
? _responseFromBadRequest(error, stackTrace)
187+
? _responseFromBadRequest(error, stackTrace, request.headers)
163188
: Response.internalServerError();
164189

165190
completer.complete(response);

pkgs/google_cloud/test/logging_test.dart

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,87 @@ void main() {
238238
expect(response.statusCode, 400);
239239
expect(
240240
await response.readAsString(),
241-
contains('Bad request. Custom bad request'),
241+
contains('Custom bad request (400)'), // toString() output
242242
);
243243
});
244244

245+
test('badRequestMiddleware handles BadRequestException (JSON)', () async {
246+
final handler = const Pipeline()
247+
.addMiddleware(badRequestMiddleware)
248+
.addHandler((request) {
249+
throw BadRequestException(400, 'Custom bad request');
250+
});
251+
252+
final response = await handler(
253+
Request(
254+
'GET',
255+
Uri.parse('http://localhost/'),
256+
headers: {'Accept': 'application/json'},
257+
),
258+
);
259+
expect(response.statusCode, 400);
260+
expect(
261+
response.headers[HttpHeaders.contentTypeHeader],
262+
contains('application/json'),
263+
);
264+
265+
final body = await response.readAsString();
266+
final json = jsonDecode(body) as Map<String, dynamic>;
267+
expect(json, {
268+
'error': {'code': 400, 'message': 'Custom bad request'},
269+
});
270+
});
271+
272+
test('skips empty details in toJson', () {
273+
final e1 = BadRequestException(400, 'Custom bad request');
274+
expect(e1.toJson()['error'], {
275+
'code': 400,
276+
'message': 'Custom bad request',
277+
});
278+
279+
final e2 = BadRequestException(400, 'Custom bad request', details: []);
280+
expect(e2.toJson()['error'], {
281+
'code': 400,
282+
'message': 'Custom bad request',
283+
});
284+
});
285+
286+
test('badRequestMiddleware handles expanded BadRequestException', () async {
287+
final handler = const Pipeline()
288+
.addMiddleware(badRequestMiddleware)
289+
.addHandler((request) {
290+
throw BadRequestException.badRequest(
291+
'Custom bad request',
292+
status: 'INVALID_ARGUMENT',
293+
details: [
294+
{'field': 'name', 'message': 'required'},
295+
],
296+
);
297+
});
298+
299+
final response = await handler(
300+
Request(
301+
'GET',
302+
Uri.parse('http://localhost/'),
303+
headers: {'Accept': 'application/json'},
304+
),
305+
);
306+
expect(response.statusCode, 400);
307+
308+
final body = await response.readAsString();
309+
final json = jsonDecode(body) as Map<String, dynamic>;
310+
expect(json, {
311+
'error': {
312+
'code': 400,
313+
'message': 'Custom bad request',
314+
'status': 'INVALID_ARGUMENT',
315+
'details': [
316+
{'field': 'name', 'message': 'required'},
317+
],
318+
},
319+
});
320+
});
321+
245322
test('badRequestMiddleware logs to stderr', () async {
246323
final stderrLines = <String>[];
247324
final handler = const Pipeline()

0 commit comments

Comments
 (0)