Skip to content

API Patterns

Rumen Damyanov edited this page Jul 31, 2025 · 1 revision

API Development Patterns

Complete guide for building RESTful APIs, GraphQL endpoints, and microservices with geographic data using php-geolocation.

Table of Contents

REST API Design

Geographic REST API Controller

<?php
// src/Api/GeolocationController.php
namespace Rumenx\Geolocation\Api;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Rumenx\Geolocation\Geolocation;
use Rumenx\Geolocation\Cache\CacheManager;
use Psr\Log\LoggerInterface;

class GeolocationController
{
    private Geolocation $geo;
    private CacheManager $cache;
    private LoggerInterface $logger;
    private array $config;

    public function __construct(
        Geolocation $geo,
        CacheManager $cache,
        LoggerInterface $logger,
        array $config = []
    ) {
        $this->geo = $geo;
        $this->cache = $cache;
        $this->logger = $logger;
        $this->config = array_merge($this->getDefaultConfig(), $config);
    }

    private function getDefaultConfig(): array
    {
        return [
            'rate_limit' => [
                'requests_per_minute' => 60,
                'requests_per_hour' => 1000
            ],
            'cache_ttl' => 3600,
            'enable_logging' => true,
            'allowed_origins' => ['*'],
            'api_version' => 'v1'
        ];
    }

