A simple API Gateway in Lumen

如何使用 Lumen 开发一个简单的 API Gateway

Routes

create a file called gateway.php in config folder.

<?php return (function () {

    $cmsModuleName = env('APP_CMS_MODULE_NAME');
    $userModuleName = env('APP_USER_MODULE_NAME');

    $routes = [
        'cms_get_slider' => [
            'method' => 'GET',
            'name' => 'Get Slider',
            'path' => '/cms/v1/slider',
            'dist' => $cmsModuleName . '/v1/slider',
            'middleware' => 'auth'
        ],
        'cms_get_pages' => [
            'method' => 'GET',
            'name' => 'Get Pages',
            'path' => '/cms/v1/pages',
            'dist' => $cmsModuleName . '/v1/pages',
            'middleware' => 'auth'
        ],
        'cms_users_pages' => [
            'method' => 'GET',
            'name' => 'Get Pages',
            'path' => '/cms/v1/pages',
            'dist' => $userModuleName . '/v1/users/{id:[0-9]+}/pages/{status}',
            'middleware' => 'auth'
        ],
        'acs_get_roles' => [
            'method' => 'GET',
            'name' => 'acs_get_roles',
            'path' => '/acs/v1/roles',
            'dist' => $userModuleName . '/acs/v1/roles',
            'middleware' => 'role:administrator,super-admin'
        ],

    ];

    return [
        'routes' => $routes,
    ];
})();

Register gateway to lumen routes.

Edit AppServiceProvider.php in app/Providers/ folder.

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
    }

    public function boot()
    {
        $this->registerRoutes();
    }

    /**
     * Register Routes
     *
     * @throws
     * @return void
     */
    protected function registerRoutes()
    {
        $routes = config('gateway');
        if ($routes) {
            $router = app('router');
            $routeArgs = ['uses' => 'App\Http\Controllers\GatewayController@request'];
            foreach ($routes['routes'] as $key => $route) {
                $method = strtolower($route['method']);
                $routeArgs['as'] = $key;
                $route['middleware'] ? $routeArgs['middleware'] = [$route['middleware']] : $routeArgs['middleware'] = [];
                $router->{$method}($route['path'], $routeArgs);
            }
        }
    }
}

Controller

AppServiceProvider will send all request to GatewayController.

So create GatewayController.php in app/Http/Controllers to handle requests.

namespace App\Http\Controllers;

use GuzzleHttp\Exception\BadResponseException;
use Illuminate\Http\Request;
use GuzzleHttp\Client as HttpClient;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;

class GatewayController extends Controller
{
    private $request;
    private $gateway;
    private $method;
    private $httpClient;
    private $currentRoute;
    private $currentUserId = 0;
    private $currentUserRoles = [];

    public function __construct(Request $request)
    {
        $this->request = $request;
        $this->setCurrentUserId();
        $this->setCurrentUserRoles();

        $this->gateway = config('gateway');
        $this->method = $request->getMethod();
        $this->pathInfo = $request->getPathInfo();
        $this->httpClient = new HttpClient(['headers' => $this->headers()]);
        $this->requestRoute = $this->request->route();
        $this->currentRoute = $this->gateway['routes'][$this->getCurrentRouteName()];
    }

    public function setCurrentUserId()
    {
        if (isset($this->request->currentUserId)) {
            $this->currentUserId = $this->request->currentUserId;
        }
    }

    public function setCurrentUserRoles()
    {
        if (isset($this->request->roles)) {
            $this->currentUserRoles = $this->request->roles;
        }
    }

    public function getCurrentRouteName()
    {
        $route = $this->requestRoute;
        if (isset($route[1]) &&  $route[1]["as"]) {
            return $route[1]["as"];
        }
        return "";
    }

    public function getCurrentRequestUserId()
    {
        $route = $this->requestRoute;
        if (isset($route[2]) &&  isset($route[2]["user_id"])) {
            $userId = $route[2]["user_id"];
        } else {
            $userId = $this->request->get('user_id', $this->request->get('sender_id'));
        }

        return (int) $userId;
    }

