From 3fe8ad1ea4dcc263f47fbe59fd9410e6e9fc6f81 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 7 Dec 2024 13:22:21 +0100 Subject: [PATCH 01/10] First endpoint fixes --- appinfo/routes.php | 1 + lib/Controller/EndpointController.php | 179 ++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 lib/Controller/EndpointController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index a07197c6..5531ecdc 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -2,6 +2,7 @@ return [ 'resources' => [ + 'Endpoints' => ['url' => 'api/endpoints'], 'Sources' => ['url' => 'api/sources'], 'Mappings' => ['url' => 'api/mappings'], 'Jobs' => ['url' => 'api/jobs'], diff --git a/lib/Controller/EndpointController.php b/lib/Controller/EndpointController.php new file mode 100644 index 00000000..124eeba4 --- /dev/null +++ b/lib/Controller/EndpointController.php @@ -0,0 +1,179 @@ +request->getParams(); + $fieldsToSearch = ['name', 'description', 'url']; + + $searchParams = $searchService->createMySQLSearchParams(filters: $filters); + $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); + $filters = $searchService->unsetSpecialQueryParams(filters: $filters); + + return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); + } + + /** + * Retrieves a single endpoint by its ID + * + * This method returns a JSON response containing the details of a specific endpoint. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to retrieve + * @return JSONResponse A JSON response containing the endpoint details + */ + public function show(string $id): JSONResponse + { + try { + return new JSONResponse($this->endpointMapper->find(id: (int) $id)); + } catch (DoesNotExistException $exception) { + return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); + } + } + + /** + * Creates a new endpoint + * + * This method creates a new endpoint based on POST data. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse A JSON response containing the created endpoint + */ + public function create(): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + + if (isset($data['id'])) { + unset($data['id']); + } + + // Create the endpoint + $endpoint = $this->endpointMapper->createFromArray(object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Updates an existing endpoint + * + * This method updates an existing endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to update + * @return JSONResponse A JSON response containing the updated endpoint details + */ + public function update(int $id): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + if (isset($data['id'])) { + unset($data['id']); + } + + // Update the endpoint + $endpoint = $this->endpointMapper->updateFromArray(id: (int) $id, object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Deletes an endpoint + * + * This method deletes an endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to delete + * @return JSONResponse An empty JSON response + */ + public function destroy(int $id): JSONResponse + { + $this->endpointMapper->delete($this->endpointMapper->find((int) $id)); + + return new JSONResponse([]); + } +} From 9065d3d92d78f9d4517421d687851a3633e4a5a2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 15 Dec 2024 22:46:25 +0100 Subject: [PATCH 02/10] First real code on endpoints --- appinfo/routes.php | 10 +- lib/Controller/EndpointController.php | 179 ------------------------- lib/Controller/EndpointsController.php | 94 +++++-------- lib/Db/EndpointMapper.php | 31 +++++ lib/Service/EndpointService.php | 134 ++++++++++++++++++ 5 files changed, 202 insertions(+), 246 deletions(-) delete mode 100644 lib/Controller/EndpointController.php create mode 100644 lib/Service/EndpointService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 6b6150fe..8a4aed1b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,10 +25,10 @@ ['name' => 'mappings#test', 'url' => '/api/mappings/test', 'verb' => 'POST'], ['name' => 'mappings#saveObject', 'url' => '/api/mappings/objects', 'verb' => 'POST'], ['name' => 'mappings#getObjects', 'url' => '/api/mappings/objects', 'verb' => 'GET'], - // Running endpoints - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'GET'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'PUT'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'POST'], - ['name' => 'endpoints#run', 'url' => '/api/v1/{endpoint}', 'verb' => 'DELETE'], + // Running endpoints - allow any path after /api/endpoints/ + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+']], + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+']], + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+']], + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+']], ], ]; diff --git a/lib/Controller/EndpointController.php b/lib/Controller/EndpointController.php deleted file mode 100644 index 124eeba4..00000000 --- a/lib/Controller/EndpointController.php +++ /dev/null @@ -1,179 +0,0 @@ -request->getParams(); - $fieldsToSearch = ['name', 'description', 'url']; - - $searchParams = $searchService->createMySQLSearchParams(filters: $filters); - $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); - $filters = $searchService->unsetSpecialQueryParams(filters: $filters); - - return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); - } - - /** - * Retrieves a single endpoint by its ID - * - * This method returns a JSON response containing the details of a specific endpoint. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to retrieve - * @return JSONResponse A JSON response containing the endpoint details - */ - public function show(string $id): JSONResponse - { - try { - return new JSONResponse($this->endpointMapper->find(id: (int) $id)); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); - } - } - - /** - * Creates a new endpoint - * - * This method creates a new endpoint based on POST data. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse A JSON response containing the created endpoint - */ - public function create(): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - - if (isset($data['id'])) { - unset($data['id']); - } - - // Create the endpoint - $endpoint = $this->endpointMapper->createFromArray(object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Updates an existing endpoint - * - * This method updates an existing endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to update - * @return JSONResponse A JSON response containing the updated endpoint details - */ - public function update(int $id): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - if (isset($data['id'])) { - unset($data['id']); - } - - // Update the endpoint - $endpoint = $this->endpointMapper->updateFromArray(id: (int) $id, object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Deletes an endpoint - * - * This method deletes an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to delete - * @return JSONResponse An empty JSON response - */ - public function destroy(int $id): JSONResponse - { - $this->endpointMapper->delete($this->endpointMapper->find((int) $id)); - - return new JSONResponse([]); - } -} diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index 0b571570..05f4a09f 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -4,6 +4,7 @@ use OCA\OpenConnector\Service\ObjectService; use OCA\OpenConnector\Service\SearchService; +use OCA\OpenConnector\Service\EndpointService; use OCA\OpenConnector\Db\Endpoint; use OCA\OpenConnector\Db\EndpointMapper; use OCP\AppFramework\Controller; @@ -13,6 +14,9 @@ use OCP\IRequest; use OCP\AppFramework\Db\DoesNotExistException; +/** + * Controller for handling endpoint related operations + */ class EndpointsController extends Controller { /** @@ -22,12 +26,14 @@ class EndpointsController extends Controller * @param IRequest $request The request object * @param IAppConfig $config The app configuration object * @param EndpointMapper $endpointMapper The endpoint mapper object + * @param EndpointService $endpointService Service for handling endpoint operations */ public function __construct( $appName, IRequest $request, private IAppConfig $config, - private EndpointMapper $endpointMapper + private EndpointMapper $endpointMapper, + private EndpointService $endpointService ) { parent::__construct($appName, $request); @@ -171,74 +177,38 @@ public function destroy(int $id): JSONResponse } /** - * Retrieves call logs for an endpoint - * - * This method returns all the call logs associated with an endpoint based on its ID. + * Handles generic path requests by matching against registered endpoints + * + * This method checks if the current path matches any registered endpoint patterns + * and forwards the request to the appropriate endpoint service if found * * @NoAdminRequired * @NoCSRFRequired - * - * @param int $id The ID of the endpoint to retrieve logs for - * @return JSONResponse A JSON response containing the call logs + * + * @param string $path The request path to match + * @return JSONResponse The response from the endpoint service or 404 if no match */ - public function logs(int $id): JSONResponse + public function handlePath(string $path): JSONResponse { - try { - $endpoint = $this->endpointMapper->find($id); - $endpointLogs = $this->endpointLogMapper->findAll(null, null, ['endpoint_id' => $endpoint->getId()]); - return new JSONResponse($endpointLogs); - } catch (DoesNotExistException $e) { - return new JSONResponse(['error' => 'Endpoint not found'], 404); - } - } + // Find matching endpoints for the given path and method + $matchingEndpoints = $this->endpointMapper->findByPathRegex( + path: $path, + method: $this->request->getMethod() + ); - /** - * Test an endpoint - * - * This method fires a test call to the endpoint and returns the response. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * Endpoint: /api/endpoints-test/{id} - * - * @param int $id The ID of the endpoint to test - * @return JSONResponse A JSON response containing the test results - */ - public function test(int $id): JSONResponse - { - try { - $endpoint = $this->endpointMapper->find(id: $id); - // Implement the logic to test the endpoint here - // This is a placeholder implementation - $testResult = ['status' => 'success', 'message' => 'Endpoint test successful']; - return new JSONResponse($testResult); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Endpoint not found'], statusCode: 404); + // If no matching endpoints found, return 404 + if (empty($matchingEndpoints)) { + return new JSONResponse( + data: ['error' => 'No matching endpoint found for path and method: ' . $path . ' ' . $this->request->getMethod()], + statusCode: 404 + ); } - } - /** - * Actually run an endpoint - * - * This method runs an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param int $id The ID of the endpoint to run - * @return JSONResponse A JSON response containing the run results - */ - public function run(int $id): JSONResponse - { - try { - $endpoint = $this->endpointMapper->find(id: $id); - // Implement the logic to run the endpoint here - // This is a placeholder implementation - $runResult = ['status' => 'success', 'message' => 'Endpoint run successful']; - return new JSONResponse($runResult); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Endpoint not found'], statusCode: 404); - } + // Get the first matching endpoint since we already filtered by method + $endpoint = reset($matchingEndpoints); + + // Forward the request to the endpoint service + return $this->endpointService->handleRequest($endpoint, $this->request); } + } diff --git a/lib/Db/EndpointMapper.php b/lib/Db/EndpointMapper.php index b391fc53..8620ad36 100644 --- a/lib/Db/EndpointMapper.php +++ b/lib/Db/EndpointMapper.php @@ -9,6 +9,9 @@ use OCP\IDBConnection; use Symfony\Component\Uid\Uuid; +/** + * Mapper class for handling Endpoint database operations + */ class EndpointMapper extends QBMapper { public function __construct(IDBConnection $db) @@ -101,4 +104,32 @@ public function getTotalCallCount(): int // Return the total count return (int)$row['count']; } + + /** + * Find endpoints that match a given path and method using regex comparison + * + * @param string $path The path to match against endpoint regex patterns + * @param string $method The HTTP method to filter by (GET, POST, etc) + * @return array Array of matching Endpoint entities + */ + public function findByPathRegex(string $path, string $method): array + { + // Get all endpoints first since we need to do regex comparison + $endpoints = $this->findAll(); + + // Filter endpoints where both path matches regex pattern and method matches + return array_filter($endpoints, function(Endpoint $endpoint) use ($path, $method) { + // Get the regex pattern from the endpoint + $pattern = $endpoint->getEndpointRegex(); + + // Skip if no regex pattern is set + if (empty($pattern)) { + return false; + } + + // Check if both path matches the regex pattern and method matches + return preg_match($pattern, $path) === 1 && + $endpoint->getMethod() === $method; + }); + } } diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php new file mode 100644 index 00000000..ba741333 --- /dev/null +++ b/lib/Service/EndpointService.php @@ -0,0 +1,134 @@ +getSchema() !== null) { + // Handle CRUD operations via ObjectService + return $this->handleSchemaRequest($endpoint, $request); + } + + // Check if endpoint connects to a source + if ($endpoint->getSource() !== null) { + // Proxy request to source via CallService + return $this->handleSourceRequest($endpoint, $request); + } + + // Invalid endpoint configuration + throw new \Exception('Endpoint must specify either a schema or source connection'); + + } catch (\Exception $e) { + $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); + return new \OCP\AppFramework\Http\JSONResponse( + ['error' => $e->getMessage()], + 400 + ); + } + } + + /** + * Handles requests for schema-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return \OCP\AppFramework\Http\JSONResponse + */ + private function handleSchemaRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + // Get request method + $method = $request->getMethod(); + + // Route to appropriate ObjectService method based on HTTP method + return match($method) { + 'GET' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->get($endpoint->getSchema(), $request->getParams()) + ), + 'POST' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->create($endpoint->getSchema(), $request->getParams()) + ), + 'PUT' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->update($endpoint->getSchema(), $request->getParams()) + ), + 'DELETE' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->delete($endpoint->getSchema(), $request->getParams()) + ), + default => throw new \Exception('Unsupported HTTP method') + }; + } + + /** + * Handles requests for source-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return \OCP\AppFramework\Http\JSONResponse + */ + private function handleSourceRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + // Proxy the request to the source via CallService + $response = $this->callService->call( + source: $endpoint->getSource(), + endpoint: $endpoint->getPath(), + method: $request->getMethod(), + config: [ + 'query' => $request->getParams(), + 'headers' => $request->getHeaders(), + 'body' => $request->getContent() + ] + ); + + return new \OCP\AppFramework\Http\JSONResponse( + $response->getResponse(), + $response->getStatusCode() + ); + } +} From b8f68957c3dbbdfcba1404edcae9bcc95219f67f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 15 Dec 2024 22:46:26 +0100 Subject: [PATCH 03/10] First real code on endpoints --- lib/Service/ConsumertService.php | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 lib/Service/ConsumertService.php diff --git a/lib/Service/ConsumertService.php b/lib/Service/ConsumertService.php new file mode 100644 index 00000000..ba741333 --- /dev/null +++ b/lib/Service/ConsumertService.php @@ -0,0 +1,134 @@ +getSchema() !== null) { + // Handle CRUD operations via ObjectService + return $this->handleSchemaRequest($endpoint, $request); + } + + // Check if endpoint connects to a source + if ($endpoint->getSource() !== null) { + // Proxy request to source via CallService + return $this->handleSourceRequest($endpoint, $request); + } + + // Invalid endpoint configuration + throw new \Exception('Endpoint must specify either a schema or source connection'); + + } catch (\Exception $e) { + $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); + return new \OCP\AppFramework\Http\JSONResponse( + ['error' => $e->getMessage()], + 400 + ); + } + } + + /** + * Handles requests for schema-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return \OCP\AppFramework\Http\JSONResponse + */ + private function handleSchemaRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + // Get request method + $method = $request->getMethod(); + + // Route to appropriate ObjectService method based on HTTP method + return match($method) { + 'GET' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->get($endpoint->getSchema(), $request->getParams()) + ), + 'POST' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->create($endpoint->getSchema(), $request->getParams()) + ), + 'PUT' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->update($endpoint->getSchema(), $request->getParams()) + ), + 'DELETE' => new \OCP\AppFramework\Http\JSONResponse( + $this->objectService->delete($endpoint->getSchema(), $request->getParams()) + ), + default => throw new \Exception('Unsupported HTTP method') + }; + } + + /** + * Handles requests for source-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return \OCP\AppFramework\Http\JSONResponse + */ + private function handleSourceRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + // Proxy the request to the source via CallService + $response = $this->callService->call( + source: $endpoint->getSource(), + endpoint: $endpoint->getPath(), + method: $request->getMethod(), + config: [ + 'query' => $request->getParams(), + 'headers' => $request->getHeaders(), + 'body' => $request->getContent() + ] + ); + + return new \OCP\AppFramework\Http\JSONResponse( + $response->getResponse(), + $response->getStatusCode() + ); + } +} From b624417633afec5305823ca68209b11872297746 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Tue, 17 Dec 2024 16:44:58 +0100 Subject: [PATCH 04/10] Working get endpoints --- appinfo/routes.php | 9 +- lib/Controller/EndpointsController.php | 13 +- lib/Db/Endpoint.php | 53 ++++--- lib/Db/EndpointMapper.php | 21 ++- lib/Service/ConsumertService.php | 134 ---------------- lib/Service/EndpointService.php | 208 ++++++++++++++++++++++--- lib/Service/ObjectService.php | 11 ++ 7 files changed, 249 insertions(+), 200 deletions(-) delete mode 100644 lib/Service/ConsumertService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 8a4aed1b..e8ee2bf2 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,7 +7,6 @@ 'Mappings' => ['url' => 'api/mappings'], 'Jobs' => ['url' => 'api/jobs'], 'Synchronizations' => ['url' => 'api/synchronizations'], - 'Endpoints' => ['url' => 'api/endpoints'], 'Consumers' => ['url' => 'api/consumers'], ], 'routes' => [ @@ -26,9 +25,9 @@ ['name' => 'mappings#saveObject', 'url' => '/api/mappings/objects', 'verb' => 'POST'], ['name' => 'mappings#getObjects', 'url' => '/api/mappings/objects', 'verb' => 'GET'], // Running endpoints - allow any path after /api/endpoints/ - ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+']], - ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+']], - ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+']], - ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+']], + ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{_path}', 'verb' => 'GET', 'requirements' => ['_path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'PUT', 'requirements' => ['path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'POST', 'requirements' => ['path' => '.+']], +// ['name' => 'endpoints#handlePath', 'url' => '/api/endpoint/{path}', 'verb' => 'DELETE', 'requirements' => ['path' => '.+']], ], ]; diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index 05f4a09f..bd0b6804 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -70,6 +70,7 @@ public function page(): TemplateResponse */ public function index(ObjectService $objectService, SearchService $searchService): JSONResponse { + $filters = $this->request->getParams(); $fieldsToSearch = ['name', 'description', 'endpoint']; @@ -178,28 +179,28 @@ public function destroy(int $id): JSONResponse /** * Handles generic path requests by matching against registered endpoints - * + * * This method checks if the current path matches any registered endpoint patterns * and forwards the request to the appropriate endpoint service if found * * @NoAdminRequired * @NoCSRFRequired - * + * * @param string $path The request path to match * @return JSONResponse The response from the endpoint service or 404 if no match */ - public function handlePath(string $path): JSONResponse + public function handlePath(string $_path): JSONResponse { // Find matching endpoints for the given path and method $matchingEndpoints = $this->endpointMapper->findByPathRegex( - path: $path, + path: $_path, method: $this->request->getMethod() ); // If no matching endpoints found, return 404 if (empty($matchingEndpoints)) { return new JSONResponse( - data: ['error' => 'No matching endpoint found for path and method: ' . $path . ' ' . $this->request->getMethod()], + data: ['error' => 'No matching endpoint found for path and method: ' . $_path . ' ' . $this->request->getMethod()], statusCode: 404 ); } @@ -208,7 +209,7 @@ public function handlePath(string $path): JSONResponse $endpoint = reset($matchingEndpoints); // Forward the request to the endpoint service - return $this->endpointService->handleRequest($endpoint, $this->request); + return $this->endpointService->handleRequest($endpoint, $this->request, $_path); } } diff --git a/lib/Db/Endpoint.php b/lib/Db/Endpoint.php index a4cc78d5..8333009f 100644 --- a/lib/Db/Endpoint.php +++ b/lib/Db/Endpoint.php @@ -8,34 +8,37 @@ class Endpoint extends Entity implements JsonSerializable { - protected ?string $uuid = null; - protected ?string $name = null; // The name of the endpoint - protected ?string $description = null; // The description of the endpoint - protected ?string $reference = null; // The reference of the endpoint - protected ?string $version = '0.0.0'; // The version of the endpoint - protected ?string $endpoint = null; // The actual endpoint e.g /api/buildings/{{id}}. An endpoint may contain parameters e.g {{id}} - protected ?array $endpointArray = null; // An array representation of the endpoint. Automatically generated - protected ?string $endpointRegex = null; // A regex representation of the endpoint. Automatically generated - protected ?string $method = null; // One of GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD. method and endpoint combination should be unique - protected ?string $targetType = null; // The target to attach this endpoint to, should be one of source (to create a proxy endpoint) or register/schema (to create an object endpoint) or job (to fire an event) or synchronization (to create a synchronization endpoint) - protected ?string $targetId = null; // The target id to attach this endpoint to + protected ?string $uuid = null; + protected ?string $name = null; // The name of the endpoint + protected ?string $description = null; // The description of the endpoint + protected ?string $reference = null; // The reference of the endpoint + protected ?string $version = '0.0.0'; // The version of the endpoint + protected ?string $endpoint = null; // The actual endpoint e.g /api/buildings/{{id}}. An endpoint may contain parameters e.g {{id}} + protected ?array $endpointArray = null; // An array representation of the endpoint. Automatically generated + protected ?string $endpointRegex = null; // A regex representation of the endpoint. Automatically generated + protected ?string $method = null; // One of GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD. method and endpoint combination should be unique + protected ?string $targetType = null; // The target to attach this endpoint to, should be one of source (to create a proxy endpoint) or register/schema (to create an object endpoint) or job (to fire an event) or synchronization (to create a synchronization endpoint) + protected ?string $targetId = null; // The target id to attach this endpoint to protected ?DateTime $created = null; protected ?DateTime $updated = null; public function __construct() { - $this->addType('uuid', 'string'); - $this->addType('name', 'string'); - $this->addType('description', 'string'); - $this->addType('reference', 'string'); - $this->addType('version', 'string'); - $this->addType('endpoint', 'string'); - $this->addType('endpointArray', 'json'); - $this->addType('endpointRegex', 'string'); - $this->addType('method', 'string'); - $this->addType('targetType', 'string'); - $this->addType('targetId', 'string'); - $this->addType('created', 'datetime'); - $this->addType('updated', 'datetime'); + $this->addType(fieldName:'uuid', type: 'string'); + $this->addType(fieldName:'name', type: 'string'); + $this->addType(fieldName:'description', type: 'string'); + $this->addType(fieldName:'reference', type: 'string'); + $this->addType(fieldName:'version', type: 'string'); + $this->addType(fieldName:'endpoint', type: 'string'); + $this->addType(fieldName:'endpointArray', type: 'json'); + $this->addType(fieldName:'endpointRegex', type: 'string'); + $this->addType(fieldName:'method', type: 'string'); + $this->addType(fieldName:'targetType', type: 'string'); + $this->addType(fieldName:'targetId', type: 'string'); + $this->addType(fieldName:'schema', type: 'int'); + $this->addType(fieldName:'register', type: 'int'); + $this->addType(fieldName:'source', type: 'int'); + $this->addType(fieldName:'created', type: 'datetime'); + $this->addType(fieldName:'updated', type: 'datetime'); } public function getJsonFields(): array @@ -85,7 +88,7 @@ public function jsonSerialize(): array 'targetId' => $this->targetId, 'created' => isset($this->created) ? $this->created->format('c') : null, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, - + ]; } } diff --git a/lib/Db/EndpointMapper.php b/lib/Db/EndpointMapper.php index 8620ad36..0479b765 100644 --- a/lib/Db/EndpointMapper.php +++ b/lib/Db/EndpointMapper.php @@ -61,6 +61,10 @@ public function findAll(?int $limit = null, ?int $offset = null, ?array $filters return $this->findEntities(query: $qb); } + private function createEndpointRegex(string $endpoint) { + return '#'.preg_replace(pattern: ['#\/{{([^}}]+)}}\/#', '#\/{{([^}}]+)}}$#'], replacement: ['/([^/]+)/', '(/([^/]+))?'], subject: $endpoint).'#'; + } + public function createFromArray(array $object): Endpoint { $obj = new Endpoint(); @@ -69,6 +73,10 @@ public function createFromArray(array $object): Endpoint if ($obj->getUuid() === null){ $obj->setUuid(Uuid::v4()); } + + $obj->setEndpointRegex($this->createEndpointRegex($obj->getEndpoint())); + $obj->setEndpointArray(explode('/', $obj->getEndpoint())); + return $this->insert(entity: $obj); } @@ -82,6 +90,9 @@ public function updateFromArray(int $id, array $object): Endpoint $version[2] = (int)$version[2] + 1; $obj->setVersion(implode('.', $version)); + $obj->setEndpointRegex($this->createEndpointRegex($obj->getEndpoint())); + $obj->setEndpointArray(explode('/', $obj->getEndpoint())); + return $this->update($obj); } @@ -112,23 +123,23 @@ public function getTotalCallCount(): int * @param string $method The HTTP method to filter by (GET, POST, etc) * @return array Array of matching Endpoint entities */ - public function findByPathRegex(string $path, string $method): array + public function findByPathRegex(string $path, string $method): array { // Get all endpoints first since we need to do regex comparison $endpoints = $this->findAll(); - + // Filter endpoints where both path matches regex pattern and method matches return array_filter($endpoints, function(Endpoint $endpoint) use ($path, $method) { // Get the regex pattern from the endpoint $pattern = $endpoint->getEndpointRegex(); - + // Skip if no regex pattern is set if (empty($pattern)) { return false; } - + // Check if both path matches the regex pattern and method matches - return preg_match($pattern, $path) === 1 && + return preg_match($pattern, $path) === 1 && $endpoint->getMethod() === $method; }); } diff --git a/lib/Service/ConsumertService.php b/lib/Service/ConsumertService.php deleted file mode 100644 index ba741333..00000000 --- a/lib/Service/ConsumertService.php +++ /dev/null @@ -1,134 +0,0 @@ -getSchema() !== null) { - // Handle CRUD operations via ObjectService - return $this->handleSchemaRequest($endpoint, $request); - } - - // Check if endpoint connects to a source - if ($endpoint->getSource() !== null) { - // Proxy request to source via CallService - return $this->handleSourceRequest($endpoint, $request); - } - - // Invalid endpoint configuration - throw new \Exception('Endpoint must specify either a schema or source connection'); - - } catch (\Exception $e) { - $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); - return new \OCP\AppFramework\Http\JSONResponse( - ['error' => $e->getMessage()], - 400 - ); - } - } - - /** - * Handles requests for schema-based endpoints - * - * @param Endpoint $endpoint The endpoint configuration - * @param IRequest $request The incoming request - * @return \OCP\AppFramework\Http\JSONResponse - */ - private function handleSchemaRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { - // Get request method - $method = $request->getMethod(); - - // Route to appropriate ObjectService method based on HTTP method - return match($method) { - 'GET' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->get($endpoint->getSchema(), $request->getParams()) - ), - 'POST' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->create($endpoint->getSchema(), $request->getParams()) - ), - 'PUT' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->update($endpoint->getSchema(), $request->getParams()) - ), - 'DELETE' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->delete($endpoint->getSchema(), $request->getParams()) - ), - default => throw new \Exception('Unsupported HTTP method') - }; - } - - /** - * Handles requests for source-based endpoints - * - * @param Endpoint $endpoint The endpoint configuration - * @param IRequest $request The incoming request - * @return \OCP\AppFramework\Http\JSONResponse - */ - private function handleSourceRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { - // Proxy the request to the source via CallService - $response = $this->callService->call( - source: $endpoint->getSource(), - endpoint: $endpoint->getPath(), - method: $request->getMethod(), - config: [ - 'query' => $request->getParams(), - 'headers' => $request->getHeaders(), - 'body' => $request->getContent() - ] - ); - - return new \OCP\AppFramework\Http\JSONResponse( - $response->getResponse(), - $response->getStatusCode() - ); - } -} diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php index ba741333..be3a974a 100644 --- a/lib/Service/EndpointService.php +++ b/lib/Service/EndpointService.php @@ -16,8 +16,13 @@ use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\ServerException; +use OCP\AppFramework\Db\Entity; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IURLGenerator; use Psr\Log\LoggerInterface; +use ValueError; /** * Service class for handling endpoint requests @@ -37,7 +42,8 @@ class EndpointService { public function __construct( private readonly ObjectService $objectService, private readonly CallService $callService, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, + private readonly IURLGenerator $urlGenerator, ) {} /** @@ -48,19 +54,19 @@ public function __construct( * * @param Endpoint $endpoint The endpoint configuration to handle * @param IRequest $request The incoming request object - * @return \OCP\AppFramework\Http\JSONResponse Response containing the result + * @return JSONResponse Response containing the result * @throws \Exception When endpoint configuration is invalid */ - public function handleRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + public function handleRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse { try { // Check if endpoint connects to a schema - if ($endpoint->getSchema() !== null) { + if ($endpoint->getTargetType() === 'register/schema') { // Handle CRUD operations via ObjectService - return $this->handleSchemaRequest($endpoint, $request); + return $this->handleSchemaRequest($endpoint, $request, $path); } - + // Check if endpoint connects to a source - if ($endpoint->getSource() !== null) { + if ($endpoint->getTargetType() === 'api') { // Proxy request to source via CallService return $this->handleSourceRequest($endpoint, $request); } @@ -70,50 +76,202 @@ public function handleRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFr } catch (\Exception $e) { $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); - return new \OCP\AppFramework\Http\JSONResponse( + return new JSONResponse( ['error' => $e->getMessage()], 400 ); } } + private function getPathParameters(array $endpointArray, string $path): array + { + $pathParts = explode(separator: '/', string: $path); + + $endpointArrayNormalized = array_map( + function($item) { + return str_replace( + search: ['{{', '{{ ', '}}', '}}'], + replace: '', + subject: $item + ); + }, + $endpointArray); + + try { + $pathParams = array_combine( + keys: $endpointArrayNormalized, + values: $pathParts + ); + } catch (ValueError $error) { + array_pop($endpointArrayNormalized); + $pathParams = array_combine( + keys: $endpointArrayNormalized, + values: $pathParts + ); + } + + return $pathParams; + } + + private function getObjects( + \OCA\OpenRegister\Service\ObjectService|QBMapper $mapper, + array $parameters, + array $pathParams, + int &$status = 200 + ): Entity|array + { + if(isset($pathParams['id']) === true && $pathParams['id'] === end($pathParams)) { + return $mapper->find($pathParams['id']); + } else if (isset($pathParams['id']) === true) { + while(prev($pathParams) !== $pathParams['id']){}; + + $property = next($pathParams); + + if(next($pathParams) !== false) { + $id = pos($pathParams); + } + + $main = $mapper->find($pathParams['id'])->getObject(); + $ids = $main[$property]; + + if(isset($id) === true && in_array(needle: $id, haystack: $ids) === true) { + + return $mapper->findSubObjects([$id], $property)[0]; + } else if (isset($id) === true) { + $status = 404; + return ['error' => 'not found', 'message' => "the subobject with id $id does not exist"]; + + } + + return $mapper->findSubObjects($ids, $property); + } + + $result = $mapper->findAllPaginated(requestParams: $parameters); + + $returnArray = [ + 'count' => $result['total'], + ]; + + if($result['page'] < $result['pages']) { + $parameters['page'] = $result['page'] + 1; + $parameters['_path'] = implode('/', $pathParams); + + + $returnArray['next'] = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute( + routeName: 'openconnector.endpoints.handlepath', + arguments: $parameters + ) + ); + } + if($result['page'] > 1) { + $parameters['page'] = $result['page'] - 1; + $parameters['_path'] = implode('/', $pathParams); + + $returnArray['previous'] = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->linkToRoute( + routeName: 'openconnector.endpoints.handlepath', + arguments: $parameters + ) + ); + } + + $returnArray['results'] = $result['results']; + + return $returnArray; + } + /** * Handles requests for schema-based endpoints - * + * * @param Endpoint $endpoint The endpoint configuration * @param IRequest $request The incoming request - * @return \OCP\AppFramework\Http\JSONResponse + * @return JSONResponse */ - private function handleSchemaRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse { // Get request method $method = $request->getMethod(); - + $target = explode('/', $endpoint->getTargetId()); + + $register = $target[0]; + $schema = $target[1]; + + $mapper = $this->objectService->getMapper(schema: $schema, register: $register); + + $parameters = $request->getParams(); + + $pathParams = $this->getPathParameters($endpoint->getEndpointArray(), $path); + + unset($parameters['_route'], $parameters['_path']); + + $status = 200; + // Route to appropriate ObjectService method based on HTTP method return match($method) { - 'GET' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->get($endpoint->getSchema(), $request->getParams()) + 'GET' => new JSONResponse( + $this->getObjects(mapper: $mapper, parameters: $parameters, pathParams: $pathParams, status: $status), statusCode: $status ), - 'POST' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->create($endpoint->getSchema(), $request->getParams()) + 'POST' => new JSONResponse( + $mapper->createFromArray(object: $parameters) ), - 'PUT' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->update($endpoint->getSchema(), $request->getParams()) + 'PUT' => new JSONResponse( + $mapper->updateFromArray($request->getParams()['id'], $request->getParams(), true, true) ), - 'DELETE' => new \OCP\AppFramework\Http\JSONResponse( - $this->objectService->delete($endpoint->getSchema(), $request->getParams()) + 'DELETE' => new JSONResponse( + $mapper->delete($request->getParams()) ), default => throw new \Exception('Unsupported HTTP method') }; } + private function getRawContent(): string + { + return file_get_contents(filename: 'php://input'); + } + + private function getHeaders(array $server, bool $proxyHeaders = false): array + { + $headers = array_filter( + array: $server, + callback: function (string $key) use ($proxyHeaders){ + if(str_starts_with($key, 'HTTP_') === false) { + return false; + } else if ($proxyHeaders === false + && (str_starts_with(haystack: $key, needle: 'HTTP_X_FORWARDED') + || $key === 'HTTP_X_REAL_IP' || $key === 'HTTP_X_ORIGINAL_URI' + ) + ) { + return false; + } + + return true; + }, + mode: ARRAY_FILTER_USE_KEY + ); + + $keys = array_keys($headers); + + return array_combine( + array_map( + callback: function($key) { + return strtolower(string: substr(string: $key, offset: 5)); + }, + array: $keys), + $headers + ); + } + /** * Handles requests for source-based endpoints * * @param Endpoint $endpoint The endpoint configuration * @param IRequest $request The incoming request - * @return \OCP\AppFramework\Http\JSONResponse + * @return JSONResponse */ - private function handleSourceRequest(Endpoint $endpoint, IRequest $request): \OCP\AppFramework\Http\JSONResponse { + private function handleSourceRequest(Endpoint $endpoint, IRequest $request): JSONResponse { + + $headers = $this->getHeaders($request->server); + // Proxy the request to the source via CallService $response = $this->callService->call( source: $endpoint->getSource(), @@ -121,12 +279,12 @@ private function handleSourceRequest(Endpoint $endpoint, IRequest $request): \OC method: $request->getMethod(), config: [ 'query' => $request->getParams(), - 'headers' => $request->getHeaders(), - 'body' => $request->getContent() + 'headers' => $headers, + 'body' => $this->getRawContent(), ] ); - return new \OCP\AppFramework\Http\JSONResponse( + return new JSONResponse( $response->getResponse(), $response->getStatusCode() ); diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index eb5f2c00..692e76ae 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -8,6 +8,7 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use OCP\App\IAppManager; +use OCP\AppFramework\Db\QBMapper; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -251,4 +252,14 @@ public function getOpenRegisters(): ?\OCA\OpenRegister\Service\ObjectService return null; } + public function getMapper(?string $objecttype = null, ?int $schema = null, ?int $register = null): QBMapper|\OCA\OpenRegister\Service\ObjectService|null + { + if($register !== null && $schema !== null && $objecttype === null) { + return $this->getOpenRegisters()->getMapper(register: $register, schema: $schema); + } + + return null; + + } + } From abfa0f5812c2ee62afc0fc7f6f0e8feffdb487f0 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 13:51:14 +0100 Subject: [PATCH 05/10] Add authorization to endpoints --- lib/Controller/EndpointsController.php | 16 +- lib/Db/Consumer.php | 6 +- lib/Exception/AuthenticationException.php | 19 +++ lib/Migration/Version1Date20241218122708.php | 57 +++++++ lib/Migration/Version1Date20241218122932.php | 58 +++++++ lib/Service/AuthorizationService.php | 164 +++++++++++++++++++ 6 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 lib/Exception/AuthenticationException.php create mode 100644 lib/Migration/Version1Date20241218122708.php create mode 100644 lib/Migration/Version1Date20241218122932.php create mode 100644 lib/Service/AuthorizationService.php diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index bd0b6804..cc42407c 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -2,6 +2,8 @@ namespace OCA\OpenConnector\Controller; +use OCA\OpenConnector\Exception\AuthenticationException; +use OCA\OpenConnector\Service\AuthorizationService; use OCA\OpenConnector\Service\ObjectService; use OCA\OpenConnector\Service\SearchService; use OCA\OpenConnector\Service\EndpointService; @@ -33,7 +35,8 @@ public function __construct( IRequest $request, private IAppConfig $config, private EndpointMapper $endpointMapper, - private EndpointService $endpointService + private EndpointService $endpointService, + private AuthorizationService $authorizationService ) { parent::__construct($appName, $request); @@ -185,12 +188,23 @@ public function destroy(int $id): JSONResponse * * @NoAdminRequired * @NoCSRFRequired + * @PublicPage * * @param string $path The request path to match * @return JSONResponse The response from the endpoint service or 404 if no match */ public function handlePath(string $_path): JSONResponse { + try { + $token = $this->request->getHeader('Authorization'); + $this->authorizationService->authorize(authorization: $token); + } catch (AuthenticationException $exception) { + return new JSONResponse( + data: ['error' => $exception->getMessage(), 'details' => $exception->getDetails()], + statusCode: 401 + ); + } + // Find matching endpoints for the given path and method $matchingEndpoints = $this->endpointMapper->findByPathRegex( path: $_path, diff --git a/lib/Db/Consumer.php b/lib/Db/Consumer.php index c5c8b4b5..38a14f61 100644 --- a/lib/Db/Consumer.php +++ b/lib/Db/Consumer.php @@ -18,12 +18,12 @@ class Consumer extends Entity implements JsonSerializable { protected ?string $uuid = null; - protected ?string $name = null; // The name of the consumer + protected ?string $name = null; // The name of the consumer protected ?string $description = null; // The description of the consumer protected ?array $domains = []; // The domains the consumer is allowed to run from protected ?array $ips = []; // The ips the consumer is allowed to run from protected ?string $authorizationType = null; // The authorization type of the consumer, should be one of the following: 'none', 'basic', 'bearer', 'apiKey', 'oauth2', 'jwt'. Keep in mind that the consumer needs to be able to handle the authorization type. - protected ?string $authorizationConfiguration = null; // The authorization configuration of the consumer + protected ?array $authorizationConfiguration = []; // The authorization configuration of the consumer protected ?DateTime $created = null; // the date and time the consumer was created protected ?DateTime $updated = null; // the date and time the consumer was updated @@ -38,7 +38,7 @@ public function __construct() { $this->addType('domains', 'json'); $this->addType('ips', 'json'); $this->addType('authorizationType', 'string'); - $this->addType('authorizationConfiguration', 'string'); + $this->addType('authorizationConfiguration', 'json'); $this->addType('created', 'datetime'); $this->addType('updated', 'datetime'); } diff --git a/lib/Exception/AuthenticationException.php b/lib/Exception/AuthenticationException.php new file mode 100644 index 00000000..d640ae39 --- /dev/null +++ b/lib/Exception/AuthenticationException.php @@ -0,0 +1,19 @@ +details = $details; + parent::__construct($message); + } + + public function getDetails(): array + { + return $this->details; + } +} diff --git a/lib/Migration/Version1Date20241218122708.php b/lib/Migration/Version1Date20241218122708.php new file mode 100644 index 00000000..e92966ed --- /dev/null +++ b/lib/Migration/Version1Date20241218122708.php @@ -0,0 +1,57 @@ +hasTable(tableName: 'openconnector_consumers') === true) { + $table = $schema->getTable(tableName: 'openconnector_consumers'); + $table->dropColumn('authorization_configuration'); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Migration/Version1Date20241218122932.php b/lib/Migration/Version1Date20241218122932.php new file mode 100644 index 00000000..6c3307fa --- /dev/null +++ b/lib/Migration/Version1Date20241218122932.php @@ -0,0 +1,58 @@ +hasTable(tableName: 'openconnector_consumers') === true) { + $table = $schema->getTable(tableName: 'openconnector_consumers'); + $table->addColumn('authorization_configuration', Types::JSON); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + } +} diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php new file mode 100644 index 00000000..4c38eed5 --- /dev/null +++ b/lib/Service/AuthorizationService.php @@ -0,0 +1,164 @@ +consumerMapper->findAll(filters: ['name' => $issuer]); + + if(count($consumers) === 0) { + throw new AuthenticationException(message: 'The issuer was not found', details: ['iss' => $issuer]); + } + + return $consumers[0]; + } + + private function checkHeaders(JWS $token): void { + $headerChecker = new HeaderCheckerManager( + checkers: [ + new AlgorithmChecker(array_merge(self::HMAC_ALGORITHMS, self::PKCS1_ALGORITHMS, self::PSS_ALGORITHMS)) + ], + tokenTypes: [new JWSTokenSupport()]); + + $headerChecker->check(jwt: $token, index: 0); + + } + + private function getJWK(string $publicKey, string $algorithm): JWKSet + { + + if ( + in_array(needle: $algorithm, haystack: self::HMAC_ALGORITHMS) === true + ) { + return new JWKSet([ + JWKFactory::createFromSecret( + secret: $publicKey, + additional_values: ['alg' => $algorithm, 'use' => 'sig']) + ]); + } else if ( + in_array( + needle: $algorithm, + haystack: self::PKCS1_ALGORITHMS + ) === true + || in_array( + needle: $algorithm, + haystack: self::PSS_ALGORITHMS + ) === true + ) { + $stamp = microtime().getmypid(); + $filename = "/var/tmp/publickey-$stamp"; + file_put_contents($filename, base64_decode($publicKey)); + $jwk = new JWKSet([JWKFactory::createFromKeyFile(file: $filename)]); + unlink($filename); + return $jwk; + } + throw new AuthenticationException(message: 'The token algorithm is not supported', details: ['algorithm' => $algorithm]); + } + + public function validatePayload(array $payload): void + { + $now = new DateTime(); + + if(isset($payload['iat']) === true) { + $iat = new DateTime('@'.$payload['iat']); + } else { + throw new AuthenticationException(message: 'The token has no time of creation', details: ['iat' => null]); + } + + if(isset($payload['exp']) === true) { + $exp = new DateTime('@'.$payload['exp']); + } else { + $exp = clone $iat; + $exp->modify('+1 Hour'); + } + + if($exp->diff($now)->format('%R') === '+') { + throw new AuthenticationException(message: 'The token has expired', details: ['iat' => $iat->getTimestamp(), 'exp' => $exp->getTimestamp(), 'time checked' => $now->getTimestamp()]); + } + } + public function authorize(string $authorization): void + { + $token = substr(string: $authorization, offset: strlen('Bearer ')); + + if($token === '') { + throw new AuthenticationException(message: 'No token has been provided', details: []); + } + + $algorithmManager = new AlgorithmManager([ + new HS256(), + new HS384(), + new HS256(), + new RS256(), + new RS384(), + new RS512(), + new PS256(), + new PS384(), + new PS512() + ]); + $verifier = new JWSVerifier($algorithmManager); + $serializerManager = new JWSSerializerManager([new CompactSerializer()]); + + + + $jws = $serializerManager->unserialize(input: $token); + + try{ + $this->checkHeaders($jws); + } catch (InvalidHeaderException $exception) { + throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => $exception->getMessage()]); + } + + $payload = json_decode(json: $jws->getPayload(), associative: true); + $issuer = $this->findIssuer(issuer: $payload['iss']); + + $publicKey = $issuer->getAuthorizationConfiguration()['publicKey']; + $algorithm = $issuer->getAuthorizationConfiguration()['algorithm']; + + $jwkSet = $this->getJWK(publicKey: $publicKey, algorithm: $algorithm); + + if($verifier->verifyWithKeySet(jws: $jws, jwkset: $jwkSet, signatureIndex: 0) === false) { + throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => 'The token does not match the public key']); + } + $this->validatePayload($payload); +// $this->userSession->setUser($this->userManager->get($issuer->getUserId())); + } +} From e40da74df622e92df43cc56931c0cd6cc3348bf0 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 14:11:32 +0100 Subject: [PATCH 06/10] Set user id in consumer --- lib/Db/Consumer.php | 3 +++ lib/Migration/Version1Date20241218122932.php | 1 + lib/Service/AuthorizationService.php | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/Db/Consumer.php b/lib/Db/Consumer.php index 38a14f61..2fcd8c7b 100644 --- a/lib/Db/Consumer.php +++ b/lib/Db/Consumer.php @@ -26,6 +26,7 @@ class Consumer extends Entity implements JsonSerializable protected ?array $authorizationConfiguration = []; // The authorization configuration of the consumer protected ?DateTime $created = null; // the date and time the consumer was created protected ?DateTime $updated = null; // the date and time the consumer was updated + protected ?string $userId = null; /** * Consumer constructor. @@ -41,6 +42,7 @@ public function __construct() { $this->addType('authorizationConfiguration', 'json'); $this->addType('created', 'datetime'); $this->addType('updated', 'datetime'); + $this->addType('userId', 'string'); } /** @@ -100,6 +102,7 @@ public function jsonSerialize(): array 'ips' => $this->ips, 'authorizationType' => $this->authorizationType, 'authorizationConfiguration' => $this->authorizationConfiguration, + 'userId' => $this->userId, 'created' => isset($this->created) ? $this->created->format('c') : null, 'updated' => isset($this->updated) ? $this->updated->format('c') : null, ]; diff --git a/lib/Migration/Version1Date20241218122932.php b/lib/Migration/Version1Date20241218122932.php index 6c3307fa..7c66532e 100644 --- a/lib/Migration/Version1Date20241218122932.php +++ b/lib/Migration/Version1Date20241218122932.php @@ -43,6 +43,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt if($schema->hasTable(tableName: 'openconnector_consumers') === true) { $table = $schema->getTable(tableName: 'openconnector_consumers'); $table->addColumn('authorization_configuration', Types::JSON); + $table->addColumn('user_id', Types::STRING); } return $schema; diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php index 4c38eed5..5f338ea3 100644 --- a/lib/Service/AuthorizationService.php +++ b/lib/Service/AuthorizationService.php @@ -159,6 +159,6 @@ public function authorize(string $authorization): void throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => 'The token does not match the public key']); } $this->validatePayload($payload); -// $this->userSession->setUser($this->userManager->get($issuer->getUserId())); + $this->userSession->setUser($this->userManager->get($issuer->getUserId())); } } From 1c7ea85a32c4df1a666f711dbe53cdbb854ea925 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 14:47:27 +0100 Subject: [PATCH 07/10] Docblocks on authorization service --- lib/Service/AuthorizationService.php | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php index 5f338ea3..b81b1948 100644 --- a/lib/Service/AuthorizationService.php +++ b/lib/Service/AuthorizationService.php @@ -28,6 +28,9 @@ use OCP\IUserManager; use OCP\IUserSession; +/** + * Service class for handling authorization on incoming calls. + */ class AuthorizationService { const HMAC_ALGORITHMS = ['HS256', 'HS384', 'HS512']; @@ -35,12 +38,24 @@ class AuthorizationService const PSS_ALGORITHMS = ['PS256', 'PS384', 'PS512']; + /** + * @param IUserManager $userManager + * @param IUserSession $userSession + * @param ConsumerMapper $consumerMapper + */ public function __construct( private readonly IUserManager $userManager, private readonly IUserSession $userSession, private readonly ConsumerMapper $consumerMapper, ) {} + /** + * Find the issuer (consumer) for the request. + * + * @param string $issuer The issuer from the JWT token. + * @return Consumer The consumer for the JWT token. + * @throws AuthenticationException Thrown if no issuer was found. + */ private function findIssuer(string $issuer): Consumer { $consumers = $this->consumerMapper->findAll(filters: ['name' => $issuer]); @@ -52,6 +67,12 @@ private function findIssuer(string $issuer): Consumer return $consumers[0]; } + /** + * Check if the headers of a JWT token are valid. + * + * @param JWS $token The unserialized token. + * @return void + */ private function checkHeaders(JWS $token): void { $headerChecker = new HeaderCheckerManager( checkers: [ @@ -63,6 +84,14 @@ private function checkHeaders(JWS $token): void { } + /** + * Get the Json Web Key for a public key combined with an algorithm. + * + * @param string $publicKey The public key to create a JWK for + * @param string $algorithm The algorithm deciding how the key should be defined. + * @return JWKSet The resulting JWK-set. + * @throws AuthenticationException + */ private function getJWK(string $publicKey, string $algorithm): JWKSet { @@ -94,6 +123,13 @@ private function getJWK(string $publicKey, string $algorithm): JWKSet throw new AuthenticationException(message: 'The token algorithm is not supported', details: ['algorithm' => $algorithm]); } + /** + * Validate data in the payload. + * + * @param array $payload The payload of the JWT token. + * @return void + * @throws AuthenticationException + */ public function validatePayload(array $payload): void { $now = new DateTime(); @@ -115,6 +151,14 @@ public function validatePayload(array $payload): void throw new AuthenticationException(message: 'The token has expired', details: ['iat' => $iat->getTimestamp(), 'exp' => $exp->getTimestamp(), 'time checked' => $now->getTimestamp()]); } } + + /** + * Checks if authorization header contains a valid JWT token. + * + * @param string $authorization The authorization header. + * @return void + * @throws AuthenticationException + */ public function authorize(string $authorization): void { $token = substr(string: $authorization, offset: strlen('Bearer ')); From 5a6fe09b35c50ce443f49747a431cadd1bffaf7f Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 14:58:09 +0100 Subject: [PATCH 08/10] PR comments --- lib/Controller/EndpointsController.php | 384 +++++++++++----------- lib/Exception/AuthenticationException.php | 14 + lib/Service/AuthorizationService.php | 32 +- lib/Service/EndpointService.php | 276 +++++++++------- 4 files changed, 380 insertions(+), 326 deletions(-) diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index cc42407c..e01248f3 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -21,180 +21,180 @@ */ class EndpointsController extends Controller { - /** - * Constructor for the EndpointsController - * - * @param string $appName The name of the app - * @param IRequest $request The request object - * @param IAppConfig $config The app configuration object - * @param EndpointMapper $endpointMapper The endpoint mapper object - * @param EndpointService $endpointService Service for handling endpoint operations - */ - public function __construct( - $appName, - IRequest $request, - private IAppConfig $config, - private EndpointMapper $endpointMapper, - private EndpointService $endpointService, + /** + * Constructor for the EndpointsController + * + * @param string $appName The name of the app + * @param IRequest $request The request object + * @param IAppConfig $config The app configuration object + * @param EndpointMapper $endpointMapper The endpoint mapper object + * @param EndpointService $endpointService Service for handling endpoint operations + */ + public function __construct( + $appName, + IRequest $request, + private IAppConfig $config, + private EndpointMapper $endpointMapper, + private EndpointService $endpointService, private AuthorizationService $authorizationService - ) - { - parent::__construct($appName, $request); - } - - /** - * Returns the template of the main app's page - * - * This method renders the main page of the application, adding any necessary data to the template. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return TemplateResponse The rendered template response - */ - public function page(): TemplateResponse - { - return new TemplateResponse( - 'openconnector', - 'index', - [] - ); - } - - /** - * Retrieves a list of all endpoints - * - * This method returns a JSON response containing an array of all endpoints in the system. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse A JSON response containing the list of endpoints - */ - public function index(ObjectService $objectService, SearchService $searchService): JSONResponse - { - - $filters = $this->request->getParams(); - $fieldsToSearch = ['name', 'description', 'endpoint']; - - $searchParams = $searchService->createMySQLSearchParams(filters: $filters); - $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); - $filters = $searchService->unsetSpecialQueryParams(filters: $filters); - - return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); - } - - /** - * Retrieves a single endpoint by its ID - * - * This method returns a JSON response containing the details of a specific endpoint. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to retrieve - * @return JSONResponse A JSON response containing the endpoint details - */ - public function show(string $id): JSONResponse - { - try { - return new JSONResponse($this->endpointMapper->find(id: (int) $id)); - } catch (DoesNotExistException $exception) { - return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); - } - } - - /** - * Creates a new endpoint - * - * This method creates a new endpoint based on POST data. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @return JSONResponse A JSON response containing the created endpoint - */ - public function create(): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - - if (isset($data['id'])) { - unset($data['id']); - } - - $endpoint = $this->endpointMapper->createFromArray(object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Updates an existing endpoint - * - * This method updates an existing endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to update - * @return JSONResponse A JSON response containing the updated endpoint details - */ - public function update(int $id): JSONResponse - { - $data = $this->request->getParams(); - - foreach ($data as $key => $value) { - if (str_starts_with($key, '_')) { - unset($data[$key]); - } - } - if (isset($data['id'])) { - unset($data['id']); - } - - $endpoint = $this->endpointMapper->updateFromArray(id: (int) $id, object: $data); - - return new JSONResponse($endpoint); - } - - /** - * Deletes an endpoint - * - * This method deletes an endpoint based on its ID. - * - * @NoAdminRequired - * @NoCSRFRequired - * - * @param string $id The ID of the endpoint to delete - * @return JSONResponse An empty JSON response - */ - public function destroy(int $id): JSONResponse - { - $this->endpointMapper->delete($this->endpointMapper->find((int) $id)); - - return new JSONResponse([]); - } - - /** - * Handles generic path requests by matching against registered endpoints - * - * This method checks if the current path matches any registered endpoint patterns - * and forwards the request to the appropriate endpoint service if found - * - * @NoAdminRequired - * @NoCSRFRequired + ) + { + parent::__construct($appName, $request); + } + + /** + * Returns the template of the main app's page + * + * This method renders the main page of the application, adding any necessary data to the template. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return TemplateResponse The rendered template response + */ + public function page(): TemplateResponse + { + return new TemplateResponse( + 'openconnector', + 'index', + [] + ); + } + + /** + * Retrieves a list of all endpoints + * + * This method returns a JSON response containing an array of all endpoints in the system. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse A JSON response containing the list of endpoints + */ + public function index(ObjectService $objectService, SearchService $searchService): JSONResponse + { + + $filters = $this->request->getParams(); + $fieldsToSearch = ['name', 'description', 'endpoint']; + + $searchParams = $searchService->createMySQLSearchParams(filters: $filters); + $searchConditions = $searchService->createMySQLSearchConditions(filters: $filters, fieldsToSearch: $fieldsToSearch); + $filters = $searchService->unsetSpecialQueryParams(filters: $filters); + + return new JSONResponse(['results' => $this->endpointMapper->findAll(limit: null, offset: null, filters: $filters, searchConditions: $searchConditions, searchParams: $searchParams)]); + } + + /** + * Retrieves a single endpoint by its ID + * + * This method returns a JSON response containing the details of a specific endpoint. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to retrieve + * @return JSONResponse A JSON response containing the endpoint details + */ + public function show(string $id): JSONResponse + { + try { + return new JSONResponse($this->endpointMapper->find(id: (int)$id)); + } catch (DoesNotExistException $exception) { + return new JSONResponse(data: ['error' => 'Not Found'], statusCode: 404); + } + } + + /** + * Creates a new endpoint + * + * This method creates a new endpoint based on POST data. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse A JSON response containing the created endpoint + */ + public function create(): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + + if (isset($data['id'])) { + unset($data['id']); + } + + $endpoint = $this->endpointMapper->createFromArray(object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Updates an existing endpoint + * + * This method updates an existing endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to update + * @return JSONResponse A JSON response containing the updated endpoint details + */ + public function update(int $id): JSONResponse + { + $data = $this->request->getParams(); + + foreach ($data as $key => $value) { + if (str_starts_with($key, '_')) { + unset($data[$key]); + } + } + if (isset($data['id'])) { + unset($data['id']); + } + + $endpoint = $this->endpointMapper->updateFromArray(id: (int)$id, object: $data); + + return new JSONResponse($endpoint); + } + + /** + * Deletes an endpoint + * + * This method deletes an endpoint based on its ID. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $id The ID of the endpoint to delete + * @return JSONResponse An empty JSON response + */ + public function destroy(int $id): JSONResponse + { + $this->endpointMapper->delete($this->endpointMapper->find((int)$id)); + + return new JSONResponse([]); + } + + /** + * Handles generic path requests by matching against registered endpoints + * + * This method checks if the current path matches any registered endpoint patterns + * and forwards the request to the appropriate endpoint service if found + * + * @NoAdminRequired + * @NoCSRFRequired * @PublicPage - * - * @param string $path The request path to match - * @return JSONResponse The response from the endpoint service or 404 if no match - */ - public function handlePath(string $_path): JSONResponse - { + * + * @param string $path The request path to match + * @return JSONResponse The response from the endpoint service or 404 if no match + */ + public function handlePath(string $_path): JSONResponse + { try { $token = $this->request->getHeader('Authorization'); $this->authorizationService->authorize(authorization: $token); @@ -205,25 +205,25 @@ public function handlePath(string $_path): JSONResponse ); } - // Find matching endpoints for the given path and method - $matchingEndpoints = $this->endpointMapper->findByPathRegex( - path: $_path, - method: $this->request->getMethod() - ); - - // If no matching endpoints found, return 404 - if (empty($matchingEndpoints)) { - return new JSONResponse( - data: ['error' => 'No matching endpoint found for path and method: ' . $_path . ' ' . $this->request->getMethod()], - statusCode: 404 - ); - } - - // Get the first matching endpoint since we already filtered by method - $endpoint = reset($matchingEndpoints); - - // Forward the request to the endpoint service - return $this->endpointService->handleRequest($endpoint, $this->request, $_path); - } + // Find matching endpoints for the given path and method + $matchingEndpoints = $this->endpointMapper->findByPathRegex( + path: $_path, + method: $this->request->getMethod() + ); + + // If no matching endpoints found, return 404 + if (empty($matchingEndpoints)) { + return new JSONResponse( + data: ['error' => 'No matching endpoint found for path and method: ' . $_path . ' ' . $this->request->getMethod()], + statusCode: 404 + ); + } + + // Get the first matching endpoint since we already filtered by method + $endpoint = reset($matchingEndpoints); + + // Forward the request to the endpoint service + return $this->endpointService->handleRequest($endpoint, $this->request, $_path); + } } diff --git a/lib/Exception/AuthenticationException.php b/lib/Exception/AuthenticationException.php index d640ae39..e7e04ed9 100644 --- a/lib/Exception/AuthenticationException.php +++ b/lib/Exception/AuthenticationException.php @@ -4,14 +4,28 @@ use Exception; +/** + * Exception for storing authentication exceptions with details. + */ class AuthenticationException extends Exception { private array $details; + + /** + * @inheritDoc + * + * @param array $details The details describing why an authentication failed. + */ public function __construct(string $message, array $details) { $this->details = $details; parent::__construct($message); } + /** + * Retrieves the details to display them. + * + * @return array The details array. + */ public function getDetails(): array { return $this->details; diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php index b81b1948..98323e10 100644 --- a/lib/Service/AuthorizationService.php +++ b/lib/Service/AuthorizationService.php @@ -44,10 +44,12 @@ class AuthorizationService * @param ConsumerMapper $consumerMapper */ public function __construct( - private readonly IUserManager $userManager, - private readonly IUserSession $userSession, + private readonly IUserManager $userManager, + private readonly IUserSession $userSession, private readonly ConsumerMapper $consumerMapper, - ) {} + ) + { + } /** * Find the issuer (consumer) for the request. @@ -60,7 +62,7 @@ private function findIssuer(string $issuer): Consumer { $consumers = $this->consumerMapper->findAll(filters: ['name' => $issuer]); - if(count($consumers) === 0) { + if (count($consumers) === 0) { throw new AuthenticationException(message: 'The issuer was not found', details: ['iss' => $issuer]); } @@ -73,7 +75,8 @@ private function findIssuer(string $issuer): Consumer * @param JWS $token The unserialized token. * @return void */ - private function checkHeaders(JWS $token): void { + private function checkHeaders(JWS $token): void + { $headerChecker = new HeaderCheckerManager( checkers: [ new AlgorithmChecker(array_merge(self::HMAC_ALGORITHMS, self::PKCS1_ALGORITHMS, self::PSS_ALGORITHMS)) @@ -113,7 +116,7 @@ private function getJWK(string $publicKey, string $algorithm): JWKSet haystack: self::PSS_ALGORITHMS ) === true ) { - $stamp = microtime().getmypid(); + $stamp = microtime() . getmypid(); $filename = "/var/tmp/publickey-$stamp"; file_put_contents($filename, base64_decode($publicKey)); $jwk = new JWKSet([JWKFactory::createFromKeyFile(file: $filename)]); @@ -134,20 +137,20 @@ public function validatePayload(array $payload): void { $now = new DateTime(); - if(isset($payload['iat']) === true) { - $iat = new DateTime('@'.$payload['iat']); + if (isset($payload['iat']) === true) { + $iat = new DateTime('@' . $payload['iat']); } else { throw new AuthenticationException(message: 'The token has no time of creation', details: ['iat' => null]); } - if(isset($payload['exp']) === true) { - $exp = new DateTime('@'.$payload['exp']); + if (isset($payload['exp']) === true) { + $exp = new DateTime('@' . $payload['exp']); } else { $exp = clone $iat; $exp->modify('+1 Hour'); } - if($exp->diff($now)->format('%R') === '+') { + if ($exp->diff($now)->format('%R') === '+') { throw new AuthenticationException(message: 'The token has expired', details: ['iat' => $iat->getTimestamp(), 'exp' => $exp->getTimestamp(), 'time checked' => $now->getTimestamp()]); } } @@ -163,7 +166,7 @@ public function authorize(string $authorization): void { $token = substr(string: $authorization, offset: strlen('Bearer ')); - if($token === '') { + if ($token === '') { throw new AuthenticationException(message: 'No token has been provided', details: []); } @@ -182,10 +185,9 @@ public function authorize(string $authorization): void $serializerManager = new JWSSerializerManager([new CompactSerializer()]); - $jws = $serializerManager->unserialize(input: $token); - try{ + try { $this->checkHeaders($jws); } catch (InvalidHeaderException $exception) { throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => $exception->getMessage()]); @@ -199,7 +201,7 @@ public function authorize(string $authorization): void $jwkSet = $this->getJWK(publicKey: $publicKey, algorithm: $algorithm); - if($verifier->verifyWithKeySet(jws: $jws, jwkset: $jwkSet, signatureIndex: 0) === false) { + if ($verifier->verifyWithKeySet(jws: $jws, jwkset: $jwkSet, signatureIndex: 0) === false) { throw new AuthenticationException(message: 'The token could not be validated', details: ['reason' => 'The token does not match the public key']); } $this->validatePayload($payload); diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php index be3a974a..1636d74e 100644 --- a/lib/Service/EndpointService.php +++ b/lib/Service/EndpointService.php @@ -30,65 +30,77 @@ * This class provides functionality to handle requests to endpoints, either by * connecting to a schema within a register or by proxying to a source. */ -class EndpointService { - - /** - * Constructor for EndpointService - * - * @param ObjectService $objectService Service for handling object operations - * @param CallService $callService Service for making external API calls - * @param LoggerInterface $logger Logger interface for error logging - */ - public function __construct( - private readonly ObjectService $objectService, - private readonly CallService $callService, - private readonly LoggerInterface $logger, - private readonly IURLGenerator $urlGenerator, - ) {} - - /** - * Handles incoming requests to endpoints - * - * This method determines how to handle the request based on the endpoint configuration. - * It either routes to a schema within a register or proxies to an external source. - * - * @param Endpoint $endpoint The endpoint configuration to handle - * @param IRequest $request The incoming request object - * @return JSONResponse Response containing the result - * @throws \Exception When endpoint configuration is invalid - */ - public function handleRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse { - try { - // Check if endpoint connects to a schema - if ($endpoint->getTargetType() === 'register/schema') { - // Handle CRUD operations via ObjectService - return $this->handleSchemaRequest($endpoint, $request, $path); - } - - // Check if endpoint connects to a source - if ($endpoint->getTargetType() === 'api') { - // Proxy request to source via CallService - return $this->handleSourceRequest($endpoint, $request); - } - - // Invalid endpoint configuration - throw new \Exception('Endpoint must specify either a schema or source connection'); - - } catch (\Exception $e) { - $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); - return new JSONResponse( - ['error' => $e->getMessage()], - 400 - ); - } - } +class EndpointService +{ + + /** + * Constructor for EndpointService + * + * @param ObjectService $objectService Service for handling object operations + * @param CallService $callService Service for making external API calls + * @param LoggerInterface $logger Logger interface for error logging + */ + public function __construct( + private readonly ObjectService $objectService, + private readonly CallService $callService, + private readonly LoggerInterface $logger, + private readonly IURLGenerator $urlGenerator, + ) + { + } + + /** + * Handles incoming requests to endpoints + * + * This method determines how to handle the request based on the endpoint configuration. + * It either routes to a schema within a register or proxies to an external source. + * + * @param Endpoint $endpoint The endpoint configuration to handle + * @param IRequest $request The incoming request object + * @return JSONResponse Response containing the result + * @throws \Exception When endpoint configuration is invalid + */ + public function handleRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse + { + try { + // Check if endpoint connects to a schema + if ($endpoint->getTargetType() === 'register/schema') { + // Handle CRUD operations via ObjectService + return $this->handleSchemaRequest($endpoint, $request, $path); + } + + // Check if endpoint connects to a source + if ($endpoint->getTargetType() === 'api') { + // Proxy request to source via CallService + return $this->handleSourceRequest($endpoint, $request); + } + + // Invalid endpoint configuration + throw new \Exception('Endpoint must specify either a schema or source connection'); + + } catch (\Exception $e) { + $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); + return new JSONResponse( + ['error' => $e->getMessage()], + 400 + ); + } + } + /** + * Parses a path to get the parameters in a path. + * + * @param array $endpointArray The endpoint array from an endpoint object. + * @param string $path The path called by the client. + * + * @return array The parsed path with the fields having the correct name. + */ private function getPathParameters(array $endpointArray, string $path): array { $pathParts = explode(separator: '/', string: $path); $endpointArrayNormalized = array_map( - function($item) { + function ($item) { return str_replace( search: ['{{', '{{ ', '}}', '}}'], replace: '', @@ -113,28 +125,39 @@ function($item) { return $pathParams; } + /** + * Fetch objects for the endpoint. + * + * @param \OCA\OpenRegister\Service\ObjectService|QBMapper $mapper The mapper for the object type + * @param array $parameters The parameters from the request + * @param array $pathParams The parameters in the path + * @param int $status The HTTP status to return. + * @return Entity|array The object(s) confirming to the request. + * @throws Exception + */ private function getObjects( \OCA\OpenRegister\Service\ObjectService|QBMapper $mapper, - array $parameters, - array $pathParams, - int &$status = 200 + array $parameters, + array $pathParams, + int &$status = 200 ): Entity|array { - if(isset($pathParams['id']) === true && $pathParams['id'] === end($pathParams)) { + if (isset($pathParams['id']) === true && $pathParams['id'] === end($pathParams)) { return $mapper->find($pathParams['id']); } else if (isset($pathParams['id']) === true) { - while(prev($pathParams) !== $pathParams['id']){}; + while (prev($pathParams) !== $pathParams['id']) { + }; $property = next($pathParams); - if(next($pathParams) !== false) { + if (next($pathParams) !== false) { $id = pos($pathParams); } $main = $mapper->find($pathParams['id'])->getObject(); $ids = $main[$property]; - if(isset($id) === true && in_array(needle: $id, haystack: $ids) === true) { + if (isset($id) === true && in_array(needle: $id, haystack: $ids) === true) { return $mapper->findSubObjects([$id], $property)[0]; } else if (isset($id) === true) { @@ -152,7 +175,7 @@ private function getObjects( 'count' => $result['total'], ]; - if($result['page'] < $result['pages']) { + if ($result['page'] < $result['pages']) { $parameters['page'] = $result['page'] + 1; $parameters['_path'] = implode('/', $pathParams); @@ -164,7 +187,7 @@ private function getObjects( ) ); } - if($result['page'] > 1) { + if ($result['page'] > 1) { $parameters['page'] = $result['page'] - 1; $parameters['_path'] = implode('/', $pathParams); @@ -181,16 +204,17 @@ private function getObjects( return $returnArray; } - /** - * Handles requests for schema-based endpoints - * - * @param Endpoint $endpoint The endpoint configuration - * @param IRequest $request The incoming request - * @return JSONResponse - */ - private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse { - // Get request method - $method = $request->getMethod(); + /** + * Handles requests for schema-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return JSONResponse + */ + private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse + { + // Get request method + $method = $request->getMethod(); $target = explode('/', $endpoint->getTargetId()); $register = $target[0]; @@ -206,39 +230,52 @@ private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, stri $status = 200; - // Route to appropriate ObjectService method based on HTTP method - return match($method) { - 'GET' => new JSONResponse( - $this->getObjects(mapper: $mapper, parameters: $parameters, pathParams: $pathParams, status: $status), statusCode: $status - ), - 'POST' => new JSONResponse( - $mapper->createFromArray(object: $parameters) - ), - 'PUT' => new JSONResponse( - $mapper->updateFromArray($request->getParams()['id'], $request->getParams(), true, true) - ), - 'DELETE' => new JSONResponse( - $mapper->delete($request->getParams()) - ), - default => throw new \Exception('Unsupported HTTP method') - }; - } + // Route to appropriate ObjectService method based on HTTP method + return match ($method) { + 'GET' => new JSONResponse( + $this->getObjects(mapper: $mapper, parameters: $parameters, pathParams: $pathParams, status: $status), statusCode: $status + ), + 'POST' => new JSONResponse( + $mapper->createFromArray(object: $parameters) + ), + 'PUT' => new JSONResponse( + $mapper->updateFromArray($request->getParams()['id'], $request->getParams(), true, true) + ), + 'DELETE' => new JSONResponse( + $mapper->delete($request->getParams()) + ), + default => throw new \Exception('Unsupported HTTP method') + }; + } + /** + * Gets the raw content for an http request from the input stream. + * + * @return string The raw content body for an http request + */ private function getRawContent(): string { return file_get_contents(filename: 'php://input'); } + /** + * Get all headers for a HTTP request. + * + * @param array $server The server data from the request. + * @param bool $proxyHeaders Whether the proxy headers should be returned. + * + * @return array The resulting headers. + */ private function getHeaders(array $server, bool $proxyHeaders = false): array { $headers = array_filter( array: $server, - callback: function (string $key) use ($proxyHeaders){ - if(str_starts_with($key, 'HTTP_') === false) { + callback: function (string $key) use ($proxyHeaders) { + if (str_starts_with($key, 'HTTP_') === false) { return false; } else if ($proxyHeaders === false && (str_starts_with(haystack: $key, needle: 'HTTP_X_FORWARDED') - || $key === 'HTTP_X_REAL_IP' || $key === 'HTTP_X_ORIGINAL_URI' + || $key === 'HTTP_X_REAL_IP' || $key === 'HTTP_X_ORIGINAL_URI' ) ) { return false; @@ -253,40 +290,41 @@ private function getHeaders(array $server, bool $proxyHeaders = false): array return array_combine( array_map( - callback: function($key) { + callback: function ($key) { return strtolower(string: substr(string: $key, offset: 5)); - }, + }, array: $keys), $headers ); } - /** - * Handles requests for source-based endpoints - * - * @param Endpoint $endpoint The endpoint configuration - * @param IRequest $request The incoming request - * @return JSONResponse - */ - private function handleSourceRequest(Endpoint $endpoint, IRequest $request): JSONResponse { + /** + * Handles requests for source-based endpoints + * + * @param Endpoint $endpoint The endpoint configuration + * @param IRequest $request The incoming request + * @return JSONResponse + */ + private function handleSourceRequest(Endpoint $endpoint, IRequest $request): JSONResponse + { $headers = $this->getHeaders($request->server); - // Proxy the request to the source via CallService - $response = $this->callService->call( - source: $endpoint->getSource(), - endpoint: $endpoint->getPath(), - method: $request->getMethod(), - config: [ - 'query' => $request->getParams(), - 'headers' => $headers, - 'body' => $this->getRawContent(), - ] - ); - - return new JSONResponse( - $response->getResponse(), - $response->getStatusCode() - ); - } + // Proxy the request to the source via CallService + $response = $this->callService->call( + source: $endpoint->getSource(), + endpoint: $endpoint->getPath(), + method: $request->getMethod(), + config: [ + 'query' => $request->getParams(), + 'headers' => $headers, + 'body' => $this->getRawContent(), + ] + ); + + return new JSONResponse( + $response->getResponse(), + $response->getStatusCode() + ); + } } From bf178e6ae5b83900aa133ea3eae4a4636d03f400 Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 15:34:29 +0100 Subject: [PATCH 09/10] More handling of PR comments --- lib/Controller/EndpointsController.php | 1 - lib/Db/EndpointMapper.php | 2 +- lib/Migration/Version1Date20241218122708.php | 2 +- lib/Service/AuthorizationService.php | 15 +++------------ lib/Service/EndpointService.php | 14 +++++++------- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/lib/Controller/EndpointsController.php b/lib/Controller/EndpointsController.php index e01248f3..2bbab0be 100644 --- a/lib/Controller/EndpointsController.php +++ b/lib/Controller/EndpointsController.php @@ -73,7 +73,6 @@ public function page(): TemplateResponse */ public function index(ObjectService $objectService, SearchService $searchService): JSONResponse { - $filters = $this->request->getParams(); $fieldsToSearch = ['name', 'description', 'endpoint']; diff --git a/lib/Db/EndpointMapper.php b/lib/Db/EndpointMapper.php index 0479b765..a7727cbe 100644 --- a/lib/Db/EndpointMapper.php +++ b/lib/Db/EndpointMapper.php @@ -134,7 +134,7 @@ public function findByPathRegex(string $path, string $method): array $pattern = $endpoint->getEndpointRegex(); // Skip if no regex pattern is set - if (empty($pattern)) { + if (empty($pattern) === true) { return false; } diff --git a/lib/Migration/Version1Date20241218122708.php b/lib/Migration/Version1Date20241218122708.php index e92966ed..2925f4a0 100644 --- a/lib/Migration/Version1Date20241218122708.php +++ b/lib/Migration/Version1Date20241218122708.php @@ -39,7 +39,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt */ $schema = $schemaClosure(); - if($schema->hasTable(tableName: 'openconnector_consumers') === true) { + if ($schema->hasTable(tableName: 'openconnector_consumers') === true) { $table = $schema->getTable(tableName: 'openconnector_consumers'); $table->dropColumn('authorization_configuration'); } diff --git a/lib/Service/AuthorizationService.php b/lib/Service/AuthorizationService.php index 98323e10..dd50243f 100644 --- a/lib/Service/AuthorizationService.php +++ b/lib/Service/AuthorizationService.php @@ -98,23 +98,14 @@ private function checkHeaders(JWS $token): void private function getJWK(string $publicKey, string $algorithm): JWKSet { - if ( - in_array(needle: $algorithm, haystack: self::HMAC_ALGORITHMS) === true - ) { + if (in_array(needle: $algorithm, haystack: self::HMAC_ALGORITHMS) === true) { return new JWKSet([ JWKFactory::createFromSecret( secret: $publicKey, additional_values: ['alg' => $algorithm, 'use' => 'sig']) ]); - } else if ( - in_array( - needle: $algorithm, - haystack: self::PKCS1_ALGORITHMS - ) === true - || in_array( - needle: $algorithm, - haystack: self::PSS_ALGORITHMS - ) === true + } else if (in_array(needle: $algorithm, haystack: self::PKCS1_ALGORITHMS) === true + || in_array(needle: $algorithm, haystack: self::PSS_ALGORITHMS) === true ) { $stamp = microtime() . getmypid(); $filename = "/var/tmp/publickey-$stamp"; diff --git a/lib/Service/EndpointService.php b/lib/Service/EndpointService.php index 1636d74e..a4a7efb4 100644 --- a/lib/Service/EndpointService.php +++ b/lib/Service/EndpointService.php @@ -58,7 +58,7 @@ public function __construct( * @param Endpoint $endpoint The endpoint configuration to handle * @param IRequest $request The incoming request object * @return JSONResponse Response containing the result - * @throws \Exception When endpoint configuration is invalid + * @throws Exception When endpoint configuration is invalid */ public function handleRequest(Endpoint $endpoint, IRequest $request, string $path): JSONResponse { @@ -76,9 +76,9 @@ public function handleRequest(Endpoint $endpoint, IRequest $request, string $pat } // Invalid endpoint configuration - throw new \Exception('Endpoint must specify either a schema or source connection'); + throw new Exception('Endpoint must specify either a schema or source connection'); - } catch (\Exception $e) { + } catch (Exception $e) { $this->logger->error('Error handling endpoint request: ' . $e->getMessage()); return new JSONResponse( ['error' => $e->getMessage()], @@ -145,8 +145,10 @@ private function getObjects( if (isset($pathParams['id']) === true && $pathParams['id'] === end($pathParams)) { return $mapper->find($pathParams['id']); } else if (isset($pathParams['id']) === true) { + + // Set the array pointer to the location of the id, so we can fetch the parameters further down the line in order. while (prev($pathParams) !== $pathParams['id']) { - }; + } $property = next($pathParams); @@ -179,7 +181,6 @@ private function getObjects( $parameters['page'] = $result['page'] + 1; $parameters['_path'] = implode('/', $pathParams); - $returnArray['next'] = $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->linkToRoute( routeName: 'openconnector.endpoints.handlepath', @@ -244,7 +245,7 @@ private function handleSchemaRequest(Endpoint $endpoint, IRequest $request, stri 'DELETE' => new JSONResponse( $mapper->delete($request->getParams()) ), - default => throw new \Exception('Unsupported HTTP method') + default => throw new Exception('Unsupported HTTP method') }; } @@ -307,7 +308,6 @@ private function getHeaders(array $server, bool $proxyHeaders = false): array */ private function handleSourceRequest(Endpoint $endpoint, IRequest $request): JSONResponse { - $headers = $this->getHeaders($request->server); // Proxy the request to the source via CallService From f35e9f35a2e014cfd1ff522537b1cd6e8c9736df Mon Sep 17 00:00:00 2001 From: Robert Zondervan Date: Wed, 18 Dec 2024 15:37:28 +0100 Subject: [PATCH 10/10] Yet some more PR comments --- lib/Service/ObjectService.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 692e76ae..c1d8e6a5 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -252,9 +252,20 @@ public function getOpenRegisters(): ?\OCA\OpenRegister\Service\ObjectService return null; } + /** + * Get the mapper for the given objecttype (usually the proper instantiation of the objectService of OpenRegister. + * + * @param string|null $objecttype The objecttype as string + * @param int|null $schema The openregister schema + * @param int|null $register The openregister register + * + * @return QBMapper|\OCA\OpenRegister\Service\ObjectService|null The resulting mapper + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ public function getMapper(?string $objecttype = null, ?int $schema = null, ?int $register = null): QBMapper|\OCA\OpenRegister\Service\ObjectService|null { - if($register !== null && $schema !== null && $objecttype === null) { + if ($register !== null && $schema !== null && $objecttype === null) { return $this->getOpenRegisters()->getMapper(register: $register, schema: $schema); }