    /**
     * GET /api/v1/geolocation
     * Get geolocation data for current IP or specified IP
     */
    public function getCurrentLocation(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $queryParams = $request->getQueryParams();
            $ip = $queryParams['ip'] ?? $this->getClientIP($request);
            $includeDetails = filter_var($queryParams['include_details'] ?? false, FILTER_VALIDATE_BOOLEAN);

            // Rate limiting
            if (!$this->checkRateLimit($ip)) {
                return $this->createErrorResponse(429, 'Rate limit exceeded');
            }

            // Check cache first
            $cacheKey = "geolocation_api_{$ip}_" . ($includeDetails ? 'detailed' : 'basic');
            $cached = $this->cache->get($cacheKey);

            if ($cached !== null) {
                $this->logger->info('Geolocation API cache hit', ['ip' => $ip]);
                return $this->createSuccessResponse($cached);
            }

            // Get geolocation data
            $this->geo->setIP($ip);

            $data = [
                'ip' => $ip,
                'country' => $this->geo->getCountryCode(),
                'country_name' => $this->geo->getCountryName(),
                'language' => $this->geo->getLanguage(),
                'currency' => $this->geo->getCurrency(),
                'timezone' => $this->geo->getTimezone()
            ];

            if ($includeDetails) {
                $data = array_merge($data, [
                    'region' => $this->geo->getRegion(),
                    'city' => $this->geo->getCity(),
                    'coordinates' => [
                        'latitude' => $this->geo->getLatitude(),
                        'longitude' => $this->geo->getLongitude()
                    ],
                    'isp' => $this->geo->getISP(),
                    'organization' => $this->geo->getOrganization(),
                    'connection_type' => $this->geo->getConnectionType(),
                    'is_mobile' => $this->geo->isMobile(),
                    'is_proxy' => $this->geo->isProxy(),
                    'is_vpn' => $this->geo->isVPN(),
                    'threat_level' => $this->geo->getThreatLevel()
                ]);
            }

            // Add metadata
            $response = [
                'data' => $data,
                'metadata' => [
                    'timestamp' => time(),
                    'version' => $this->config['api_version'],
                    'cached' => false,
                    'processing_time' => $this->getProcessingTime()
                ]
            ];

            // Cache the response
            $this->cache->set($cacheKey, $response, $this->config['cache_ttl']);

            $this->logger->info('Geolocation API request processed', [
                'ip' => $ip,
                'country' => $data['country'],
                'include_details' => $includeDetails
            ]);

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Geolocation API error', [
                'error' => $e->getMessage(),
                'ip' => $ip ?? 'unknown'
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    /**
     * GET /api/v1/geolocation/batch
     * Get geolocation data for multiple IPs
     */
    public function getBatchLocations(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $body = json_decode($request->getBody()->getContents(), true);
            $ips = $body['ips'] ?? [];

            if (empty($ips) || !is_array($ips)) {
                return $this->createErrorResponse(400, 'Invalid or missing IPs array');
            }

            if (count($ips) > 100) {
                return $this->createErrorResponse(400, 'Maximum 100 IPs allowed per batch request');
            }

            $results = [];
            $errors = [];

            foreach ($ips as $ip) {
                try {
                    if (!filter_var($ip, FILTER_VALIDATE_IP)) {
                        $errors[] = "Invalid IP: {$ip}";
                        continue;
                    }

                    $this->geo->setIP($ip);

                    $results[$ip] = [
                        'country' => $this->geo->getCountryCode(),
                        'country_name' => $this->geo->getCountryName(),
                        'language' => $this->geo->getLanguage(),
                        'currency' => $this->geo->getCurrency(),
                        'timezone' => $this->geo->getTimezone()
                    ];

                } catch (\Exception $e) {
                    $errors[] = "Error processing IP {$ip}: " . $e->getMessage();
                }
            }

            $response = [
                'data' => $results,
                'errors' => $errors,
                'metadata' => [
                    'total_requested' => count($ips),
                    'successful' => count($results),
                    'failed' => count($errors),
                    'timestamp' => time(),
                    'version' => $this->config['api_version']
                ]
            ];

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Batch geolocation API error', [
                'error' => $e->getMessage()
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    /**
     * GET /api/v1/geolocation/countries
     * Get list of supported countries with details
     */
    public function getCountries(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $queryParams = $request->getQueryParams();
            $language = $queryParams['language'] ?? 'en';

            $cacheKey = "countries_list_{$language}";
            $cached = $this->cache->get($cacheKey);

            if ($cached !== null) {
                return $this->createSuccessResponse($cached);
            }

            $countries = $this->getCountriesData($language);

            $response = [
                'data' => $countries,
                'metadata' => [
                    'total_countries' => count($countries),
                    'language' => $language,
                    'timestamp' => time(),
                    'version' => $this->config['api_version']
                ]
            ];

            $this->cache->set($cacheKey, $response, 86400); // Cache for 24 hours

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Countries API error', [
                'error' => $e->getMessage()
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    /**
     * GET /api/v1/geolocation/currencies
     * Get currency information by country
     */
    public function getCurrencies(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $queryParams = $request->getQueryParams();
            $country = $queryParams['country'] ?? null;

            $cacheKey = $country ? "currency_{$country}" : 'currencies_all';
            $cached = $this->cache->get($cacheKey);

            if ($cached !== null) {
                return $this->createSuccessResponse($cached);
            }

            if ($country) {
                $currency = $this->getCurrencyForCountry($country);
                $data = $currency ? [$country => $currency] : [];
            } else {
                $data = $this->getAllCurrencies();
            }

            $response = [
                'data' => $data,
                'metadata' => [
                    'total_currencies' => count($data),
                    'country_filter' => $country,
                    'timestamp' => time(),
                    'version' => $this->config['api_version']
                ]
            ];

            $this->cache->set($cacheKey, $response, 86400);

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Currencies API error', [
                'error' => $e->getMessage()
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    /**
     * GET /api/v1/geolocation/languages
     * Get language information by country
     */
    public function getLanguages(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $queryParams = $request->getQueryParams();
            $country = $queryParams['country'] ?? null;

            $cacheKey = $country ? "languages_{$country}" : 'languages_all';
            $cached = $this->cache->get($cacheKey);

            if ($cached !== null) {
                return $this->createSuccessResponse($cached);
            }

            if ($country) {
                $languages = $this->getLanguagesForCountry($country);
                $data = [$country => $languages];
            } else {
                $data = $this->getAllLanguages();
            }

            $response = [
                'data' => $data,
                'metadata' => [
                    'country_filter' => $country,
                    'timestamp' => time(),
                    'version' => $this->config['api_version']
                ]
            ];

            $this->cache->set($cacheKey, $response, 86400);

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Languages API error', [
                'error' => $e->getMessage()
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    /**
     * GET /api/v1/geolocation/timezones
     * Get timezone information by country
     */
    public function getTimezones(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $queryParams = $request->getQueryParams();
            $country = $queryParams['country'] ?? null;

            $cacheKey = $country ? "timezones_{$country}" : 'timezones_all';
            $cached = $this->cache->get($cacheKey);

            if ($cached !== null) {
                return $this->createSuccessResponse($cached);
            }

            if ($country) {
                $timezones = $this->getTimezonesForCountry($country);
                $data = [$country => $timezones];
            } else {
                $data = $this->getAllTimezones();
            }

            $response = [
                'data' => $data,
                'metadata' => [
                    'country_filter' => $country,
                    'timestamp' => time(),
                    'version' => $this->config['api_version']
                ]
            ];

            $this->cache->set($cacheKey, $response, 86400);

            return $this->createSuccessResponse($response);

        } catch (\Exception $e) {
            $this->logger->error('Timezones API error', [
                'error' => $e->getMessage()
            ]);

            return $this->createErrorResponse(500, 'Internal server error');
        }
    }

    private function createSuccessResponse(array $data, int $status = 200): ResponseInterface
    {
        $response = new \Nyholm\Psr7\Response($status);
        $response = $response->withHeader('Content-Type', 'application/json');
        $response = $response->withHeader('Access-Control-Allow-Origin', implode(',', $this->config['allowed_origins']));
        $response->getBody()->write(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

        return $response;
    }

    private function createErrorResponse(int $status, string $message, array $details = []): ResponseInterface
    {
        $error = [
            'error' => [
                'code' => $status,
                'message' => $message,
                'details' => $details,
                'timestamp' => time(),
                'version' => $this->config['api_version']
            ]
        ];

        $response = new \Nyholm\Psr7\Response($status);
        $response = $response->withHeader('Content-Type', 'application/json');
        $response = $response->withHeader('Access-Control-Allow-Origin', implode(',', $this->config['allowed_origins']));
        $response->getBody()->write(json_encode($error, JSON_PRETTY_PRINT));

        return $response;
    }

    private function getClientIP(ServerRequestInterface $request): string
    {
        $serverParams = $request->getServerParams();
        $headers = $request->getHeaders();

        // Check various headers for the real IP
        $ipHeaders = [
            'HTTP_CF_CONNECTING_IP',
            'HTTP_X_FORWARDED_FOR',
            'HTTP_X_REAL_IP',
            'HTTP_CLIENT_IP'
        ];

        foreach ($ipHeaders as $header) {
            if (!empty($serverParams[$header])) {
                $ip = trim(explode(',', $serverParams[$header])[0]);
                if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                    return $ip;
                }
            }
        }

        return $serverParams['REMOTE_ADDR'] ?? '127.0.0.1';
    }

    private function checkRateLimit(string $ip): bool
    {
        $minuteKey = "rate_limit_minute_{$ip}_" . floor(time() / 60);
        $hourKey = "rate_limit_hour_{$ip}_" . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        if ($minuteCount >= $this->config['rate_limit']['requests_per_minute']) {
            return false;
        }

        if ($hourCount >= $this->config['rate_limit']['requests_per_hour']) {
            return false;
        }

        $this->cache->set($minuteKey, $minuteCount + 1, 60);
        $this->cache->set($hourKey, $hourCount + 1, 3600);

        return true;
    }

    private function getProcessingTime(): float
    {
        return round((microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1000, 2);
    }

    private function getCountriesData(string $language): array
    {
        // This would typically come from a database or external service
        return [
            'US' => [
                'code' => 'US',
                'name' => $language === 'es' ? 'Estados Unidos' : 'United States',
                'currency' => 'USD',
                'languages' => ['en', 'es'],
                'timezones' => ['America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles']
            ],
            'CA' => [
                'code' => 'CA',
                'name' => $language === 'fr' ? 'Canada' : 'Canada',
                'currency' => 'CAD',
                'languages' => ['en', 'fr'],
                'timezones' => ['America/St_Johns', 'America/Halifax', 'America/Toronto', 'America/Winnipeg', 'America/Edmonton', 'America/Vancouver']
            ],
            'GB' => [
                'code' => 'GB',
                'name' => 'United Kingdom',
                'currency' => 'GBP',
                'languages' => ['en'],
                'timezones' => ['Europe/London']
            ],
            'DE' => [
                'code' => 'DE',
                'name' => $language === 'de' ? 'Deutschland' : 'Germany',
                'currency' => 'EUR',
                'languages' => ['de'],
                'timezones' => ['Europe/Berlin']
            ],
            'FR' => [
                'code' => 'FR',
                'name' => $language === 'fr' ? 'France' : 'France',
                'currency' => 'EUR',
                'languages' => ['fr'],
                'timezones' => ['Europe/Paris']
            ]
        ];
    }

    private function getCurrencyForCountry(string $country): ?array
    {
        $currencies = [
            'US' => ['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$'],
            'CA' => ['code' => 'CAD', 'name' => 'Canadian Dollar', 'symbol' => 'C$'],
            'GB' => ['code' => 'GBP', 'name' => 'British Pound', 'symbol' => '£'],
            'DE' => ['code' => 'EUR', 'name' => 'Euro', 'symbol' => ''],
            'FR' => ['code' => 'EUR', 'name' => 'Euro', 'symbol' => '']
        ];

        return $currencies[$country] ?? null;
    }

    private function getAllCurrencies(): array
    {
        return [
            'USD' => ['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$', 'countries' => ['US']],
            'CAD' => ['code' => 'CAD', 'name' => 'Canadian Dollar', 'symbol' => 'C$', 'countries' => ['CA']],
            'GBP' => ['code' => 'GBP', 'name' => 'British Pound', 'symbol' => '£', 'countries' => ['GB']],
            'EUR' => ['code' => 'EUR', 'name' => 'Euro', 'symbol' => '', 'countries' => ['DE', 'FR', 'ES', 'IT']]
        ];
    }

    private function getLanguagesForCountry(string $country): array
    {
        $languages = [
            'US' => [
                ['code' => 'en', 'name' => 'English', 'native' => 'English', 'primary' => true],
                ['code' => 'es', 'name' => 'Spanish', 'native' => 'Español', 'primary' => false]
            ],
            'CA' => [
                ['code' => 'en', 'name' => 'English', 'native' => 'English', 'primary' => true],
                ['code' => 'fr', 'name' => 'French', 'native' => 'Français', 'primary' => true]
            ],
            'GB' => [
                ['code' => 'en', 'name' => 'English', 'native' => 'English', 'primary' => true]
            ],
            'DE' => [
                ['code' => 'de', 'name' => 'German', 'native' => 'Deutsch', 'primary' => true]
            ],
            'FR' => [
                ['code' => 'fr', 'name' => 'French', 'native' => 'Français', 'primary' => true]
            ]
        ];

        return $languages[$country] ?? [];
    }

    private function getAllLanguages(): array
    {
        return [
            'en' => ['code' => 'en', 'name' => 'English', 'native' => 'English', 'countries' => ['US', 'CA', 'GB', 'AU']],
            'es' => ['code' => 'es', 'name' => 'Spanish', 'native' => 'Español', 'countries' => ['ES', 'MX', 'AR', 'CO']],
            'fr' => ['code' => 'fr', 'name' => 'French', 'native' => 'Français', 'countries' => ['FR', 'CA', 'BE', 'CH']],
            'de' => ['code' => 'de', 'name' => 'German', 'native' => 'Deutsch', 'countries' => ['DE', 'AT', 'CH']]
        ];
    }

    private function getTimezonesForCountry(string $country): array
    {
        $timezones = [
            'US' => [
                'America/New_York' => ['name' => 'Eastern Time', 'offset' => '-05:00'],
                'America/Chicago' => ['name' => 'Central Time', 'offset' => '-06:00'],
                'America/Denver' => ['name' => 'Mountain Time', 'offset' => '-07:00'],
                'America/Los_Angeles' => ['name' => 'Pacific Time', 'offset' => '-08:00']
            ],
            'CA' => [
                'America/St_Johns' => ['name' => 'Newfoundland Time', 'offset' => '-03:30'],
                'America/Halifax' => ['name' => 'Atlantic Time', 'offset' => '-04:00'],
                'America/Toronto' => ['name' => 'Eastern Time', 'offset' => '-05:00'],
                'America/Winnipeg' => ['name' => 'Central Time', 'offset' => '-06:00'],
                'America/Edmonton' => ['name' => 'Mountain Time', 'offset' => '-07:00'],
                'America/Vancouver' => ['name' => 'Pacific Time', 'offset' => '-08:00']
            ],
            'GB' => [
                'Europe/London' => ['name' => 'Greenwich Mean Time', 'offset' => '+00:00']
            ],
            'DE' => [
                'Europe/Berlin' => ['name' => 'Central European Time', 'offset' => '+01:00']
            ],
            'FR' => [
                'Europe/Paris' => ['name' => 'Central European Time', 'offset' => '+01:00']
            ]
        ];

        return $timezones[$country] ?? [];
    }

    private function getAllTimezones(): array
    {
        $allTimezones = [];
        $countries = ['US', 'CA', 'GB', 'DE', 'FR'];

        foreach ($countries as $country) {
            $countryTimezones = $this->getTimezonesForCountry($country);
            foreach ($countryTimezones as $tz => $info) {
                $allTimezones[$tz] = array_merge($info, ['countries' => [$country]]);
            }
        }

        return $allTimezones;
    }
}

GraphQL Implementation

GraphQL Schema and Resolvers

<?php
// src/GraphQL/GeolocationSchema.php
namespace Rumenx\Geolocation\GraphQL;

use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Schema;
use Rumenx\Geolocation\Geolocation;

class GeolocationSchema
{
    private Geolocation $geo;

    public function __construct(Geolocation $geo)
    {
        $this->geo = $geo;
    }

    public function build(): Schema
    {
        return new Schema([
            'query' => $this->buildQueryType(),
            'mutation' => $this->buildMutationType()
        ]);
    }

    private function buildQueryType(): ObjectType
    {
        return new ObjectType([
            'name' => 'Query',
            'fields' => [
                'geolocation' => [
                    'type' => $this->buildGeolocationType(),
                    'args' => [
                        'ip' => ['type' => Type::string()]
                    ],
                    'resolve' => function($root, $args) {
                        $ip = $args['ip'] ?? $this->getCurrentIP();
                        $this->geo->setIP($ip);

                        return [
                            'ip' => $ip,
                            'country' => $this->geo->getCountryCode(),
                            'countryName' => $this->geo->getCountryName(),
                            'region' => $this->geo->getRegion(),
                            'city' => $this->geo->getCity(),
                            'language' => $this->geo->getLanguage(),
                            'currency' => $this->geo->getCurrency(),
                            'timezone' => $this->geo->getTimezone(),
                            'coordinates' => [
                                'latitude' => $this->geo->getLatitude(),
                                'longitude' => $this->geo->getLongitude()
                            ]
                        ];
                    }
                ],
                'countries' => [
                    'type' => Type::listOf($this->buildCountryType()),
                    'args' => [
                        'language' => ['type' => Type::string(), 'defaultValue' => 'en']
                    ],
                    'resolve' => function($root, $args) {
                        return $this->getCountriesData($args['language']);
                    }
                ],
                'batchGeolocation' => [
                    'type' => Type::listOf($this->buildGeolocationType()),
                    'args' => [
                        'ips' => ['type' => Type::listOf(Type::string())]
                    ],
                    'resolve' => function($root, $args) {
                        $results = [];
                        foreach ($args['ips'] as $ip) {
                            try {
                                $this->geo->setIP($ip);
                                $results[] = [
                                    'ip' => $ip,
                                    'country' => $this->geo->getCountryCode(),
                                    'countryName' => $this->geo->getCountryName(),
                                    'language' => $this->geo->getLanguage(),
                                    'currency' => $this->geo->getCurrency()
                                ];
                            } catch (\Exception $e) {
                                $results[] = [
                                    'ip' => $ip,
                                    'error' => $e->getMessage()
                                ];
                            }
                        }
                        return $results;
                    }
                ]
            ]
        ]);
    }

    private function buildMutationType(): ObjectType
    {
        return new ObjectType([
            'name' => 'Mutation',
            'fields' => [
                'updateGeolocationCache' => [
                    'type' => Type::boolean(),
                    'args' => [
                        'ip' => ['type' => Type::nonNull(Type::string())],
                        'country' => ['type' => Type::nonNull(Type::string())]
                    ],
                    'resolve' => function($root, $args) {
                        // Implementation for cache updates
                        return true;
                    }
                ]
            ]
        ]);
    }

    private function buildGeolocationType(): ObjectType
    {
        return new ObjectType([
            'name' => 'Geolocation',
            'fields' => [
                'ip' => ['type' => Type::string()],
                'country' => ['type' => Type::string()],
                'countryName' => ['type' => Type::string()],
                'region' => ['type' => Type::string()],
                'city' => ['type' => Type::string()],
                'language' => ['type' => Type::string()],
                'currency' => ['type' => Type::string()],
                'timezone' => ['type' => Type::string()],
                'coordinates' => ['type' => $this->buildCoordinatesType()],
                'error' => ['type' => Type::string()]
            ]
        ]);
    }

    private function buildCoordinatesType(): ObjectType
    {
        return new ObjectType([
            'name' => 'Coordinates',
            'fields' => [
                'latitude' => ['type' => Type::float()],
                'longitude' => ['type' => Type::float()]
            ]
        ]);
    }

    private function buildCountryType(): ObjectType
    {
        return new ObjectType([
            'name' => 'Country',
            'fields' => [
                'code' => ['type' => Type::string()],
                'name' => ['type' => Type::string()],
                'currency' => ['type' => Type::string()],
                'languages' => ['type' => Type::listOf(Type::string())],
                'timezones' => ['type' => Type::listOf(Type::string())]
            ]
        ]);
    }

    private function getCurrentIP(): string
    {
        return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
    }

    private function getCountriesData(string $language): array
    {
        // Return countries data - same as REST API
        return [
            ['code' => 'US', 'name' => 'United States', 'currency' => 'USD'],
            ['code' => 'CA', 'name' => 'Canada', 'currency' => 'CAD'],
            ['code' => 'GB', 'name' => 'United Kingdom', 'currency' => 'GBP']
        ];
    }
}

// Example GraphQL endpoint
// src/GraphQL/GraphQLEndpoint.php
class GraphQLEndpoint
{
    private GeolocationSchema $schema;

    public function __construct(GeolocationSchema $schema)
    {
        $this->schema = $schema;
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        try {
            $input = json_decode($request->getBody()->getContents(), true);
            $query = $input['query'] ?? '';
            $variables = $input['variables'] ?? [];

            $result = \GraphQL\GraphQL::executeQuery(
                $this->schema->build(),
                $query,
                null,
                null,
                $variables
            );

            $response = new \Nyholm\Psr7\Response(200);
            $response = $response->withHeader('Content-Type', 'application/json');
            $response->getBody()->write(json_encode($result->toArray()));

            return $response;

        } catch (\Exception $e) {
            $error = ['errors' => [['message' => $e->getMessage()]]];

            $response = new \Nyholm\Psr7\Response(500);
            $response = $response->withHeader('Content-Type', 'application/json');
            $response->getBody()->write(json_encode($error));

            return $response;
        }
    }
}

API Authentication

JWT Authentication Middleware

<?php
// src/Api/AuthenticationMiddleware.php
namespace Rumenx\Geolocation\Api;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class AuthenticationMiddleware implements MiddlewareInterface
{
    private array $config;
    private array $publicRoutes;

    public function __construct(array $config, array $publicRoutes = [])
    {
        $this->config = $config;
        $this->publicRoutes = $publicRoutes;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $path = $request->getUri()->getPath();

        // Check if route is public
        if ($this->isPublicRoute($path)) {
            return $handler->handle($request);
        }

        // Check for API key or JWT token
        $authResult = $this->authenticate($request);

        if (!$authResult['success']) {
            return $this->createUnauthorizedResponse($authResult['message']);
        }

        // Add user context to request
        $request = $request->withAttribute('user', $authResult['user']);
        $request = $request->withAttribute('api_limits', $authResult['limits']);

        return $handler->handle($request);
    }

    private function authenticate(ServerRequestInterface $request): array
    {
        // Try API key authentication first
        $apiKey = $this->getApiKey($request);
        if ($apiKey) {
            return $this->authenticateApiKey($apiKey);
        }

        // Try JWT authentication
        $token = $this->getJwtToken($request);
        if ($token) {
            return $this->authenticateJwt($token);
        }

        return ['success' => false, 'message' => 'No authentication provided'];
    }

    private function getApiKey(ServerRequestInterface $request): ?string
    {
        $headers = $request->getHeaders();

        // Check X-API-Key header
        if (isset($headers['X-API-Key'][0])) {
            return $headers['X-API-Key'][0];
        }

        // Check query parameter
        $queryParams = $request->getQueryParams();
        return $queryParams['api_key'] ?? null;
    }

    private function getJwtToken(ServerRequestInterface $request): ?string
    {
        $headers = $request->getHeaders();

        if (isset($headers['Authorization'][0])) {
            $authHeader = $headers['Authorization'][0];
            if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
                return $matches[1];
            }
        }

        return null;
    }

    private function authenticateApiKey(string $apiKey): array
    {
        // In a real implementation, this would check against a database
        $validKeys = [
            'test_key_123' => [
                'user_id' => 'user_1',
                'name' => 'Test User',
                'limits' => [
                    'requests_per_minute' => 100,
                    'requests_per_hour' => 2000,
                    'features' => ['basic', 'batch']
                ]
            ],
            'premium_key_456' => [
                'user_id' => 'user_2',
                'name' => 'Premium User',
                'limits' => [
                    'requests_per_minute' => 500,
                    'requests_per_hour' => 10000,
                    'features' => ['basic', 'batch', 'detailed', 'analytics']
                ]
            ]
        ];

        if (isset($validKeys[$apiKey])) {
            return [
                'success' => true,
                'user' => $validKeys[$apiKey],
                'limits' => $validKeys[$apiKey]['limits']
            ];
        }

        return ['success' => false, 'message' => 'Invalid API key'];
    }

    private function authenticateJwt(string $token): array
    {
        try {
            $decoded = JWT::decode($token, new Key($this->config['jwt_secret'], 'HS256'));

            // Verify token claims
            if ($decoded->exp < time()) {
                return ['success' => false, 'message' => 'Token expired'];
            }

            return [
                'success' => true,
                'user' => [
                    'user_id' => $decoded->sub,
                    'name' => $decoded->name ?? 'Unknown',
                    'email' => $decoded->email ?? null
                ],
                'limits' => $decoded->limits ?? [
                    'requests_per_minute' => 60,
                    'requests_per_hour' => 1000,
                    'features' => ['basic']
                ]
            ];

        } catch (\Exception $e) {
            return ['success' => false, 'message' => 'Invalid token: ' . $e->getMessage()];
        }
    }

    private function isPublicRoute(string $path): bool
    {
        foreach ($this->publicRoutes as $route) {
            if (fnmatch($route, $path)) {
                return true;
            }
        }
        return false;
    }

    private function createUnauthorizedResponse(string $message): ResponseInterface
    {
        $error = [
            'error' => [
                'code' => 401,
                'message' => $message,
                'timestamp' => time()
            ]
        ];

        $response = new \Nyholm\Psr7\Response(401);
        $response = $response->withHeader('Content-Type', 'application/json');
        $response->getBody()->write(json_encode($error));

        return $response;
    }
}

// JWT Token Generator
class JwtTokenGenerator
{
    private string $secret;
    private string $issuer;

    public function __construct(string $secret, string $issuer)
    {
        $this->secret = $secret;
        $this->issuer = $issuer;
    }

    public function generateToken(array $user, int $expiresIn = 3600): string
    {
        $now = time();

        $payload = [
            'iss' => $this->issuer,
            'sub' => $user['user_id'],
            'name' => $user['name'],
            'email' => $user['email'] ?? null,
            'iat' => $now,
            'exp' => $now + $expiresIn,
            'limits' => $user['limits'] ?? [
                'requests_per_minute' => 60,
                'requests_per_hour' => 1000,
                'features' => ['basic']
            ]
        ];

        return JWT::encode($payload, $this->secret, 'HS256');
    }
}

Rate Limiting

Advanced Rate Limiting System

<?php
// src/Api/RateLimitMiddleware.php
namespace Rumenx\Geolocation\Api;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Rumenx\Geolocation\Cache\CacheManager;

class RateLimitMiddleware implements MiddlewareInterface
{
    private CacheManager $cache;
    private array $config;

    public function __construct(CacheManager $cache, array $config = [])
    {
        $this->cache = $cache;
        $this->config = array_merge($this->getDefaultConfig(), $config);
    }

    private function getDefaultConfig(): array
    {
        return [
            'global' => [
                'requests_per_minute' => 1000,
                'requests_per_hour' => 10000
            ],
            'per_ip' => [
                'requests_per_minute' => 60,
                'requests_per_hour' => 1000
            ],
            'per_user' => [
                'requests_per_minute' => 100,
                'requests_per_hour' => 2000
            ],
            'endpoints' => [
                '/api/v1/geolocation/batch' => [
                    'requests_per_minute' => 10,
                    'requests_per_hour' => 100
                ]
            ]
        ];
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $ip = $this->getClientIP($request);
        $path = $request->getUri()->getPath();
        $user = $request->getAttribute('user');

        // Check global rate limits
        if (!$this->checkGlobalLimits()) {
            return $this->createRateLimitResponse('Global rate limit exceeded');
        }

        // Check IP-based rate limits
        if (!$this->checkIPLimits($ip)) {
            return $this->createRateLimitResponse('IP rate limit exceeded');
        }

        // Check user-based rate limits (if authenticated)
        if ($user && !$this->checkUserLimits($user)) {
            return $this->createRateLimitResponse('User rate limit exceeded');
        }

        // Check endpoint-specific rate limits
        if (!$this->checkEndpointLimits($ip, $path, $user)) {
            return $this->createRateLimitResponse('Endpoint rate limit exceeded');
        }

        // Add rate limit headers to response
        $response = $handler->handle($request);
        return $this->addRateLimitHeaders($response, $ip, $user);
    }

    private function checkGlobalLimits(): bool
    {
        $minuteKey = 'global_rate_limit_minute_' . floor(time() / 60);
        $hourKey = 'global_rate_limit_hour_' . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        if ($minuteCount >= $this->config['global']['requests_per_minute']) {
            return false;
        }

        if ($hourCount >= $this->config['global']['requests_per_hour']) {
            return false;
        }

        $this->cache->set($minuteKey, $minuteCount + 1, 60);
        $this->cache->set($hourKey, $hourCount + 1, 3600);

        return true;
    }

    private function checkIPLimits(string $ip): bool
    {
        $minuteKey = "ip_rate_limit_minute_{$ip}_" . floor(time() / 60);
        $hourKey = "ip_rate_limit_hour_{$ip}_" . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        if ($minuteCount >= $this->config['per_ip']['requests_per_minute']) {
            return false;
        }

        if ($hourCount >= $this->config['per_ip']['requests_per_hour']) {
            return false;
        }

        $this->cache->set($minuteKey, $minuteCount + 1, 60);
        $this->cache->set($hourKey, $hourCount + 1, 3600);

        return true;
    }

    private function checkUserLimits(array $user): bool
    {
        $userId = $user['user_id'];
        $limits = $user['limits'] ?? $this->config['per_user'];

        $minuteKey = "user_rate_limit_minute_{$userId}_" . floor(time() / 60);
        $hourKey = "user_rate_limit_hour_{$userId}_" . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        if ($minuteCount >= $limits['requests_per_minute']) {
            return false;
        }

        if ($hourCount >= $limits['requests_per_hour']) {
            return false;
        }

        $this->cache->set($minuteKey, $minuteCount + 1, 60);
        $this->cache->set($hourKey, $hourCount + 1, 3600);

        return true;
    }

    private function checkEndpointLimits(string $ip, string $path, ?array $user): bool
    {
        if (!isset($this->config['endpoints'][$path])) {
            return true;
        }

        $limits = $this->config['endpoints'][$path];
        $identifier = $user ? $user['user_id'] : $ip;

        $minuteKey = "endpoint_rate_limit_minute_{$path}_{$identifier}_" . floor(time() / 60);
        $hourKey = "endpoint_rate_limit_hour_{$path}_{$identifier}_" . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        if ($minuteCount >= $limits['requests_per_minute']) {
            return false;
        }

        if ($hourCount >= $limits['requests_per_hour']) {
            return false;
        }

        $this->cache->set($minuteKey, $minuteCount + 1, 60);
        $this->cache->set($hourKey, $hourCount + 1, 3600);

        return true;
    }

    private function addRateLimitHeaders(ResponseInterface $response, string $ip, ?array $user): ResponseInterface
    {
        $identifier = $user ? $user['user_id'] : $ip;
        $limits = $user['limits'] ?? $this->config['per_ip'];

        $minuteKey = ($user ? "user" : "ip") . "_rate_limit_minute_{$identifier}_" . floor(time() / 60);
        $hourKey = ($user ? "user" : "ip") . "_rate_limit_hour_{$identifier}_" . floor(time() / 3600);

        $minuteCount = (int) $this->cache->get($minuteKey, 0);
        $hourCount = (int) $this->cache->get($hourKey, 0);

        $response = $response->withHeader('X-RateLimit-Limit-Minute', (string) $limits['requests_per_minute']);
        $response = $response->withHeader('X-RateLimit-Remaining-Minute', (string) max(0, $limits['requests_per_minute'] - $minuteCount));
        $response = $response->withHeader('X-RateLimit-Reset-Minute', (string) ((floor(time() / 60) + 1) * 60));

        $response = $response->withHeader('X-RateLimit-Limit-Hour', (string) $limits['requests_per_hour']);
        $response = $response->withHeader('X-RateLimit-Remaining-Hour', (string) max(0, $limits['requests_per_hour'] - $hourCount));
        $response = $response->withHeader('X-RateLimit-Reset-Hour', (string) ((floor(time() / 3600) + 1) * 3600));

        return $response;
    }

    private function getClientIP(ServerRequestInterface $request): string
    {
        $serverParams = $request->getServerParams();

        $ipHeaders = [
            'HTTP_CF_CONNECTING_IP',
            'HTTP_X_FORWARDED_FOR',
            'HTTP_X_REAL_IP',
            'HTTP_CLIENT_IP'
        ];

        foreach ($ipHeaders as $header) {
            if (!empty($serverParams[$header])) {
                $ip = trim(explode(',', $serverParams[$header])[0]);
                if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                    return $ip;
                }
            }
        }

        return $serverParams['REMOTE_ADDR'] ?? '127.0.0.1';
    }

    private function createRateLimitResponse(string $message): ResponseInterface
    {
        $error = [
            'error' => [
                'code' => 429,
                'message' => $message,
                'timestamp' => time(),
                'retry_after' => 60
            ]
        ];

        $response = new \Nyholm\Psr7\Response(429);
        $response = $response->withHeader('Content-Type', 'application/json');
        $response = $response->withHeader('Retry-After', '60');
        $response->getBody()->write(json_encode($error));

        return $response;
    }
}

Next Steps


Previous: Analytics Integration | Next: Configuration Reference

Clone this wiki locally