From 8bf319e2fbebf7958aa6b9d64448ef919e1ef7c7 Mon Sep 17 00:00:00 2001 From: Rob Signorelli Date: Fri, 4 Jun 2021 09:40:33 -0400 Subject: [PATCH] Support for Raw/File Responses (#46) --- README.md | 48 ++- .../basic/calc/calculator_service_handler.go | 2 + example/multiservice/games/game_handler.go | 2 + example/multiservice/scores/score_handler.go | 2 + .../names/gen/name_service.gen.client.dart | 284 +++++++++++++++--- example/names/gen/name_service.gen.client.go | 49 ++- example/names/gen/name_service.gen.client.js | 199 ++++++++++-- example/names/gen/name_service.gen.gateway.go | 46 +++ example/names/name_service.go | 89 +++++- example/names/name_service_handler.go | 42 +++ generate/client_dart_test.go | 64 +++- generate/client_go_test.go | 235 ++++++++++++--- generate/client_node_test.go | 70 ++++- generate/file.go | 1 + generate/templates/client.dart.tmpl | 98 ++++-- generate/templates/client.js.tmpl | 79 ++++- generate/testdata/dart/run_client.dart | 32 ++ generate/testdata/js/run_client.js | 27 +- go.mod | 2 +- go.sum | 8 +- internal/implements/method.go | 73 +++++ internal/naming/naming.go | 23 ++ internal/reflection/reflection.go | 1 + parser/context.go | 16 + parser/parse.go | 11 + rpc/client.go | 43 ++- rpc/gateway.go | 31 ++ 27 files changed, 1403 insertions(+), 174 deletions(-) create mode 100644 internal/implements/method.go diff --git a/README.md b/README.md index 8e63bba..7329d67 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ with as little fuss as possible. * [Example](https://github.com/monadicstack/frodo#example) * [Customize HTTP Route, Status, etc](https://github.com/monadicstack/frodo#doc-options-custom-urls-status-etc) * [Error Handling](https://github.com/monadicstack/frodo#error-handling) -* [HTTP Redirects](https://github.com/monadicstack/frodo#http-redirects) * [Middleware](https://github.com/monadicstack/frodo#middleware) + +* [HTTP Redirects](https://github.com/monadicstack/frodo#http-redirects) * [Request Scoped Metadata](https://github.com/monadicstack/frodo#request-scoped-metadata) * [Create a JavaScript Client](https://github.com/monadicstack/frodo#creating-a-javascript-client) * [Create a Dart/Flutter Client](https://github.com/monadicstack/frodo#creating-a-dartflutter-client) @@ -353,6 +354,51 @@ documentation for [github.com/monadicstack/respond](https://github.com/monadicst to see how you can roll your own custom errors, but still drive which 4XX/5XX status your service generates. +## Returning Raw File Data + +Let's say that you're writing `ProfilePictureService`. One of the +operations you might want is the ability to return the raw JPG data +for a user's profile picture. You do this the same way that you +handle JSON-based responses; just implement some interfaces so that +Frodo knows to treat it a little different: + +```go +type ServeResponse struct { + file *io.File +} + +// By implementing io.Reader, that tells Frodo to respond w/ raw +// data rather than JSON. Whatever it reads from here, that's what +// the caller will receive. +func (res ServeResponse) Read(b []byte) (int, error) { + return res.file.Read(b) +} + +// By implementing ContentTypeSpecifier, this lets you dictate the +// underlying HTTP Content-Type header. Without this Frodo will have +// nothing to go on and assume "application/octet-stream". +func (res ServeResponse) ContentType() string { + return "image/jpeg" +} + +// --- and now in your service --- + +func (svc ProfilePictureService) Serve(ctx context.Context, req *ServeRequest) (*ServeResponse, error) { + // Ignore the fact that you probably don't store profile pictures on the + // hard drive of your service boxes... + f, err := os.Open("./pictures/" + req.UserID + ".jpg") + if err != nil { + return nil, errors.NotFound("no profile picture for user %s", req.UserID) + } + return &ServeResponse{file: f}, nil +} +``` + +Since `ServeResponse` implements `io.Reader`, the raw JPG bytes will +be sent to the caller instead of the JSON-marshaled version of the +result. Also, since it implements the `ContentType()` function, the +caller will see it as an "image/jpg" rather than "application/octet-stream". + ## HTTP Redirects It's fairly common to have a service call that does some work diff --git a/example/basic/calc/calculator_service_handler.go b/example/basic/calc/calculator_service_handler.go index 1bceb59..7305860 100644 --- a/example/basic/calc/calculator_service_handler.go +++ b/example/basic/calc/calculator_service_handler.go @@ -11,6 +11,7 @@ import ( // are only included to show how you can return specific types of 4XX errors in a readable/maintainable fashion. type CalculatorServiceHandler struct{} +// Add accepts two integers and returns a result w/ their sum. func (c CalculatorServiceHandler) Add(_ context.Context, req *AddRequest) (*AddResponse, error) { sum := req.A + req.B if sum == 12345 { @@ -20,6 +21,7 @@ func (c CalculatorServiceHandler) Add(_ context.Context, req *AddRequest) (*AddR return &AddResponse{Result: sum}, nil } +// Sub accepts two integers and returns a result w/ their difference. func (c CalculatorServiceHandler) Sub(_ context.Context, req *SubRequest) (*SubResponse, error) { if req.A < req.B { return nil, errors.BadRequest("calculator service does not support negative numbers") diff --git a/example/multiservice/games/game_handler.go b/example/multiservice/games/game_handler.go index 7e223e3..c6d5e76 100644 --- a/example/multiservice/games/game_handler.go +++ b/example/multiservice/games/game_handler.go @@ -14,6 +14,7 @@ type GameServiceHandler struct { Repo Repo } +// GetByID looks up a game record given its unique id. func (svc *GameServiceHandler) GetByID(ctx context.Context, req *GetByIDRequest) (*GetByIDResponse, error) { if req.ID == "" { return nil, errors.BadRequest("id is required") @@ -28,6 +29,7 @@ func (svc *GameServiceHandler) GetByID(ctx context.Context, req *GetByIDRequest) return &response, nil } +// Register adds another game record to our gaming database. func (svc *GameServiceHandler) Register(ctx context.Context, req *RegisterRequest) (*RegisterResponse, error) { if req.Name == "" { return nil, errors.BadRequest("create: name is required") diff --git a/example/multiservice/scores/score_handler.go b/example/multiservice/scores/score_handler.go index 499312e..90a2c70 100644 --- a/example/multiservice/scores/score_handler.go +++ b/example/multiservice/scores/score_handler.go @@ -24,6 +24,7 @@ type ScoreServiceHandler struct { Repo Repo } +// NewHighScore captures a player's high score for the given game. func (svc *ScoreServiceHandler) NewHighScore(ctx context.Context, request *NewHighScoreRequest) (*NewHighScoreResponse, error) { if request.GameID == "" { return nil, errors.BadRequest("high scores: game id is required") @@ -58,6 +59,7 @@ func (svc *ScoreServiceHandler) NewHighScore(ctx context.Context, request *NewHi return &response, nil } +// HighScoresForGame fetches the top "N" high scores achieved by any player func (svc ScoreServiceHandler) HighScoresForGame(ctx context.Context, request *HighScoresForGameRequest) (*HighScoresForGameResponse, error) { if request.GameID == "" { return nil, errors.BadRequest("high scores: game id is required") diff --git a/example/names/gen/name_service.gen.client.dart b/example/names/gen/name_service.gen.client.dart index fded258..b96ff23 100644 --- a/example/names/gen/name_service.gen.client.dart +++ b/example/names/gen/name_service.gen.client.dart @@ -18,6 +18,43 @@ class NameServiceClient { }); + /// Download returns a raw CSV file containing the parsed name. + Future Download(DownloadRequest serviceRequest, {String authorization = ''}) async { + var requestJson = serviceRequest.toJson(); + var method = 'POST'; + var route = '/NameService.Download'; + var url = _joinUrl([baseURL, pathPrefix, _buildRequestPath(method, route, requestJson)]); + + var httpRequest = await httpClient.openUrl(method, Uri.parse(url)); + httpRequest.headers.set('Accept', 'application/json'); + httpRequest.headers.set('Authorization', _authorize(authorization)); + httpRequest.headers.set('Content-Type', 'application/json'); + httpRequest.write(jsonEncode(requestJson)); + + var httpResponse = await httpRequest.close(); + return _handleResponseRaw(httpResponse, (json) => DownloadResponse.fromJson(json)); + } + + /// DownloadExt returns a raw CSV file containing the parsed name. This differs from Download + /// by giving you the "Ext" knob which will let you exercise the content type and disposition + /// interfaces that Frodo supports for raw responses. + Future DownloadExt(DownloadExtRequest serviceRequest, {String authorization = ''}) async { + var requestJson = serviceRequest.toJson(); + var method = 'POST'; + var route = '/NameService.DownloadExt'; + var url = _joinUrl([baseURL, pathPrefix, _buildRequestPath(method, route, requestJson)]); + + var httpRequest = await httpClient.openUrl(method, Uri.parse(url)); + httpRequest.headers.set('Accept', 'application/json'); + httpRequest.headers.set('Authorization', _authorize(authorization)); + httpRequest.headers.set('Content-Type', 'application/json'); + httpRequest.write(jsonEncode(requestJson)); + + var httpResponse = await httpRequest.close(); + return _handleResponseRaw(httpResponse, (json) => DownloadExtResponse.fromJson(json)); + } + + /// FirstName extracts just the first name from a full name string. Future FirstName(FirstNameRequest serviceRequest, {String authorization = ''}) async { var requestJson = serviceRequest.toJson(); var method = 'POST'; @@ -32,8 +69,10 @@ class NameServiceClient { var httpResponse = await httpRequest.close(); return _handleResponse(httpResponse, (json) => FirstNameResponse.fromJson(json)); + } + /// LastName extracts just the last name from a full name string. Future LastName(LastNameRequest serviceRequest, {String authorization = ''}) async { var requestJson = serviceRequest.toJson(); var method = 'POST'; @@ -48,8 +87,10 @@ class NameServiceClient { var httpResponse = await httpRequest.close(); return _handleResponse(httpResponse, (json) => LastNameResponse.fromJson(json)); + } + /// SortName establishes the "phone book" name for the given full name. Future SortName(SortNameRequest serviceRequest, {String authorization = ''}) async { var requestJson = serviceRequest.toJson(); var method = 'POST'; @@ -64,8 +105,10 @@ class NameServiceClient { var httpResponse = await httpRequest.close(); return _handleResponse(httpResponse, (json) => SortNameResponse.fromJson(json)); + } + /// Split separates a first and last name. Future Split(SplitRequest serviceRequest, {String authorization = ''}) async { var requestJson = serviceRequest.toJson(); var method = 'POST'; @@ -80,6 +123,7 @@ class NameServiceClient { var httpResponse = await httpRequest.close(); return _handleResponse(httpResponse, (json) => SplitResponse.fromJson(json)); + } @@ -114,25 +158,24 @@ class NameServiceClient { } Future _handleResponse(HttpClientResponse httpResponse, T Function(Map) factory) async { - var bodyCompleter = new Completer(); - httpResponse.transform(utf8.decoder).listen(bodyCompleter.complete); - var bodyText = await bodyCompleter.future; - if (httpResponse.statusCode >= 400) { - throw new NameServiceException(httpResponse.statusCode, _parseErrorMessage(bodyText)); + throw await NameServiceException.fromResponse(httpResponse); } - return factory(jsonDecode(bodyText)); + var bodyJson = await _streamToString(httpResponse); + return factory(jsonDecode(bodyJson)); } - String _parseErrorMessage(String bodyText) { - try { - Map json = jsonDecode(bodyText); - return json['message'] ?? json['error'] ?? bodyText; - } - catch (_) { - return bodyText; + Future _handleResponseRaw(HttpClientResponse httpResponse, T Function(Map) factory) async { + if (httpResponse.statusCode >= 400) { + throw await NameServiceException.fromResponse(httpResponse); } + + return factory({ + 'Content': httpResponse, + 'ContentType': httpResponse.headers.value('Content-Type') ?? 'application/octet-stream', + 'ContentFileName': _dispositionFileName(httpResponse.headers.value('Content-Disposition')), + }); } String _authorize(String callAuthorization) { @@ -185,24 +228,55 @@ class NameServiceClient { json.keys.forEach((key) => flattenEntry("", key, json[key], result)); return result; } + + String _dispositionFileName(String? contentDisposition) { + if (contentDisposition == null) { + return ''; + } + + var fileNameAttrPos = contentDisposition.indexOf("filename="); + if (fileNameAttrPos < 0) { + return ''; + } + + var fileName = contentDisposition.substring(fileNameAttrPos + 9); + fileName = fileName.startsWith('"') ? fileName.substring(1) : fileName; + fileName = fileName.endsWith('"') ? fileName.substring(0, fileName.length - 1) : fileName; + fileName = fileName.replaceAll('\\"', '\"'); + return fileName; + } } class NameServiceException implements Exception { - int status; - String message; + int status; + String message; + + NameServiceException(this.status, this.message); - NameServiceException(this.status, this.message); + static Future fromResponse(HttpClientResponse response) async { + var body = await _streamToString(response); + var message = ''; + try { + Map json = jsonDecode(body); + message = json['message'] ?? json['error'] ?? body; + } + catch (_) { + message = body; + } + throw new NameServiceException(response.statusCode, message); + } } -class NameRequest implements NameServiceModelJSON { +/// SortNameRequest is the input for the SortName function. +class SortNameRequest implements NameServiceModelJSON { String? Name; - NameRequest({ + SortNameRequest({ this.Name, }); - NameRequest.fromJson(Map json) { + SortNameRequest.fromJson(Map json) { Name = json['Name']; } @@ -213,6 +287,56 @@ class NameRequest implements NameServiceModelJSON { } } +/// SortNameResponse is the output for the SortName function. +class SortNameResponse implements NameServiceModelJSON { + String? SortName; + + SortNameResponse({ + this.SortName, + }); + + SortNameResponse.fromJson(Map json) { + SortName = json['SortName']; + } + + Map toJson() { + return { + 'SortName': SortName, + }; + } +} + +/// DownloadResponse is the output for the Download function. +class DownloadResponse implements NameServiceModelJSON { + Stream>? Content; + String? ContentType; + String? ContentFileName; + + DownloadResponse({ + this.Content, + this.ContentType, + this.ContentFileName, + + }); + + DownloadResponse.fromJson(Map json) { + Content = json['Content'] as Stream>?; + ContentType = json['ContentType'] ?? 'application/octet-stream'; + ContentFileName = json['ContentFileName'] ?? ''; + + } + + Map toJson() { + return { + 'Content': _streamToString(Content), + 'ContentType': ContentType ?? 'application/octet-stream', + 'ContentFileName': ContentFileName ?? '', + + }; + } +} + +/// SplitResponse is the output for the Split function. class SplitResponse implements NameServiceModelJSON { String? FirstName; String? LastName; @@ -235,14 +359,15 @@ class SplitResponse implements NameServiceModelJSON { } } -class LastNameRequest implements NameServiceModelJSON { +/// DownloadRequest is the input for the Download function. +class DownloadRequest implements NameServiceModelJSON { String? Name; - LastNameRequest({ + DownloadRequest({ this.Name, }); - LastNameRequest.fromJson(Map json) { + DownloadRequest.fromJson(Map json) { Name = json['Name']; } @@ -253,50 +378,57 @@ class LastNameRequest implements NameServiceModelJSON { } } -class LastNameResponse implements NameServiceModelJSON { - String? LastName; +/// FirstNameRequest is the input for the FirstName function. +class FirstNameRequest implements NameServiceModelJSON { + String? Name; - LastNameResponse({ - this.LastName, + FirstNameRequest({ + this.Name, }); - LastNameResponse.fromJson(Map json) { - LastName = json['LastName']; + FirstNameRequest.fromJson(Map json) { + Name = json['Name']; } Map toJson() { return { - 'LastName': LastName, + 'Name': Name, }; } } -class SortNameResponse implements NameServiceModelJSON { - String? SortName; +/// DownloadExtRequest is the input for the DownloadExt function. +class DownloadExtRequest implements NameServiceModelJSON { + String? Name; + String? Ext; - SortNameResponse({ - this.SortName, + DownloadExtRequest({ + this.Name, + this.Ext, }); - SortNameResponse.fromJson(Map json) { - SortName = json['SortName']; + DownloadExtRequest.fromJson(Map json) { + Name = json['Name']; + Ext = json['Ext']; } Map toJson() { return { - 'SortName': SortName, + 'Name': Name, + 'Ext': Ext, }; } } -class SortNameRequest implements NameServiceModelJSON { +/// LastNameRequest is the output for the LastName function. +class LastNameRequest implements NameServiceModelJSON { String? Name; - SortNameRequest({ + LastNameRequest({ this.Name, }); - SortNameRequest.fromJson(Map json) { + LastNameRequest.fromJson(Map json) { Name = json['Name']; } @@ -307,14 +439,34 @@ class SortNameRequest implements NameServiceModelJSON { } } -class SplitRequest implements NameServiceModelJSON { +/// LastNameResponse is the output for the LastName function. +class LastNameResponse implements NameServiceModelJSON { + String? LastName; + + LastNameResponse({ + this.LastName, + }); + + LastNameResponse.fromJson(Map json) { + LastName = json['LastName']; + } + + Map toJson() { + return { + 'LastName': LastName, + }; + } +} + +/// NameRequest generalizes the data we pass to any of the name service functions. +class NameRequest implements NameServiceModelJSON { String? Name; - SplitRequest({ + NameRequest({ this.Name, }); - SplitRequest.fromJson(Map json) { + NameRequest.fromJson(Map json) { Name = json['Name']; } @@ -325,6 +477,37 @@ class SplitRequest implements NameServiceModelJSON { } } +/// DownloadExtResponse is the output for the DownloadExt function. +class DownloadExtResponse implements NameServiceModelJSON { + Stream>? Content; + String? ContentType; + String? ContentFileName; + + DownloadExtResponse({ + this.Content, + this.ContentType, + this.ContentFileName, + + }); + + DownloadExtResponse.fromJson(Map json) { + Content = json['Content'] as Stream>?; + ContentType = json['ContentType'] ?? 'application/octet-stream'; + ContentFileName = json['ContentFileName'] ?? ''; + + } + + Map toJson() { + return { + 'Content': _streamToString(Content), + 'ContentType': ContentType ?? 'application/octet-stream', + 'ContentFileName': ContentFileName ?? '', + + }; + } +} + +/// FirstNameResponse is the output for the FirstName function. class FirstNameResponse implements NameServiceModelJSON { String? FirstName; @@ -343,14 +526,14 @@ class FirstNameResponse implements NameServiceModelJSON { } } -class FirstNameRequest implements NameServiceModelJSON { +class SplitRequest implements NameServiceModelJSON { String? Name; - FirstNameRequest({ + SplitRequest({ this.Name, }); - FirstNameRequest.fromJson(Map json) { + SplitRequest.fromJson(Map json) { Name = json['Name']; } @@ -368,6 +551,15 @@ class NameServiceModelJSON { } } - List? _map(List? jsonList, T Function(dynamic) mapping) { +List? _map(List? jsonList, T Function(dynamic) mapping) { return jsonList == null ? null : jsonList.map(mapping).toList(); } + +Future _streamToString(Stream>? stream) async { + if (stream == null) { + return ''; + } + var bodyCompleter = new Completer(); + stream.transform(utf8.decoder).listen(bodyCompleter.complete); + return bodyCompleter.future; +} diff --git a/example/names/gen/name_service.gen.client.go b/example/names/gen/name_service.gen.client.go index c51713d..29f46bc 100644 --- a/example/names/gen/name_service.gen.client.go +++ b/example/names/gen/name_service.gen.client.go @@ -15,6 +15,9 @@ import ( // NewNameServiceClient creates an RPC client that conforms to the NameService interface, but delegates // work to remote instances. You must supply the base address of the remote service gateway instance or // the load balancer for that service. +// +// NameService performs parsing/processing on a person's name. This is primarily just +// used as a reference service for integration testing our generated clients. func NewNameServiceClient(address string, options ...rpc.ClientOption) *NameServiceClient { rpcClient := rpc.NewClient("NameService", address, options...) rpcClient.PathPrefix = "" @@ -28,7 +31,37 @@ type NameServiceClient struct { rpc.Client } -// +// Download returns a raw CSV file containing the parsed name. +func (client *NameServiceClient) Download(ctx context.Context, request *names.DownloadRequest) (*names.DownloadResponse, error) { + if ctx == nil { + return nil, fmt.Errorf("precondition failed: nil context") + } + if request == nil { + return nil, fmt.Errorf("precondition failed: nil request") + } + + response := &names.DownloadResponse{} + err := client.Invoke(ctx, "POST", "/NameService.Download", request, response) + return response, err +} + +// DownloadExt returns a raw CSV file containing the parsed name. This differs from Download +// by giving you the "Ext" knob which will let you exercise the content type and disposition +// interfaces that Frodo supports for raw responses. +func (client *NameServiceClient) DownloadExt(ctx context.Context, request *names.DownloadExtRequest) (*names.DownloadExtResponse, error) { + if ctx == nil { + return nil, fmt.Errorf("precondition failed: nil context") + } + if request == nil { + return nil, fmt.Errorf("precondition failed: nil request") + } + + response := &names.DownloadExtResponse{} + err := client.Invoke(ctx, "POST", "/NameService.DownloadExt", request, response) + return response, err +} + +// FirstName extracts just the first name from a full name string. func (client *NameServiceClient) FirstName(ctx context.Context, request *names.FirstNameRequest) (*names.FirstNameResponse, error) { if ctx == nil { return nil, fmt.Errorf("precondition failed: nil context") @@ -42,7 +75,7 @@ func (client *NameServiceClient) FirstName(ctx context.Context, request *names.F return response, err } -// +// LastName extracts just the last name from a full name string. func (client *NameServiceClient) LastName(ctx context.Context, request *names.LastNameRequest) (*names.LastNameResponse, error) { if ctx == nil { return nil, fmt.Errorf("precondition failed: nil context") @@ -56,7 +89,7 @@ func (client *NameServiceClient) LastName(ctx context.Context, request *names.La return response, err } -// +// SortName establishes the "phone book" name for the given full name. func (client *NameServiceClient) SortName(ctx context.Context, request *names.SortNameRequest) (*names.SortNameResponse, error) { if ctx == nil { return nil, fmt.Errorf("precondition failed: nil context") @@ -70,7 +103,7 @@ func (client *NameServiceClient) SortName(ctx context.Context, request *names.So return response, err } -// +// Split separates a first and last name. func (client *NameServiceClient) Split(ctx context.Context, request *names.SplitRequest) (*names.SplitResponse, error) { if ctx == nil { return nil, fmt.Errorf("precondition failed: nil context") @@ -95,6 +128,14 @@ type NameServiceProxy struct { Service names.NameService } +func (proxy *NameServiceProxy) Download(ctx context.Context, request *names.DownloadRequest) (*names.DownloadResponse, error) { + return proxy.Service.Download(ctx, request) +} + +func (proxy *NameServiceProxy) DownloadExt(ctx context.Context, request *names.DownloadExtRequest) (*names.DownloadExtResponse, error) { + return proxy.Service.DownloadExt(ctx, request) +} + func (proxy *NameServiceProxy) FirstName(ctx context.Context, request *names.FirstNameRequest) (*names.FirstNameResponse, error) { return proxy.Service.FirstName(ctx, request) } diff --git a/example/names/gen/name_service.gen.client.js b/example/names/gen/name_service.gen.client.js index f76bd6f..1529330 100644 --- a/example/names/gen/name_service.gen.client.js +++ b/example/names/gen/name_service.gen.client.js @@ -8,7 +8,8 @@ /** * Exposes all of the standard operations for the remote NameService service. These RPC calls * will be sent over http(s) to the backend service instances. - * + * NameService performs parsing/processing on a person's name. This is primarily just + * used as a reference service for integration testing our generated clients. */ class NameServiceClient { _baseURL; @@ -34,7 +35,75 @@ class NameServiceClient { /** - * + * Download returns a raw CSV file containing the parsed name. + * + * @param { DownloadRequest } serviceRequest The input parameters + * @param {object} [options] + * @param { string } [options.authorization] The HTTP Authorization header value to include + * in the request. This will override any authorization you might have applied when + * constructing this client. Use this in multi-tenant situations where multiple users + * might utilize this service. + * @returns {Promise} The JSON-encoded return value of the operation. + */ + async Download(serviceRequest, {authorization} = {}) { + if (!serviceRequest) { + throw new Error('precondition failed: empty request'); + } + + const method = 'POST'; + const route = '/NameService.Download'; + const url = this._baseURL + '/' + buildRequestPath(method, route, serviceRequest); + const fetchOptions = { + method: 'POST', + headers: { + 'Authorization': authorization || this._authorization, + 'Accept': 'application/json,*/*', + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(serviceRequest), + }; + + const response = await this._fetch(url, fetchOptions); + return handleResponseRaw(response); + } + + /** + * DownloadExt returns a raw CSV file containing the parsed name. This differs from Download + * by giving you the "Ext" knob which will let you exercise the content type and disposition + * interfaces that Frodo supports for raw responses. + * + * @param { DownloadExtRequest } serviceRequest The input parameters + * @param {object} [options] + * @param { string } [options.authorization] The HTTP Authorization header value to include + * in the request. This will override any authorization you might have applied when + * constructing this client. Use this in multi-tenant situations where multiple users + * might utilize this service. + * @returns {Promise} The JSON-encoded return value of the operation. + */ + async DownloadExt(serviceRequest, {authorization} = {}) { + if (!serviceRequest) { + throw new Error('precondition failed: empty request'); + } + + const method = 'POST'; + const route = '/NameService.DownloadExt'; + const url = this._baseURL + '/' + buildRequestPath(method, route, serviceRequest); + const fetchOptions = { + method: 'POST', + headers: { + 'Authorization': authorization || this._authorization, + 'Accept': 'application/json,*/*', + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(serviceRequest), + }; + + const response = await this._fetch(url, fetchOptions); + return handleResponseRaw(response); + } + + /** + * FirstName extracts just the first name from a full name string. * * @param { FirstNameRequest } serviceRequest The input parameters * @param {object} [options] @@ -56,18 +125,18 @@ class NameServiceClient { method: 'POST', headers: { 'Authorization': authorization || this._authorization, - 'Accept': 'application/json', + 'Accept': 'application/json,*/*', 'Content-Type': 'application/json; charset=utf-8', }, body: JSON.stringify(serviceRequest), }; const response = await this._fetch(url, fetchOptions); - return handleResponse(response); + return handleResponseJSON(response); } /** - * + * LastName extracts just the last name from a full name string. * * @param { LastNameRequest } serviceRequest The input parameters * @param {object} [options] @@ -89,18 +158,18 @@ class NameServiceClient { method: 'POST', headers: { 'Authorization': authorization || this._authorization, - 'Accept': 'application/json', + 'Accept': 'application/json,*/*', 'Content-Type': 'application/json; charset=utf-8', }, body: JSON.stringify(serviceRequest), }; const response = await this._fetch(url, fetchOptions); - return handleResponse(response); + return handleResponseJSON(response); } /** - * + * SortName establishes the "phone book" name for the given full name. * * @param { SortNameRequest } serviceRequest The input parameters * @param {object} [options] @@ -122,18 +191,18 @@ class NameServiceClient { method: 'POST', headers: { 'Authorization': authorization || this._authorization, - 'Accept': 'application/json', + 'Accept': 'application/json,*/*', 'Content-Type': 'application/json; charset=utf-8', }, body: JSON.stringify(serviceRequest), }; const response = await this._fetch(url, fetchOptions); - return handleResponse(response); + return handleResponseJSON(response); } /** - * + * Split separates a first and last name. * * @param { SplitRequest } serviceRequest The input parameters * @param {object} [options] @@ -155,14 +224,14 @@ class NameServiceClient { method: 'POST', headers: { 'Authorization': authorization || this._authorization, - 'Accept': 'application/json', + 'Accept': 'application/json,*/*', 'Content-Type': 'application/json; charset=utf-8', }, body: JSON.stringify(serviceRequest), }; const response = await this._fetch(url, fetchOptions); - return handleResponse(response); + return handleResponseJSON(response); } } @@ -241,20 +310,79 @@ function attributeValue(struct, attributeName) { return null; } + /** * Accepts the full response data and the request's promise resolve/reject and determines * which to invoke. This will also JSON-unmarshal the response data if need be. */ -async function handleResponse(response) { - const contentType = response.headers.get('content-type'); - const responseValue = !contentType || contentType.startsWith('application/json') +async function handleResponseJSON(response) { + if (response.status >= 400) { + throw await newError(response); + } + return await response.json(); +} + +/** + * Accepts the full response data and the request's promise resolve/reject and determines + * which to invoke. This assumes that you want the raw bytes as a blob from the HTTP response + * rather than treating it like JSON. This will also capture the Content-Type value as well as + * the "filename" from the Content-Disposition if it's set to "attachment". + * + * @returns { {content: Blob, contentType: string, contentFileName: string} } + */ +async function handleResponseRaw(response) { + if (response.status >= 400) { + throw await newError(response); + } + const content = await response.blob(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentFileName = dispositionFileName(response.headers.get('content-disposition')); + return { + Content: content, + ContentType: contentType, + ContentFileName: contentFileName, + } +} + +/** + * Creates a new GatewayError with all of the meaningful status/message info extracted + * from the HTTP response. + * + * @returns {Promise} + */ +async function newError(response) { + const responseValue = isJSON(response) ? await response.json() : await response.text(); - if (response.status >= 400) { - throw new GatewayError(response.status, parseErrorMessage(responseValue)); + throw new GatewayError(response.status, parseErrorMessage(responseValue)); +} + +/** + * Parses a value from the Content-Disposition header to extract just the filename attribute. + * + * @param {string} contentDisposition + * @returns {string} + */ +function dispositionFileName(contentDisposition = '') { + const fileNameAttrPos = contentDisposition.indexOf('filename='); + if (fileNameAttrPos < 0) { + return ''; } - return responseValue; + + let fileName = contentDisposition.substring(fileNameAttrPos + 9); + fileName = fileName.startsWith('"') ? fileName.substring(1) : fileName; + fileName = fileName.endsWith('"') ? fileName.substring(0, fileName.length - 1) : fileName; + fileName = fileName.replace(/\\"/g, '"'); + return fileName; +} + +/** + * Determines whether or not the response has a content type of JSON. + */ +function isJSON(response) { + const contentType = response.headers.get('content-type'); + return contentType && contentType.toLowerCase().startsWith('application/json'); } /** @@ -358,38 +486,53 @@ class GatewayError { * @property { string } [SortName] */ /** - * @typedef { object } FirstNameResponse - * @property { string } [FirstName] + * @typedef { object } LastNameResponse + * @property { string } [LastName] */ /** * @typedef { object } NameRequest * @property { string } [Name] */ /** - * @typedef { object } SplitRequest + * @typedef { object } DownloadRequest * @property { string } [Name] */ /** - * @typedef { object } SplitResponse - * @property { string } [FirstName] - * @property { string } [LastName] + * @typedef { object } LastNameRequest + * @property { string } [Name] +*/ +/** + * @typedef { object } DownloadExtResponse */ /** * @typedef { object } FirstNameRequest * @property { string } [Name] */ /** - * @typedef { object } LastNameRequest - * @property { string } [Name] + * @typedef { object } FirstNameResponse + * @property { string } [FirstName] */ /** * @typedef { object } SortNameRequest * @property { string } [Name] */ /** - * @typedef { object } LastNameResponse + * @typedef { object } SplitResponse + * @property { string } [FirstName] * @property { string } [LastName] */ +/** + * @typedef { object } DownloadResponse +*/ +/** + * @typedef { object } SplitRequest + * @property { string } [Name] +*/ +/** + * @typedef { object } DownloadExtRequest + * @property { string } [Name] + * @property { string } [Ext] +*/ module.exports = { NameServiceClient, diff --git a/example/names/gen/name_service.gen.gateway.go b/example/names/gen/name_service.gen.gateway.go index dcc7178..ab4f0e8 100644 --- a/example/names/gen/name_service.gen.gateway.go +++ b/example/names/gen/name_service.gen.gateway.go @@ -29,6 +29,44 @@ func NewNameServiceGateway(service names.NameService, options ...rpc.GatewayOpti gw.Name = "NameService" gw.PathPrefix = "" + gw.Register(rpc.Endpoint{ + Method: "POST", + Path: "/NameService.Download", + ServiceName: "NameService", + Name: "Download", + Handler: func(w http.ResponseWriter, req *http.Request) { + response := respond.To(w, req) + + serviceRequest := names.DownloadRequest{} + if err := gw.Binder.Bind(req, &serviceRequest); err != nil { + response.Fail(err) + return + } + + serviceResponse, err := service.Download(req.Context(), &serviceRequest) + response.Reply(200, serviceResponse, err) + }, + }) + + gw.Register(rpc.Endpoint{ + Method: "POST", + Path: "/NameService.DownloadExt", + ServiceName: "NameService", + Name: "DownloadExt", + Handler: func(w http.ResponseWriter, req *http.Request) { + response := respond.To(w, req) + + serviceRequest := names.DownloadExtRequest{} + if err := gw.Binder.Bind(req, &serviceRequest); err != nil { + response.Fail(err) + return + } + + serviceResponse, err := service.DownloadExt(req.Context(), &serviceRequest) + response.Reply(200, serviceResponse, err) + }, + }) + gw.Register(rpc.Endpoint{ Method: "POST", Path: "/NameService.FirstName", @@ -113,6 +151,14 @@ type NameServiceGateway struct { service names.NameService } +func (gw NameServiceGateway) Download(ctx context.Context, request *names.DownloadRequest) (*names.DownloadResponse, error) { + return gw.service.Download(ctx, request) +} + +func (gw NameServiceGateway) DownloadExt(ctx context.Context, request *names.DownloadExtRequest) (*names.DownloadExtResponse, error) { + return gw.service.DownloadExt(ctx, request) +} + func (gw NameServiceGateway) FirstName(ctx context.Context, request *names.FirstNameRequest) (*names.FirstNameResponse, error) { return gw.service.FirstName(ctx, request) } diff --git a/example/names/name_service.go b/example/names/name_service.go index 53d2eeb..2c1eda2 100644 --- a/example/names/name_service.go +++ b/example/names/name_service.go @@ -1,45 +1,132 @@ package names -import "context" +import ( + "context" + "io" +) +// NameService performs parsing/processing on a person's name. This is primarily just +// used as a reference service for integration testing our generated clients. type NameService interface { + // Split separates a first and last name. Split(ctx context.Context, req *SplitRequest) (*SplitResponse, error) + // FirstName extracts just the first name from a full name string. FirstName(ctx context.Context, req *FirstNameRequest) (*FirstNameResponse, error) + // LastName extracts just the last name from a full name string. LastName(ctx context.Context, req *LastNameRequest) (*LastNameResponse, error) + // SortName establishes the "phone book" name for the given full name. SortName(ctx context.Context, req *SortNameRequest) (*SortNameResponse, error) + // Download returns a raw CSV file containing the parsed name. + Download(ctx context.Context, req *DownloadRequest) (*DownloadResponse, error) + // DownloadExt returns a raw CSV file containing the parsed name. This differs from Download + // by giving you the "Ext" knob which will let you exercise the content type and disposition + // interfaces that Frodo supports for raw responses. + DownloadExt(ctx context.Context, req *DownloadExtRequest) (*DownloadExtResponse, error) } +// NameRequest generalizes the data we pass to any of the name service functions. type NameRequest struct { + // Name is the full name we're going to process. Name string } +// SplitRequest is the input for the Split function. type SplitRequest NameRequest +// SplitResponse is the output for the Split function. type SplitResponse struct { FirstNameResponse LastNameResponse } +// FirstNameRequest is the input for the FirstName function. type FirstNameRequest struct { + // Name is the full name we're going to process. Name string } +// FirstNameResponse is the output for the FirstName function. type FirstNameResponse struct { + // FirstName is the result we extracted. FirstName string } +// LastNameRequest is the output for the LastName function. type LastNameRequest struct { + // Name is the full name we're going to process. Name string } +// LastNameResponse is the output for the LastName function. type LastNameResponse struct { + // LastName is the result we extracted. LastName string } +// SortNameRequest is the input for the SortName function. type SortNameRequest struct { + // Name is the full name we're going to process. Name string } +// SortNameResponse is the output for the SortName function. type SortNameResponse struct { + // SortName is the result we extracted. SortName string } + +// DownloadRequest is the input for the Download function. +type DownloadRequest struct { + // Name is the full name we're going to process. + Name string +} + +// DownloadResponse is the output for the Download function. +type DownloadResponse struct { + reader io.ReadCloser +} + +// Content returns the raw CSV bytes we generated. +func (r DownloadResponse) Content() io.ReadCloser { + return r.reader +} + +// SetContent allows clients to accept the raw bytes generated by the service gateway. +func (r *DownloadResponse) SetContent(reader io.ReadCloser) { + r.reader = reader +} + +// DownloadExtRequest is the input for the DownloadExt function. +type DownloadExtRequest struct { + // Name is the full name we're going to process. + Name string + // Ext is the file extension we'll use for the resulting file (also used in the content type) + Ext string +} + +// DownloadExtResponse is the output for the DownloadExt function. +type DownloadExtResponse struct { + DownloadResponse + contentType string + contentFileName string +} + +// ContentType returns the MIME content type of the resulting file. +func (r DownloadExtResponse) ContentType() string { + return r.contentType +} + +// SetContentType allows clients to accept the MIME content type specified by the gateway. +func (r *DownloadExtResponse) SetContentType(contentType string) { + r.contentType = contentType +} + +// ContentFileName returns the file name that the server wanted to call this file. +func (r DownloadExtResponse) ContentFileName() string { + return r.contentFileName +} + +// SetContentFileName allows the client to accept the file name. +func (r *DownloadExtResponse) SetContentFileName(contentFileName string) { + r.contentFileName = contentFileName +} diff --git a/example/names/name_service_handler.go b/example/names/name_service_handler.go index 4f7ce61..b4520b3 100644 --- a/example/names/name_service_handler.go +++ b/example/names/name_service_handler.go @@ -1,16 +1,20 @@ package names import ( + "bytes" "context" + "io" "strings" "github.com/monadicstack/frodo/rpc/authorization" "github.com/monadicstack/frodo/rpc/errors" ) +// NameServiceHandler provides the reference implementation of the NameService. type NameServiceHandler struct { } +// Split separates a first and last name. func (svc NameServiceHandler) Split(ctx context.Context, req *SplitRequest) (*SplitResponse, error) { if authorization.FromContext(ctx).String() == "Donny" { return nil, errors.PermissionDenied("donny, you're out of your element") @@ -27,6 +31,7 @@ func (svc NameServiceHandler) Split(ctx context.Context, req *SplitRequest) (*Sp return &response, nil } +// FirstName extracts just the first name from a full name string. func (svc NameServiceHandler) FirstName(ctx context.Context, req *FirstNameRequest) (*FirstNameResponse, error) { res, err := svc.Split(ctx, &SplitRequest{Name: req.Name}) if err != nil { @@ -35,6 +40,7 @@ func (svc NameServiceHandler) FirstName(ctx context.Context, req *FirstNameReque return &res.FirstNameResponse, nil } +// LastName extracts just the last name from a full name string. func (svc NameServiceHandler) LastName(ctx context.Context, req *LastNameRequest) (*LastNameResponse, error) { res, err := svc.Split(ctx, &SplitRequest{Name: req.Name}) if err != nil { @@ -43,6 +49,7 @@ func (svc NameServiceHandler) LastName(ctx context.Context, req *LastNameRequest return &res.LastNameResponse, nil } +// SortName establishes the "phone book" name for the given full name. func (svc NameServiceHandler) SortName(ctx context.Context, req *SortNameRequest) (*SortNameResponse, error) { res, err := svc.Split(ctx, &SplitRequest{Name: req.Name}) if err != nil { @@ -54,3 +61,38 @@ func (svc NameServiceHandler) SortName(ctx context.Context, req *SortNameRequest } return &SortNameResponse{SortName: strings.ToLower(res.LastName + ", " + res.FirstName)}, nil } + +// Download returns a raw CSV file containing the parsed name. +func (svc NameServiceHandler) Download(ctx context.Context, req *DownloadRequest) (*DownloadResponse, error) { + split, err := svc.Split(ctx, &SplitRequest{Name: req.Name}) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + buf.WriteString("first,last\n") + buf.WriteString(split.FirstName) + buf.WriteString(",") + buf.WriteString(split.LastName) + return &DownloadResponse{reader: io.NopCloser(&buf)}, nil +} + +// DownloadExt returns a raw CSV file containing the parsed name. +func (svc NameServiceHandler) DownloadExt(ctx context.Context, req *DownloadExtRequest) (*DownloadExtResponse, error) { + split, err := svc.Split(ctx, &SplitRequest{Name: req.Name}) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + buf.WriteString("first,last\n") + buf.WriteString(split.FirstName) + buf.WriteString(",") + buf.WriteString(split.LastName) + + res := DownloadExtResponse{} + res.reader = io.NopCloser(&buf) + res.contentType = "text/" + req.Ext + res.contentFileName = "name." + req.Ext + return &res, nil +} diff --git a/generate/client_dart_test.go b/generate/client_dart_test.go index dc3634c..86e350e 100644 --- a/generate/client_dart_test.go +++ b/generate/client_dart_test.go @@ -28,7 +28,7 @@ func (suite *DartClientSuite) TearDownTest() { func (suite *DartClientSuite) Run(testName string, expectedLines int) testext.ClientTestResults { output, err := testext.RunClientTest("dart", "testdata/dart/run_client.dart", testName) - suite.Require().NoError(err, "Executing client runner should not give an error") + suite.Require().NoError(err, "Executing client runner should not give an error: %v", err) suite.Require().Len(output, expectedLines) return output } @@ -36,11 +36,16 @@ func (suite *DartClientSuite) Run(testName string, expectedLines int) testext.Cl // Ensures that we get a connection refused error when connecting to a not-running server. func (suite *DartClientSuite) TestNotConnected() { assert := suite.Require() - output := suite.Run("NotConnected", 1) + output := suite.Run("NotConnected", 2) - fail0 := errors.RPCError{} - suite.ExpectFail(output, 0, &fail0, func() { - assert.Contains(fail0.Message, "Connection refused") + fail := errors.RPCError{} + suite.ExpectFail(output, 0, &fail, func() { + assert.Contains(fail.Message, "Connection refused") + }) + + fail = errors.RPCError{} + suite.ExpectFail(output, 1, &fail, func() { + assert.Contains(fail.Message, "Connection refused") }) } @@ -76,6 +81,44 @@ func (suite *DartClientSuite) TestSuccess() { }) } +func (suite *DartClientSuite) TestSuccessRaw() { + assert := suite.Require() + output := suite.Run("SuccessRaw", 1) + + res := RawDartResult{} + suite.ExpectPass(output, 0, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal("application/octet-stream", res.ContentType) + assert.Equal("", res.ContentFileName) + }) +} + +func (suite *DartClientSuite) TestSuccessRawHeaders() { + assert := suite.Require() + output := suite.Run("SuccessRawHeaders", 3) + + res := RawNodeResult{} + suite.ExpectPass(output, 0, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal("text/csv", res.ContentType) + assert.Equal("name.csv", res.ContentFileName) + }) + + res = RawNodeResult{} + suite.ExpectPass(output, 1, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal("text/txt", res.ContentType) + assert.Equal("name.txt", res.ContentFileName) + }) + + res = RawNodeResult{} + suite.ExpectPass(output, 2, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal(`text/t"x"t`, res.ContentType) + assert.Equal(`name.t"x"t`, res.ContentFileName) + }) +} + // Ensures that validation failures are properly propagated from the server. func (suite *DartClientSuite) TestValidationFailure() { output := suite.Run("ValidationFailure", 8) @@ -140,3 +183,14 @@ func (suite *DartClientSuite) TestAuthFailureCallOverride() { func TestDartClientSuite(t *testing.T) { suite.Run(t, new(DartClientSuite)) } + +// RawDartResult matches the data structure of the Node/JS object returned by service +// functions that result in "raw" byte responses. +type RawDartResult struct { + // Content contains the raw byte content output by the service call. + Content string + // ContentType contains the captured "Content-Type" header data. + ContentType string + // ContentFileName contains the captured file name from the "Content-Disposition" header data. + ContentFileName string +} diff --git a/generate/client_go_test.go b/generate/client_go_test.go index 8b9e9d2..97441ed 100644 --- a/generate/client_go_test.go +++ b/generate/client_go_test.go @@ -4,12 +4,15 @@ package generate_test import ( "context" + stderrors "errors" + "io/ioutil" "net/http" "testing" - "github.com/monadicstack/frodo/example/basic/calc" - calcrpc "github.com/monadicstack/frodo/example/basic/calc/gen" + "github.com/monadicstack/frodo/example/names" + namesrpc "github.com/monadicstack/frodo/example/names/gen" "github.com/monadicstack/frodo/rpc" + "github.com/monadicstack/frodo/rpc/authorization" "github.com/monadicstack/frodo/rpc/errors" "github.com/stretchr/testify/suite" ) @@ -17,18 +20,18 @@ import ( type GoClientSuite struct { suite.Suite server *http.Server - client *calcrpc.CalculatorServiceClient + client *namesrpc.NameServiceClient } func (suite *GoClientSuite) SetupTest() { - serviceHandler := calc.CalculatorServiceHandler{} - gateway := calcrpc.NewCalculatorServiceGateway(&serviceHandler) + serviceHandler := names.NameServiceHandler{} + gateway := namesrpc.NewNameServiceGateway(&serviceHandler) suite.server = &http.Server{Addr: ":54242", Handler: gateway} go func() { _ = suite.server.ListenAndServe() }() - suite.client = calcrpc.NewCalculatorServiceClient("http://localhost:54242") + suite.client = namesrpc.NewNameServiceClient("http://localhost:54242") } func (suite *GoClientSuite) TearDownTest() { @@ -37,75 +40,190 @@ func (suite *GoClientSuite) TearDownTest() { } } -func (suite *GoClientSuite) TestMethodSuccess() { +func (suite *GoClientSuite) errorStatus(err error) int { + var rpcErr errors.RPCError + if stderrors.As(err, &rpcErr) { + return rpcErr.Status() + } + return 0 +} + +// Ensures that we capture a "connection refused" error if we attempt to connect to a bad address for the service +// or it's not responding on that address. +func (suite *GoClientSuite) TestNotConnected() { + r := suite.Require() + ctx := context.Background() + badClient := namesrpc.NewNameServiceClient("http://localhost:55555") + + _, err := badClient.Split(ctx, &names.SplitRequest{Name: "Jeff Lebowski"}) + r.Error(err, "Calls should not succeed if client can't connect to address") + r.Contains(err.Error(), "connection refused") + + _, err = badClient.Download(ctx, &names.DownloadRequest{Name: "Jeff Lebowski"}) + r.Error(err, "Calls should not succeed if client can't connect to address") + r.Contains(err.Error(), "connection refused") +} + +// Ensures that JSON-based requests succeed if nothing goes wrong. +func (suite *GoClientSuite) TestSuccess() { + r := suite.Require() + ctx := context.Background() + + split, err := suite.client.Split(ctx, &names.SplitRequest{Name: "Jeff Lebowski"}) + r.NoError(err, "Successful calls should not result in an error") + r.Equal("Jeff", split.FirstName) + r.Equal("Lebowski", split.LastName) + + first, err := suite.client.FirstName(ctx, &names.FirstNameRequest{Name: "Jeff Lebowski"}) + r.NoError(err, "Successful calls should not result in an error") + r.Equal("Jeff", first.FirstName) + + last, err := suite.client.LastName(ctx, &names.LastNameRequest{Name: "Jeff Lebowski"}) + r.NoError(err, "Successful calls should not result in an error") + r.Equal("Lebowski", last.LastName) + + sort1, err := suite.client.SortName(ctx, &names.SortNameRequest{Name: "Jeff Lebowski"}) + r.NoError(err, "Successful calls should not result in an error") + r.Equal("lebowski, jeff", sort1.SortName) + + sort2, err := suite.client.SortName(ctx, &names.SortNameRequest{Name: "Dude"}) + r.NoError(err, "Successful calls should not result in an error") + r.Equal("dude", sort2.SortName) +} + +// Ensures that explicit error status codes are preserved when the service returns strongly-coded errors. +func (suite *GoClientSuite) TestValidationFailure() { r := suite.Require() ctx := context.Background() - add, err := suite.client.Add(ctx, &calc.AddRequest{A: 5, B: 3}) - r.NoError(err, "Adding two positive numbers should not result in an error") - r.Equal(8, add.Result) + assertError := func(_ interface{}, err error) { + r.Error(err, "When service returns an error, client should propagate the error") + r.Equal(400, suite.errorStatus(err), "Service errors should maintain status code") + } + + assertError(suite.client.Split(ctx, &names.SplitRequest{Name: ""})) + assertError(suite.client.FirstName(ctx, &names.FirstNameRequest{Name: ""})) + assertError(suite.client.LastName(ctx, &names.LastNameRequest{Name: ""})) + assertError(suite.client.SortName(ctx, &names.SortNameRequest{Name: ""})) - sub, err := suite.client.Sub(ctx, &calc.SubRequest{A: 5, B: 3}) - r.NoError(err, "Subtracting two positive numbers should not result in an error") - r.Equal(2, sub.Result) + // Frodo always treats errors as JSON even if the success response type is a "raw" response. + assertError(suite.client.Download(ctx, &names.DownloadRequest{Name: ""})) + assertError(suite.client.DownloadExt(ctx, &names.DownloadExtRequest{Name: ""})) } -func (suite *GoClientSuite) TestMethodFailure() { +// Ensure that the client propagates 403-style errors returned by the service when it rejects the authorization +// value we supply w/ the context. +func (suite *GoClientSuite) TestAuthFailureCall() { r := suite.Require() ctx := context.Background() + ctx = authorization.WithHeader(ctx, authorization.New("Donny")) + + assertError := func(_ interface{}, err error) { + r.Error(err, "Calls should not succeed if context contained bad authorization credentials") + r.Contains(err.Error(), "out of your element", "Authorization error should contain message from the service") + } - _, err := suite.client.Add(ctx, &calc.AddRequest{A: 12344, B: 1}) - r.Error(err, "Adding up to 12345 should result in a 403 error") - rpcErr, ok := err.(errors.RPCError) - r.True(ok, "Client should convert all HTTP error status codes to RPCError instances") - r.Equal(403, rpcErr.Status()) - - _, err = suite.client.Sub(ctx, &calc.SubRequest{A: 3, B: 5}) - r.Error(err, "Subtraction doesn't allow negative results") - rpcErr, ok = err.(errors.RPCError) - r.True(ok, "Client should convert all HTTP error status codes to RPCError instances") - r.Equal(400, rpcErr.Status()) + assertError(suite.client.Split(ctx, &names.SplitRequest{Name: "Jeff Lebowski"})) + assertError(suite.client.FirstName(ctx, &names.FirstNameRequest{Name: "Jeff Lebowski"})) + assertError(suite.client.LastName(ctx, &names.LastNameRequest{Name: "Jeff Lebowski"})) + assertError(suite.client.SortName(ctx, &names.SortNameRequest{Name: "Jeff Lebowski"})) +} + +// Ensures that we don't fail w/ a 403 if the service accepts our authorization credentials. +func (suite *GoClientSuite) TestAuthSuccessCall() { + r := suite.Require() + ctx := context.Background() + ctx = authorization.WithHeader(ctx, authorization.New("Maude")) + + assertSuccess := func(_ interface{}, err error) { + r.NoError(err, "Calls should succeed if service accepted the context authorization") + } + + assertSuccess(suite.client.Split(ctx, &names.SplitRequest{Name: "Jeff Lebowski"})) + assertSuccess(suite.client.FirstName(ctx, &names.FirstNameRequest{Name: "Jeff Lebowski"})) + assertSuccess(suite.client.LastName(ctx, &names.LastNameRequest{Name: "Jeff Lebowski"})) + assertSuccess(suite.client.SortName(ctx, &names.SortNameRequest{Name: "Jeff Lebowski"})) } func (suite *GoClientSuite) TestParamNilChecks() { r := suite.Require() ctx := context.Background() - _, err := suite.client.Add(nil, &calc.AddRequest{A: 5, B: 3}) - r.Error(err, "Should fail if context is nil") + assertNilContextError := func(_ interface{}, err error) { + r.Error(err, "Should fail if context is nil") + r.Equal(0, suite.errorStatus(err), "Error should have 0 status when context is nil") + } + assertNilParamError := func(_ interface{}, err error) { + r.Error(err, "Should fail if method request parameter is nil") + r.Equal(0, suite.errorStatus(err), "Error should have 0 status when request parameter is nil") + } + + assertNilContextError(suite.client.Split(nil, &names.SplitRequest{Name: "Dude"})) + assertNilParamError(suite.client.Split(ctx, nil)) + + assertNilContextError(suite.client.FirstName(nil, &names.FirstNameRequest{Name: "Dude"})) + assertNilParamError(suite.client.FirstName(ctx, nil)) + + assertNilContextError(suite.client.LastName(nil, &names.LastNameRequest{Name: "Dude"})) + assertNilParamError(suite.client.LastName(ctx, nil)) - _, err = suite.client.Add(ctx, nil) - r.Error(err, "Should fail if method request parameter is nil") + assertNilContextError(suite.client.SortName(nil, &names.SortNameRequest{Name: "Dude"})) + assertNilParamError(suite.client.SortName(ctx, nil)) - _, err = suite.client.Sub(nil, &calc.SubRequest{A: 5, B: 3}) - r.Error(err, "Should fail if context is nil") + assertNilContextError(suite.client.Download(nil, &names.DownloadRequest{Name: "Dude"})) + assertNilParamError(suite.client.Download(ctx, nil)) - _, err = suite.client.Sub(ctx, nil) - r.Error(err, "Should fail if method request parameter is nil") + assertNilContextError(suite.client.DownloadExt(nil, &names.DownloadExtRequest{Name: "Dude"})) + assertNilParamError(suite.client.DownloadExt(ctx, nil)) } -func (suite *GoClientSuite) TestInvalidEndpoint() { +// Ensures that a response that implements ContentReader/Writer is properly populated w/ raw data rather +// than using JSON serialization. +func (suite *GoClientSuite) TestRaw() { r := suite.Require() ctx := context.Background() - // Port is different than the gateway in SetupTest - client := calcrpc.NewCalculatorServiceClient("http://localhost:55555") + res, err := suite.client.Download(ctx, &names.DownloadRequest{Name: "Jeff Lebowski"}) + r.NoError(err, "Raw calls should succeed w/o failure") + data, _ := ioutil.ReadAll(res.Content()) + r.Equal("first,last\nJeff,Lebowski", string(data)) +} + +// Ensures that a raw response will capture the content type and disposition file name if you implement +// the correct interfaces. +func (suite *GoClientSuite) TestRaw_withHeaders() { + r := suite.Require() + ctx := context.Background() - _, err := client.Add(ctx, &calc.AddRequest{A: 5, B: 3}) - r.Error(err, "Should fail if unable to connect to gateway endpoint") - r.Contains(err.Error(), "rpc: round trip error") + res, err := suite.client.DownloadExt(ctx, &names.DownloadExtRequest{Name: "Jeff Lebowski", Ext: "csv"}) + r.NoError(err, "Raw calls should succeed w/o failure") + data, _ := ioutil.ReadAll(res.Content()) + r.Equal("first,last\nJeff,Lebowski", string(data)) + r.Equal("text/csv", res.ContentType()) + r.Equal("name.csv", res.ContentFileName()) - _, err = client.Sub(ctx, &calc.SubRequest{A: 5, B: 3}) - r.Error(err, "Should fail if unable to connect to gateway endpoint") - r.Contains(err.Error(), "rpc: round trip error") + res, err = suite.client.DownloadExt(ctx, &names.DownloadExtRequest{Name: "Walter Sobchak", Ext: "txt"}) + r.NoError(err, "Raw calls should succeed w/o failure") + data, _ = ioutil.ReadAll(res.Content()) + r.Equal("first,last\nWalter,Sobchak", string(data)) + r.Equal("text/txt", res.ContentType()) + r.Equal("name.txt", res.ContentFileName()) + + res, err = suite.client.DownloadExt(ctx, &names.DownloadExtRequest{Name: "Walter Sobchak", Ext: `t"x"t`}) + r.NoError(err, "Raw calls should succeed w/o failure") + data, _ = ioutil.ReadAll(res.Content()) + r.Equal("first,last\nWalter,Sobchak", string(data)) + r.Equal(`text/t"x"t`, res.ContentType()) + r.Equal(`name.t"x"t`, res.ContentFileName()) } +// Ensures that middleware functions work properly when calling JSON-based methods on the service. func (suite *GoClientSuite) TestMiddleware() { r := suite.Require() ctx := context.Background() var values []string - client := calcrpc.NewCalculatorServiceClient("http://localhost:54242", rpc.WithClientMiddleware( + client := namesrpc.NewNameServiceClient("http://localhost:54242", rpc.WithClientMiddleware( func(request *http.Request, next rpc.RoundTripperFunc) (*http.Response, error) { values = append(values, "a") res, err := next(request) @@ -120,13 +238,38 @@ func (suite *GoClientSuite) TestMiddleware() { }, )) - _, err := client.Add(ctx, &calc.AddRequest{A: 5, B: 3}) + first, err := client.FirstName(ctx, &names.FirstNameRequest{Name: "Jeff Lebowski"}) r.NoError(err) + r.Equal("Jeff", first.FirstName) r.Equal([]string{"a", "b", "c", "d"}, values) +} - _, err = client.Sub(ctx, &calc.SubRequest{A: 5, B: 3}) +// Ensures that middleware functions work when calling raw-content functions on the service. +func (suite *GoClientSuite) TestMiddleware_raw() { + r := suite.Require() + ctx := context.Background() + var values []string + + client := namesrpc.NewNameServiceClient("http://localhost:54242", rpc.WithClientMiddleware( + func(request *http.Request, next rpc.RoundTripperFunc) (*http.Response, error) { + values = append(values, "a") + res, err := next(request) + values = append(values, "d") + return res, err + }, + func(request *http.Request, next rpc.RoundTripperFunc) (*http.Response, error) { + values = append(values, "b") + res, err := next(request) + values = append(values, "c") + return res, err + }, + )) + + download, err := client.Download(ctx, &names.DownloadRequest{Name: "Jeff Lebowski"}) r.NoError(err) - r.Equal([]string{"a", "b", "c", "d", "a", "b", "c", "d"}, values) + data, _ := ioutil.ReadAll(download.Content()) + r.Equal("first,last\nJeff,Lebowski", string(data)) + r.Equal([]string{"a", "b", "c", "d"}, values) } func TestGoClientSuite(t *testing.T) { diff --git a/generate/client_node_test.go b/generate/client_node_test.go index 95ef044..2b73a72 100644 --- a/generate/client_node_test.go +++ b/generate/client_node_test.go @@ -36,11 +36,16 @@ func (suite *JavaScriptClientSuite) Run(testName string, expectedLines int) test // Ensures that we get a connection refused error when connecting to a not-running server. func (suite *JavaScriptClientSuite) TestNotConnected() { assert := suite.Require() - output := suite.Run("NotConnected", 1) + output := suite.Run("NotConnected", 2) - fail0 := errors.RPCError{} - suite.ExpectFail(output, 0, &fail0, func() { - assert.Contains(fail0.Message, "ECONNREFUSED") + fail := errors.RPCError{} + suite.ExpectFail(output, 0, &fail, func() { + assert.Contains(fail.Message, "ECONNREFUSED") + }) + + fail = errors.RPCError{} + suite.ExpectFail(output, 0, &fail, func() { + assert.Contains(fail.Message, "ECONNREFUSED") }) } @@ -82,9 +87,49 @@ func (suite *JavaScriptClientSuite) TestSuccess() { }) } +// Ensures that all of our service functions succeed when they return "raw" results. +func (suite *JavaScriptClientSuite) TestSuccessRaw() { + assert := suite.Require() + output := suite.Run("SuccessRaw", 1) + + res0 := RawNodeResult{} + suite.ExpectPass(output, 0, &res0, func() { + assert.Equal("first,last\nJeff,Lebowski", res0.Content) + assert.Equal("application/octet-stream", res0.ContentType) + assert.Equal("", res0.ContentFileName) + }) +} + +// Ensures that raw response calls support content type and disposition header processing. +func (suite *JavaScriptClientSuite) TestSuccessRawHeaders() { + assert := suite.Require() + output := suite.Run("SuccessRawHeaders", 3) + + res := RawNodeResult{} + suite.ExpectPass(output, 0, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal("text/csv", res.ContentType) + assert.Equal("name.csv", res.ContentFileName) + }) + + res = RawNodeResult{} + suite.ExpectPass(output, 1, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal("text/txt", res.ContentType) + assert.Equal("name.txt", res.ContentFileName) + }) + + res = RawNodeResult{} + suite.ExpectPass(output, 2, &res, func() { + assert.Equal("first,last\nJeff,Lebowski", res.Content) + assert.Equal(`text/t"x"t`, res.ContentType) + assert.Equal(`name.t"x"t`, res.ContentFileName) + }) +} + // Ensures that validation failures are properly propagated from the server. func (suite *JavaScriptClientSuite) TestValidationFailure() { - output := suite.Run("ValidationFailure", 8) + output := suite.Run("ValidationFailure", 10) suite.ExpectFailStatus(output, 0, 400) suite.ExpectFailStatus(output, 1, 400) @@ -94,6 +139,10 @@ func (suite *JavaScriptClientSuite) TestValidationFailure() { suite.ExpectFailStatus(output, 5, 400) suite.ExpectFailStatus(output, 6, 400) suite.ExpectFailStatus(output, 7, 400) + + // These are the two "raw" failures. + suite.ExpectFailStatus(output, 8, 400) + suite.ExpectFailStatus(output, 9, 400) } // Ensures that calls fail with a 403 if you have a bad authorization value on the entire client. @@ -146,3 +195,14 @@ func (suite *JavaScriptClientSuite) TestAuthFailureCallOverride() { func TestJavaScriptClientSuite(t *testing.T) { suite.Run(t, new(JavaScriptClientSuite)) } + +// RawNodeResult matches the data structure of the Node/JS object returned by service +// functions that result in "raw" byte responses. +type RawNodeResult struct { + // Content contains the raw byte content output by the service call. + Content string + // ContentType contains the captured "Content-Type" header data. + ContentType string + // ContentFileName contains the captured file name from the "Content-Disposition" header data. + ContentFileName string +} diff --git a/generate/file.go b/generate/file.go index 5cbb5f0..33eae9f 100644 --- a/generate/file.go +++ b/generate/file.go @@ -17,6 +17,7 @@ import ( ) //go:embed templates/* +// StandardTemplates provides access to all of the code generation templates that Frodo ships with out of the box. var StandardTemplates embed.FS // File runs the parsed service context through the given file template, generating the appropriate diff --git a/generate/templates/client.dart.tmpl b/generate/templates/client.dart.tmpl index 2fb5b5d..3478a9a 100644 --- a/generate/templates/client.dart.tmpl +++ b/generate/templates/client.dart.tmpl @@ -38,7 +38,11 @@ class {{ $clientName }} { {{ if .Gateway.SupportsBody }}httpRequest.write(jsonEncode(requestJson));{{ end }} var httpResponse = await httpRequest.close(); + {{- if .Response.Implements.ContentReader }} + return _handleResponseRaw(httpResponse, (json) => {{ .Response.Name }}.fromJson(json)); + {{- else }} return _handleResponse(httpResponse, (json) => {{ .Response.Name }}.fromJson(json)); + {{ end }} } {{ end }} @@ -73,25 +77,24 @@ class {{ $clientName }} { } Future _handleResponse(HttpClientResponse httpResponse, T Function(Map) factory) async { - var bodyCompleter = new Completer(); - httpResponse.transform(utf8.decoder).listen(bodyCompleter.complete); - var bodyText = await bodyCompleter.future; - if (httpResponse.statusCode >= 400) { - throw new {{ $exceptionName }}(httpResponse.statusCode, _parseErrorMessage(bodyText)); + throw await {{ $exceptionName }}.fromResponse(httpResponse); } - return factory(jsonDecode(bodyText)); + var bodyJson = await _streamToString(httpResponse); + return factory(jsonDecode(bodyJson)); } - String _parseErrorMessage(String bodyText) { - try { - Map json = jsonDecode(bodyText); - return json['message'] ?? json['error'] ?? bodyText; - } - catch (_) { - return bodyText; + Future _handleResponseRaw(HttpClientResponse httpResponse, T Function(Map) factory) async { + if (httpResponse.statusCode >= 400) { + throw await {{ $exceptionName }}.fromResponse(httpResponse); } + + return factory({ + 'Content': httpResponse, + 'ContentType': httpResponse.headers.value('Content-Type') ?? 'application/octet-stream', + 'ContentFileName': _dispositionFileName(httpResponse.headers.value('Content-Disposition')), + }); } String _authorize(String callAuthorization) { @@ -144,13 +147,43 @@ class {{ $clientName }} { json.keys.forEach((key) => flattenEntry("", key, json[key], result)); return result; } + + String _dispositionFileName(String? contentDisposition) { + if (contentDisposition == null) { + return ''; + } + + var fileNameAttrPos = contentDisposition.indexOf("filename="); + if (fileNameAttrPos < 0) { + return ''; + } + + var fileName = contentDisposition.substring(fileNameAttrPos + 9); + fileName = fileName.startsWith('"') ? fileName.substring(1) : fileName; + fileName = fileName.endsWith('"') ? fileName.substring(0, fileName.length - 1) : fileName; + fileName = fileName.replaceAll('\\"', '\"'); + return fileName; + } } class {{ $exceptionName }} implements Exception { - int status; - String message; + int status; + String message; - {{ $exceptionName }}(this.status, this.message); + {{ $exceptionName }}(this.status, this.message); + + static Future<{{ $exceptionName }}> fromResponse(HttpClientResponse response) async { + var body = await _streamToString(response); + var message = ''; + try { + Map json = jsonDecode(body); + message = json['message'] ?? json['error'] ?? body; + } + catch (_) { + message = body; + } + throw new {{ $exceptionName }}(response.statusCode, message); + } } {{ range .Types.NonBasicTypes }} @@ -161,10 +194,20 @@ class {{ $exceptionName }} implements Exception { class {{ $typeName }} implements {{ $serviceName }}ModelJSON { {{ range .Fields }} {{ .Type | DartType }}? {{ .Binding.Name }}; {{- end }} + {{- if .Implements.ContentReader }} + Stream>? Content; + String? ContentType; + String? ContentFileName; + {{- end }} {{ $typeName }}({ {{ range .Fields }} this.{{ .Binding.Name }}, - {{- end }} + {{- end }} + {{- if .Implements.ContentReader }} + this.Content, + this.ContentType, + this.ContentFileName, + {{ end }} }); {{ $typeName }}.fromJson(Map json) { {{ range .Fields -}} @@ -181,6 +224,11 @@ class {{ $typeName }} implements {{ $serviceName }}ModelJSON { {{ range .Fields {{- end }} {{- end }} {{- end }} + {{- if .Implements.ContentReader }} + Content = json['Content'] as Stream>?; + ContentType = json['ContentType'] ?? 'application/octet-stream'; + ContentFileName = json['ContentFileName'] ?? ''; + {{ end }} } Map toJson() { @@ -198,6 +246,11 @@ class {{ $typeName }} implements {{ $serviceName }}ModelJSON { {{ range .Fields {{- end }} {{- end }} {{- end }} + {{- if .Implements.ContentReader }} + 'Content': _streamToString(Content), + 'ContentType': ContentType ?? 'application/octet-stream', + 'ContentFileName': ContentFileName ?? '', + {{ end }} }; } } @@ -209,6 +262,15 @@ class {{$serviceName}}ModelJSON { } } - List? _map(List? jsonList, T Function(dynamic) mapping) { +List? _map(List? jsonList, T Function(dynamic) mapping) { return jsonList == null ? null : jsonList.map(mapping).toList(); } + +Future _streamToString(Stream>? stream) async { + if (stream == null) { + return ''; + } + var bodyCompleter = new Completer(); + stream.transform(utf8.decoder).listen(bodyCompleter.complete); + return bodyCompleter.future; +} diff --git a/generate/templates/client.js.tmpl b/generate/templates/client.js.tmpl index 7660e33..bc333f5 100644 --- a/generate/templates/client.js.tmpl +++ b/generate/templates/client.js.tmpl @@ -56,14 +56,18 @@ class {{ .Service.Name }}Client { method: '{{ .Gateway.Method }}', headers: { 'Authorization': authorization || this._authorization, - 'Accept': 'application/json', + 'Accept': 'application/json,*/*', 'Content-Type': 'application/json; charset=utf-8', }, {{ if .Gateway.SupportsBody }}body: JSON.stringify(serviceRequest),{{ end }} }; const response = await this._fetch(url, fetchOptions); - return handleResponse(response); + {{- if .Response.Implements.ContentWriter }} + return handleResponseRaw(response); + {{- else }} + return handleResponseJSON(response); + {{- end }} } {{ end }} } @@ -142,20 +146,79 @@ function attributeValue(struct, attributeName) { return null; } + /** * Accepts the full response data and the request's promise resolve/reject and determines * which to invoke. This will also JSON-unmarshal the response data if need be. */ -async function handleResponse(response) { - const contentType = response.headers.get('content-type'); - const responseValue = !contentType || contentType.startsWith('application/json') +async function handleResponseJSON(response) { + if (response.status >= 400) { + throw await newError(response); + } + return await response.json(); +} + +/** + * Accepts the full response data and the request's promise resolve/reject and determines + * which to invoke. This assumes that you want the raw bytes as a blob from the HTTP response + * rather than treating it like JSON. This will also capture the Content-Type value as well as + * the "filename" from the Content-Disposition if it's set to "attachment". + * + * @returns { {content: Blob, contentType: string, contentFileName: string} } + */ +async function handleResponseRaw(response) { + if (response.status >= 400) { + throw await newError(response); + } + const content = await response.blob(); + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentFileName = dispositionFileName(response.headers.get('content-disposition')); + return { + Content: content, + ContentType: contentType, + ContentFileName: contentFileName, + } +} + +/** + * Creates a new GatewayError with all of the meaningful status/message info extracted + * from the HTTP response. + * + * @returns {Promise} + */ +async function newError(response) { + const responseValue = isJSON(response) ? await response.json() : await response.text(); - if (response.status >= 400) { - throw new GatewayError(response.status, parseErrorMessage(responseValue)); + throw new GatewayError(response.status, parseErrorMessage(responseValue)); +} + +/** + * Parses a value from the Content-Disposition header to extract just the filename attribute. + * + * @param {string} contentDisposition + * @returns {string} + */ +function dispositionFileName(contentDisposition = '') { + const fileNameAttrPos = contentDisposition.indexOf('filename='); + if (fileNameAttrPos < 0) { + return ''; } - return responseValue; + + let fileName = contentDisposition.substring(fileNameAttrPos + 9); + fileName = fileName.startsWith('"') ? fileName.substring(1) : fileName; + fileName = fileName.endsWith('"') ? fileName.substring(0, fileName.length - 1) : fileName; + fileName = fileName.replace(/\\"/g, '"'); + return fileName; +} + +/** + * Determines whether or not the response has a content type of JSON. + */ +function isJSON(response) { + const contentType = response.headers.get('content-type'); + return contentType && contentType.toLowerCase().startsWith('application/json'); } /** diff --git a/generate/testdata/dart/run_client.dart b/generate/testdata/dart/run_client.dart index 3bfab4e..00dbe42 100644 --- a/generate/testdata/dart/run_client.dart +++ b/generate/testdata/dart/run_client.dart @@ -18,6 +18,10 @@ runTestCase(String name) async { return testNotConnected(); case "Success": return testSuccess(); + case "SuccessRaw": + return testSuccessRaw(); + case "SuccessRawHeaders": + return testSuccessRawHeaders(); case "ValidationFailure": return testValidationFailure(); case "AuthFailureClient": @@ -35,6 +39,7 @@ runTestCase(String name) async { testNotConnected() async { var client = new NameServiceClient("http://localhost:9999"); await output(client.FirstName(new FirstNameRequest(Name: "Jeff Lebowski"))); + await output(client.Download(new DownloadRequest(Name: "Jeff Lebowski"))); } testSuccess() async { @@ -46,6 +51,18 @@ testSuccess() async { await output(client.SortName(SortNameRequest(Name: 'Dude'))); } +testSuccessRaw() async { + var client = new NameServiceClient("http://localhost:9100"); + await outputRaw(client.Download(DownloadRequest(Name: 'Jeff Lebowski'))); +} + +testSuccessRawHeaders() async { + var client = new NameServiceClient("http://localhost:9100"); + await outputRaw(client.DownloadExt(DownloadExtRequest(Name: 'Jeff Lebowski', Ext: 'csv'))); + await outputRaw(client.DownloadExt(DownloadExtRequest(Name: 'Jeff Lebowski', Ext: 'txt'))); + await outputRaw(client.DownloadExt(DownloadExtRequest(Name: 'Jeff Lebowski', Ext: 't"x"t'))); +} + testValidationFailure() async { var client = new NameServiceClient("http://localhost:9100"); await output(client.Split(SplitRequest(Name: ''))); @@ -95,3 +112,18 @@ output(Future model) async { print('FAIL {"message": "$err"}'); } } + +outputRaw(Future model) async { + try { + var modelJson = (await model).toJson(); + modelJson['Content'] = await modelJson['Content']; + print('OK ${jsonEncode(modelJson)}'); + } + on NameServiceException catch (err) { + var message = err.message.replaceAll('"', '\''); + print('FAIL {"status":${err.status}, "message": "${message}"}'); + } + catch (err) { + print('FAIL {"message": "$err"}'); + } +} diff --git a/generate/testdata/js/run_client.js b/generate/testdata/js/run_client.js index 5d43733..d7ee2a5 100644 --- a/generate/testdata/js/run_client.js +++ b/generate/testdata/js/run_client.js @@ -12,6 +12,7 @@ class TestSuite { async testNotConnected() { const client = new NameServiceClient('http://localhost:9999', {fetch}); await output(client.Split({ Name: 'Jeff Lebowski' })); + await output(client.Download({ Name: 'Jeff Lebowski' })); } async testBadFetch() { @@ -28,6 +29,18 @@ class TestSuite { await output(client.SortName({ Name: 'Dude' })); } + async testSuccessRaw() { + const client = new NameServiceClient('http://localhost:9100', {fetch}); + await outputRaw(client.Download({ Name: 'Jeff Lebowski' })); + } + + async testSuccessRawHeaders() { + const client = new NameServiceClient('http://localhost:9100', {fetch}); + await outputRaw(client.DownloadExt({ Name: 'Jeff Lebowski', Ext: 'csv' })); + await outputRaw(client.DownloadExt({ Name: 'Jeff Lebowski', Ext: 'txt' })); + await outputRaw(client.DownloadExt({ Name: 'Jeff Lebowski', Ext: 't"x"t' })); + } + async testValidationFailure() { const client = new NameServiceClient('http://localhost:9100', {fetch}); await output(client.Split({ Name: '' })); @@ -38,6 +51,10 @@ class TestSuite { await output(client.LastName({ })); await output(client.SortName({ Name: '' })); await output(client.SortName({ })); + + // Raw failures should be output as JSON, too. + await outputRaw(client.Download({ })); + await outputRaw(client.DownloadExt({ })); } async testAuthFailureClient() { @@ -65,8 +82,16 @@ class TestSuite { } } -async function output(value) { +async function outputRaw(value) { + return output(value, true); +} + +async function output(value, raw = false) { try { + value = await value; + if (raw) { + value.Content = await value.Content.text(); + } console.info('OK ' + JSON.stringify(await value)); } catch (e) { diff --git a/go.mod b/go.mod index add626b..128c978 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.16 require ( github.com/dimfeld/httptreemux/v5 v5.2.2 - github.com/monadicstack/respond v0.3.0 + github.com/monadicstack/respond v0.4.2 github.com/spf13/cobra v1.1.1 github.com/stretchr/testify v1.6.1 golang.org/x/mod v0.4.0 diff --git a/go.sum b/go.sum index 5f74e8f..569ce3d 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dimfeld/httptreemux v5.0.1+incompatible h1:Qj3gVcDNoOthBAqftuD596rm4wg/adLLz5xh5CmpiCA= -github.com/dimfeld/httptreemux v5.0.1+incompatible/go.mod h1:rbUlSV+CCpv/SuqUTP/8Bk2O3LyUV436/yaRGkhP6Z0= github.com/dimfeld/httptreemux/v5 v5.2.2 h1:8JAUcuNrLbL5uwmvQ4lZVCjuQ/Ioojc+7VGt89aMElU= github.com/dimfeld/httptreemux/v5 v5.2.2/go.mod h1:QeEylH57C0v3VO0tkKraVz9oD3Uu93CKPnTLbsidvSw= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -100,8 +98,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.1-0.20200921135023-fe77dd05ab5a h1:VTF3sHLbpm2PdWMPKVWUMwKg85VE7Ep7wgBw8ETYri8= -github.com/julienschmidt/httprouter v1.3.1-0.20200921135023-fe77dd05ab5a/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -126,8 +122,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/monadicstack/respond v0.3.0 h1:UrB3GFxTBbBUyvuMjEGAzYLRYZwM7EI5gTv3bBABXV4= -github.com/monadicstack/respond v0.3.0/go.mod h1:vpVT7Kya6vnlaBToc6qgS7T5lvVb4jz/8BqGP3GAdQk= +github.com/monadicstack/respond v0.4.2 h1:1O+xmqWP3UaVEoMY4xW0yX92oTIHYThtmF/38tKTtaU= +github.com/monadicstack/respond v0.4.2/go.mod h1:vpVT7Kya6vnlaBToc6qgS7T5lvVb4jz/8BqGP3GAdQk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= diff --git a/internal/implements/method.go b/internal/implements/method.go new file mode 100644 index 0000000..08ec71d --- /dev/null +++ b/internal/implements/method.go @@ -0,0 +1,73 @@ +package implements + +import ( + "go/types" +) + +// Method returns true if the given type is a named structure that implements the specified method +// signature. Since you might be referencing types that are hard to look up in the AST packages info, +// you can just supply the qualified names of the types for your params and return values (e.g. "io.Reader" +// or "http.Client"). +func Method(t types.Type, name string, paramTypes []string, returnTypes []string) bool { + if structType, ok := t.(*types.Struct); ok { + return methodOnStruct(structType, name, paramTypes, returnTypes) + } + if named, ok := t.(*types.Named); ok { + return methodOnNamed(named, name, paramTypes, returnTypes) + } + return false +} + +func methodOnStruct(structType *types.Struct, name string, paramTypes []string, returnTypes []string) bool { + for i := 0; i < structType.NumFields(); i++ { + field := structType.Field(i) + if field.Embedded() && Method(field.Type(), name, paramTypes, returnTypes) { + return true + } + } + return false +} + +func methodOnNamed(namedType *types.Named, name string, paramTypes []string, returnTypes []string) bool { + for i := 0; i < namedType.NumMethods(); i++ { + method := namedType.Method(i) + if Signature(method, name, paramTypes, returnTypes) { + return true + } + } + + if underlying := namedType.Underlying(); namedType != underlying { + return Method(underlying, name, paramTypes, returnTypes) + } + return false +} + +// Signature accepts a single method and determines whether or not it has the same name, parameter types, and return +// types as what you provide. Since you might be referencing types that are hard to look up in the AST packages info, +// you can just supply the qualified names of the types for your params and return values (e.g. "io.Reader" +// or "http.Client"). +func Signature(method *types.Func, name string, paramTypes []string, resultTypes []string) bool { + signature, ok := method.Type().(*types.Signature) + if !ok { + return false + } + if method.Name() != name { + return false + } + + for i, paramType := range paramTypes { + param := signature.Params().At(i) + if param.Type().String() != paramType { + return false + } + } + + for i, resultType := range resultTypes { + result := signature.Results().At(i) + if result.Type().String() != resultType { + return false + } + } + + return true +} diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 67c5c1e..031d230 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -97,6 +97,7 @@ func PathTokens(path string) []string { return strings.Split(path, "/") } +// CleanTypeNameUpper normalizes a raw type's name to be a single token name in upper camel case. func CleanTypeNameUpper(typeName string) string { typeName = CleanPrefix(typeName) typeName = NoSlice(typeName) @@ -105,3 +106,25 @@ func CleanTypeNameUpper(typeName string) string { typeName = ToUpperCamel(typeName) return typeName } + +// DispositionFileName extracts the "filename" from an HTTP Content-Disposition header value. +func DispositionFileName(contentDisposition string) string { + // The start or the file name in the header is the index of "filename=" plus the 9 + // characters in that substring. + fileNameAttrIndex := strings.Index(contentDisposition, "filename=") + if fileNameAttrIndex < 0 { + return "" + } + + // Support the fact that all of these are valid for the disposition header: + // + // attachment; filename=foo.pdf + // attachment; filename="foo.pdf" + // attachment; filename='foo.pdf' + // + // This just makes sure that you don't have any quotes in your final value. + fileName := contentDisposition[fileNameAttrIndex+9:] + fileName = strings.Trim(fileName, `"'`) + fileName = strings.ReplaceAll(fileName, `\"`, `"`) + return fileName +} diff --git a/internal/reflection/reflection.go b/internal/reflection/reflection.go index e7ea96d..9b71991 100644 --- a/internal/reflection/reflection.go +++ b/internal/reflection/reflection.go @@ -44,6 +44,7 @@ func ToAttributes(item interface{}) StructAttributes { return attrs } +// IsNil returns true if the given value's type is both nil-able and nil. func IsNil(value reflect.Value) bool { switch value.Kind() { case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.UnsafePointer, reflect.Interface, reflect.Slice: diff --git a/parser/context.go b/parser/context.go index 71a1168..75f6cd3 100644 --- a/parser/context.go +++ b/parser/context.go @@ -489,6 +489,22 @@ type TypeDeclaration struct { Fields FieldDeclarations // Documentation are all of the comments documenting this operation. Documentation DocumentationLines + // Implements contains some quick checks for whether or not this type implements the various + // single function interfaces used to handle raw data responses. + Implements struct { + // ContentReader is true when it implements that interface. + ContentReader bool + // ContentWriter is true when it implements that interface. + ContentWriter bool + // ContentTypeReader is true when it implements that interface. + ContentTypeReader bool + // ContentTypeWriter is true when it implements that interface. + ContentTypeWriter bool + // ContentFileNameReader is true when it implements that interface. + ContentFileNameReader bool + // ContentFileNameWriter is true when it implements that interface. + ContentFileNameWriter bool + } } // String returns the name of the type. That is all. diff --git a/parser/parse.go b/parser/parse.go index c0f7826..e68fb57 100644 --- a/parser/parse.go +++ b/parser/parse.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/monadicstack/frodo/internal/implements" "github.com/monadicstack/frodo/internal/naming" "golang.org/x/mod/modfile" "golang.org/x/tools/go/packages" @@ -136,6 +137,15 @@ func registerTypeEntry(ctx *Context, registry TypeRegistry, entry *TypeDeclarati // on the underlying type, "[]Bar". registerTypeEntry(ctx, registry, entry, tt.Underlying()) + // Check to see if any/all of our raw file interfaces are implemented. This will serve as helper data for the + // client/gateway generators to know when a response should be treated as JSON (default) or raw bytes. + entry.Implements.ContentReader = implements.Method(tt, "Content", nil, []string{"io.ReadCloser"}) + entry.Implements.ContentTypeReader = implements.Method(tt, "ContentType", nil, []string{"string"}) + entry.Implements.ContentFileNameReader = implements.Method(tt, "ContentFileName", nil, []string{"string"}) + entry.Implements.ContentWriter = implements.Method(tt, "SetContent", []string{"io.ReadCloser"}, nil) + entry.Implements.ContentTypeWriter = implements.Method(tt, "SetContentType", []string{"string"}, nil) + entry.Implements.ContentFileNameWriter = implements.Method(tt, "SetContentFileName", []string{"string"}, nil) + case *types.Array: entry.Basic = entry.Type == t entry.Kind = reflect.Array @@ -530,6 +540,7 @@ func flattenedStructFields(structType *types.Struct) []*types.Var { var fields []*types.Var for i := 0; i < structType.NumFields(); i++ { field := structType.Field(i) + if !field.Exported() { continue } diff --git a/rpc/client.go b/rpc/client.go index 26e378b..df4a245 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/monadicstack/frodo/internal/naming" "github.com/monadicstack/frodo/internal/reflection" "github.com/monadicstack/frodo/rpc/authorization" "github.com/monadicstack/frodo/rpc/errors" @@ -112,24 +113,58 @@ func (c Client) Invoke(ctx context.Context, method string, path string, serviceR if err != nil { return fmt.Errorf("rpc: round trip error: %w", err) } - defer response.Body.Close() // Step 5: Based on the status code, either fill in the "out" struct (service response) with the // unmarshaled JSON or respond a properly formed error. + err = c.decodeResponse(response, serviceResponse) + if err != nil { + return fmt.Errorf("rpc: unable to decode response: %w", err) + } + return nil +} + +func (c Client) decodeResponse(response *http.Response, serviceResponse interface{}) error { if response.StatusCode >= 400 { - return c.newStatusError(response) + return c.decodeStatusError(response) } + if contentWriter, ok := serviceResponse.(ContentWriter); ok { + return c.decodeResponseRaw(response, contentWriter) + } + return c.decodeResponseJSON(response, serviceResponse) +} - err = json.NewDecoder(response.Body).Decode(serviceResponse) +func (c Client) decodeResponseJSON(response *http.Response, serviceResponse interface{}) error { + defer response.Body.Close() + + err := json.NewDecoder(response.Body).Decode(serviceResponse) if err != nil { return fmt.Errorf("rpc: unable to decode response: %w", err) } return nil } +func (c Client) decodeResponseRaw(response *http.Response, serviceResponse ContentWriter) error { + // We do NOT auto-close the body because we have no idea what you plan to do with the body. + // The stream may be much bigger than we want to keep in memory so we don't want to just + // copy it to a bytes.Buffer{}. Thus, we need to force the user to close it when they're done + // with the data. + serviceResponse.SetContent(response.Body) + + if typeWriter, ok := serviceResponse.(ContentTypeWriter); ok { + typeWriter.SetContentType(response.Header.Get("Content-Type")) + } + if fileNameWriter, ok := serviceResponse.(ContentFileNameWriter); ok { + fileName := naming.DispositionFileName(response.Header.Get("Content-Disposition")) + fileNameWriter.SetContentFileName(fileName) + } + return nil +} + // newStatusError takes the response (assumed to be a 400+ status already) and creates // an RPCError with the proper HTTP status as it tries to preserve the original error's message. -func (c Client) newStatusError(r *http.Response) error { +func (c Client) decodeStatusError(r *http.Response) error { + defer r.Body.Close() + errData, _ := ioutil.ReadAll(r.Body) contentType := r.Header.Get("Content-Type") diff --git a/rpc/gateway.go b/rpc/gateway.go index adca5b9..7d9cebc 100644 --- a/rpc/gateway.go +++ b/rpc/gateway.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "io" "net/http" "strings" @@ -310,3 +311,33 @@ type route struct { method string path string } + +// ContentReader defines a response type that should be treated as raw bytes, not JSON. +type ContentReader respond.ContentReader + +// ContentWriter defines a response type that should be treated as raw bytes, not JSON. This is +// utilized by clients to automatically populate readers received from the gateway. +type ContentWriter interface { + // SetContent applies the raw byte data to the response. + SetContent(reader io.ReadCloser) +} + +// ContentTypeReader allows raw responses to specify what type of data the bytes represent. +type ContentTypeReader respond.ContentTypeReader + +// ContentTypeWriter allows raw responses to specify what type of data the bytes represent. This is +// utilized by clients to automatically populate content type values received from the gateway. +type ContentTypeWriter interface { + // SetContentType applies the content type value data to the response. + SetContentType(contentType string) +} + +// ContentFileNameReader allows raw responses to specify the file name (if any) of the file the bytes represent. +type ContentFileNameReader respond.ContentFileNameReader + +// ContentFileNameWriter allows raw responses to specify the file name (if any) of the file the bytes represent. +// This is utilized by clients to automatically populate content type values received from the gateway. +type ContentFileNameWriter interface { + // SetContentType applies the file name value data to the response. + SetContentFileName(contentFileName string) +}