    public function getRequestUri()
    {
        $uri = $this->currentRoute['dist'];

        if (isset($this->requestRoute[2]) && count($this->requestRoute[2])) {
            foreach ($this->requestRoute[2] as $key => $value) {
                $uri = str_replace("{" . $key . "}", $value, $uri);
                $uri = preg_replace('/{' . $key . ':.+?}/', $value, $uri);
            }
        }
        return $uri;
    }

    /**
     * set guzzle HTTP headers
     *
     *  "headers" => array:10 [
     *      "Accept" => "application/json"
     *      "User-Agent" => "PostmanRuntime/7.24.1"
     *      "X-Forwarded-For" => "172.21.0.1"
     *      "X-User-Id" => 2
     *      "X-Roles" => "administrator"
     *      "X-Is-Admin" => "yes"
     *  ]
     *
     * @return void
     */
    public function headers()
    {
        if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
            $clientIps = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
            $clientIp = explode(':', $clientIps[0])[0];
        } else {
            $clientIp = $this->request->getClientIp();
        }

        return [
            'Accept' => 'application/json',
            'User-Agent' => $this->request->header('User-Agent'),
            'X-Forwarded-For' => $clientIp,
            'X-User-Id' => $this->currentUserId, // add current user id to guzzle header
            'X-Roles' => join(',', $this->currentUserRoles),
            'X-Is-Admin' => $this->isAdmin()
        ];
    }

    public function isAdmin()
    {
        return in_array('administrator', $this->currentUserRoles) || in_array('super-admin', $this->currentUserRoles) ? 'yes' : 'no';
    }

    public function request()
    {
        switch ($this->method) {
            case "POST":
            case "PUT":
                $requestOption = "json";
                break;
            case "GET":
                $requestOption = "query";
                break;
            default:
                $requestOption = "query";
        }

        $contentType = $this->request->headers->get('content-type');
   
        // 如果请求里有附件
        if (false !== strpos($contentType, 'multipart/form-data')  && $this->request->hasFile('file')) {
            $file = $this->request->file;
            $fileName = $file->getClientOriginalName();

            $requestOption = 'multipart';
            $requestArgs = [
                [
                    'name'     => 'file',
                    'contents' => file_get_contents($file->getPathname(), 'r'),
                    'filename' => $fileName
                ],
                [
                    'name'     => 'prefix',
                    'contents' => $this->request->input('prefix', '')
                ],
                [
                    'name'     => 'disk',
                    'contents' => $this->request->input('disk', '')
                ]
            ];
        } else {
            $requestArgs =  $this->request->all();
        }

        try {
            $response = $this->httpClient->request(
                $this->method,
                $this->getRequestUri(),
                [
                    $requestOption => $requestArgs
                ]
            );
        } catch (BadResponseException $e) {
            $response = $e->getResponse();
            Log::error('Bad response exception', [
                'request' => [
                    'method' => $this->method,
                    'uri' => $this->getRequestUri(),
                ],
                'response' => [
                    'headers' => $response->getHeaders(),
                    'body' => $response->getBody()->getContents(),
                ],
            ]);
            return response($response->getBody(), $response->getStatusCode(), $response->getHeaders());
        } catch (\Exception $e) {
            Log::error($e->getMessage());
            $response = response()->json(['code' => $e->getCode(), 'message' => $e->getMessage()], 500);
            return $response;
        }

        return response($response->getBody(), $response->getStatusCode(), $response->getHeaders());
    }
}

Middleware

Update Authenticate middleware to handle Auth.

Update Authenticate.php in app/Http/Middleware folder.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\JsonResponse;
use Firebase\JWT\JWT;
use Firebase\JWT\ExpiredException;
use Exception;
use Illuminate\Support\Facades\Log;

