Using Laravel as a service proxy/gateway
Abdurrahman Shofy Adianto
Posted on February 20, 2023
Last week at my dayjob, I was exploring options for implementing a proxy endpoint for one our external service. One of the stack used in our application is Laravel, so naturally I investigated how to implement a proxy endpoint using it. Turns out its pretty simple a Laravel already included most of the stuff required to built it. This approach should be suitable for many simple use case to avoid overhead of adding new dedicated proxy service such as a full blown API gateway like Traefik or Kong, or if you need custom logic which may be hard to achieve using on-the-shelf solutions.
why
When building application using microservice/service oriented architecture, its common to require connection to external services. This services may be some vendor API such as email provider, payment gateway, or simply internal service that one way or another happen to be in a different networks. Some common use cases:
- limit access to credential. by using a single proxy service, other services didnt need to store credential to external services, thus providing better control in terms of maintanance & security.
- sharing resource. for example: oauth token. each service didn't need to request their own token, but instead could share the same token which handled by the proxy service. other example is to caching resource, so frequently accessed resources only need to be accessed in a handful of times
why laravel?
In restropect, Laravel may be an odd choice for building a custom proxy service/API Gateway. If you intent on making the service as a standalone service, it may make more sense to use some microframework such as Lumen or just use Symphony. Other options is to use other more performant stack like Golang or NodeJs. However in my case, the system would actually be embedded in existing Laravel service (because of some reasons), and because of that I want to share my experience, in case someone encounter similar situation like I did.
pros compared to dedicated proxy service/api gateway
- easy to implement custom logic. when using dedicated service, you may need to use some obscure DSL, or plugging your script in some weird way. Obviously this may be subjective, but IMHO using existing stack is a clear advantage as it reduce cognitive load on building & maintaining the service.
- no additional maintenance overhead: no new technology need to be learn
cons
- additional work hops compared to direct access, although it depends on situation. caching may actually improve network connection
- limited scalibility
how
prerequisities
for this tutorial, we'll use Guzzle library as HTTP client. some of you may wondering, "why dont we use Laravel's built-in HTTP client?". Well, Laravel's client is easier to use, but its less flexible for our needs. Laravel's HTTP Client is actually just a wrapper around Guzzle, so it made sense that it may actually less flexible.
To install guzzle just run
composer require guzzlehttp/guzzle
Also for brevity, here we would implement the endpoint right in our route file. In practice, it would be cleaner to separate the logic into dedicated controller file. Here you could choose either routes/web.php
or routes/api.php
, or event create a new route file if you prefer.
basic usage
Lets start simple. Here we would create new endpoint that would call httpbin.org given the path & HTTP method
use GuzzleHttp\Client as HttpClient;
Route::any('/proxy/{path}', function(Request $req, $path) {
$client = new HttpClient([
'base_uri' => 'https://httpbin.org'
]);
return $client->request($req->method(), $path);
});
the code should be pretty straighforward. for every request to /proxy/{path}
, it would make a request to httpbin.org/{path}
with the same HTTP method. you could try requesting the new endpoint using different methods to see it in effect. here I used HTTPie to test the endpoint:
http POST localhost:8000/proxy/post
http PUT localhost:8000/proxy/put
http DELETE localhost:8000/proxy/delete
proxy-ing subpaths
If you notice, the path variable actually only able to retrieve the path, but not the subpath. for example getting localhost:8000/proxy/get works, but localhost:8000/proxy/get/subpath would failed, because laravel would not be able to route the later. the solution is simply to add 'where" method to allow path variable to catch all subpath. so just add:
Route::any(
//...
)->where('path', '.*');
adding request body, params & response code
you may also realize that our current implementation doesn't forward our request body, query params, and response status code. so lets change that:
//...
$resp = $client->request($req->method(), $path, [
'query' => $req->query(),
'body' => $req->getContent(),
]);
return response($resp->getBody()->getContents(), $resp->getStatusCode());
//...
forwarding necessary headers
Finally, you may also realize that our implementation does't forward headers of both request & response. Headers are actually pretty tricky as they may affect our request & response and turns them invalid. I personally found that it best to only forward the necessary header fields, and ignore the rest. This also improve our security.
To do this, we will prepare a helper function to filter our headers, amd only extract only 'content-type' & 'accept' headers. of course you could also modify it according to your needs:
// simple helper function to filter header array on request & response
function filterHeaders($headers) {
$allowedHeaders = ['accept', 'content-type'];
return array_filter($headers, function($key) use ($allowedHeaders) {
return in_array(strtolower($key), $allowedHeaders);
}, ARRAY_FILTER_USE_KEY);
}
and then we could use it in our endpoints. Our final could would be this:
<?php
// you could use either routes/web.php or routes/api.php
// simple helper function to filter header array on request & response
function filterHeaders($headers) {
$allowedHeaders = ['accept', 'content-type'];
return array_filter($headers, function($key) use ($allowedHeaders) {
return in_array(strtolower($key), $allowedHeaders);
}, ARRAY_FILTER_USE_KEY);
}
Route::any('/proxy_example/{path}', function(Request $request, $path) {
$client = new GuzzleHttp\Client([
// Base URI is used with relative requests
'base_uri' => 'https://pie.dev', // public dummy API for example
// You can set any number of default request options.
'timeout' => 60.0,
'http_errors' => false, // disable guzzle exception on 4xx or 5xx response code
]);
// create request according to our needs. we could add
// custom logic such as auth flow, caching mechanism, etc
$resp = $client->request($request->method(), $path, [
'headers' => filterHeaders($request->header()),
'query' => $request->query(),
'body' => $request->getContent(),
]);
// recreate response object to be passed to actual caller
// according to our needs.
return response($resp->getBody()->getContents(), $resp->getStatusCode())
->withHeaders(filterHeaders($resp->getHeaders()));
})->where('path', '.*'); // required to allow $path to catch all sub-path
Next
This implementation should cover 80-90% of most use case. However as stated in the beginning of this article, you may extend this code to include more functionalities. For example you could add authentication mechanism here, or some caching to reduce the number of network request.
Posted on February 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.