class Authenticate
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string|null  $guard
     * @return mixed
     */
    public function handle($request, Closure $next, $guard = null)
    {
        $token = $request->bearerToken();
        $errorCode = null;
        $errorMessage = null;
        try {
            // decode and check Token
            $credentials = JWT::decode($token, env('JWT_SECRET'), ['HS256']);
            if ($credentials && $credentials->iss == 'you_application_name' && $credentials->data->user->id) {
                $request->currentUserId = $credentials->data->user->id;
                // 生产TOKEN时,需要将用户角色保存到TOKEN里
                $request->roles = $credentials->data->user->roles;
                if (env('ENVIRONMENT') === 'local') {
                    return $next($request);
                }
                if (!$this->isValidToken($credentials->data->user->id, $token)) {
                    $errorCode = 'token_abandoned';
                    $errorMessage = 'The token is abandoned. Please get a new token.';
                } else {
                    return $next($request);
                }
            }
        } catch (ExpiredException $e) {
            Log::warning('token_expired');
            Log::warning($e->getMessage());
            $errorCode = 'token_expired';
            $errorMessage = 'Provided token is expired.';
        } catch (Exception $e) {
            Log::warning('decode_token_failed');
            Log::warning($e->getMessage());
            $errorCode = 'decode_token_failed';
            $errorMessage = 'An error while decoding the token.';
        }

        // unauthorized
        return response()->json(
            [
                'code' => $errorCode,
                'message' => $errorMessage,
                'data' => [
                    'status' => JsonResponse::HTTP_UNAUTHORIZED,
                ]
            ],
            JsonResponse::HTTP_UNAUTHORIZED
        );
    }

    /**
     * 如果需要限制登录数量。
     * 可以在登录的时候,将TOKEN更新到REDIS作为可用TOKEN
     * 再次请求的时候,确认TOKEN是否是可用的TOKEN
     *
     * @param integer $userId
     * @param string $token
     * @return boolean
     */
    public function isValidToken(int $userId, string $token): bool
    {
        $redis = \Illuminate\Support\Facades\Redis::connection('auth');
        $userToken = $redis->get('auth:' . $userId . ':token', '');
        if (is_array($userToken)) {
            return in_array($token, $userToken);
        }
        return $userToken === $token;
    }
}

或者,使用角色认证 role middleware.

Create Role.php in app/Http/Middleware folder.

<?php

namespace App\Http\Middleware;


use Closure;
use Illuminate\Http\JsonResponse;
use Firebase\JWT\JWT;
use Firebase\JWT\ExpiredException;
use Exception;
use Illuminate\Support\Facades\Log;

class Role
{
    /**
     * Role MiddleWare handle function
     *
     * @param [type] $request
     * @param Closure $next
     * @param [type] ...$roles
     * @return void
     */
    public function handle($request, Closure $next, ...$roles)
    {
        $token = $request->bearerToken();
        $roles = is_array($roles) ? $roles : explode(',', $roles);
        $errorCode = null;
        $errorMessage = null;

        try {
            // decode and check Token
            $credentials = JWT::decode($token, env('JWT_SECRET'), ['HS256']);
            if ($credentials && $credentials->iss == 'your_application_name' && $credentials->data->user->roles) {
                // check if the middleware roles and user role have at least 1 match
                $rolesMatches = count(array_intersect($roles, $credentials->data->user->roles));
                if ($rolesMatches) {
                    $request->currentUserId = $credentials->data->user->id;
                    $request->roles = $credentials->data->user->roles;
                    return $next($request);
                }
            }

            // unauthorized
            $errorCode = 'not_authorized';
            $errorMessage = 'You are not authorized to access this route';
        } catch (ExpiredException $e) {
            Log::warning('token_expired');
            Log::warning($e->getMessage());
            $errorCode = 'token_expired';
            $errorMessage = 'Provided token is expired.';
        } catch (Exception $e) {
            Log::warning('decode_token_failed');
            Log::warning($e->getMessage());
            $errorCode = 'decode_token_failed';
            $errorMessage = 'An error while decoding the token.';
        }

        // unauthorized
        return response()->json(
            [
                'code' => $errorCode,
                'message' => $errorMessage,
                'data' => [
                    'status' => JsonResponse::HTTP_UNAUTHORIZED,
                ]
            ],
            JsonResponse::HTTP_UNAUTHORIZED
        );
    }
}