From 78ce281b0c0fb27a90289af8cc99abd6ea9cfa6e Mon Sep 17 00:00:00 2001 From: Zi Xing Date: Sun, 2 Jan 2022 14:51:27 -0500 Subject: [PATCH] Added basic router initialization and basic request handling (Prototype) --- Makefile | 5 +- api_frontend/.htaccess | 3 + api_frontend/index.php | 6 + .../Methods/v1/{Ping.php => PingMethod.php} | 2 +- api_handler/configuration.json | 18 +- api_handler/package.json | 35 ++ .../Abstracts/{Command.php => Method.php} | 2 +- src/KimchiAPI/Classes/API.php | 172 +++++++++ src/KimchiAPI/Classes/Request.php | 166 +++++++++ src/KimchiAPI/Classes/Router.php | 338 ++++++++++++++++++ src/KimchiAPI/Exceptions/ApiException.php | 21 ++ .../Exceptions/ConnectionBlockedException.php | 21 ++ src/KimchiAPI/Exceptions/RouterException.php | 21 ++ src/KimchiAPI/KimchiAPI.php | 92 ++++- src/KimchiAPI/Objects/Configuration.php | 67 +++- .../Configuration/MethodConfiguration.php | 65 ++++ .../Configuration/ServerConfiguration.php | 81 ++++- .../Configuration/VersionConfiguration.php | 68 ++++ src/KimchiAPI/Utilities/Client.php | 81 +++++ src/KimchiAPI/package.json | 99 +++-- 20 files changed, 1305 insertions(+), 58 deletions(-) create mode 100644 api_frontend/.htaccess create mode 100644 api_frontend/index.php rename api_handler/Methods/v1/{Ping.php => PingMethod.php} (66%) create mode 100644 api_handler/package.json rename src/KimchiAPI/Abstracts/{Command.php => Method.php} (96%) create mode 100644 src/KimchiAPI/Classes/API.php create mode 100644 src/KimchiAPI/Classes/Request.php create mode 100644 src/KimchiAPI/Classes/Router.php create mode 100644 src/KimchiAPI/Exceptions/ApiException.php create mode 100644 src/KimchiAPI/Exceptions/ConnectionBlockedException.php create mode 100644 src/KimchiAPI/Exceptions/RouterException.php create mode 100644 src/KimchiAPI/Objects/Configuration/MethodConfiguration.php create mode 100644 src/KimchiAPI/Objects/Configuration/VersionConfiguration.php create mode 100644 src/KimchiAPI/Utilities/Client.php diff --git a/Makefile b/Makefile index 10a1765..f989843 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,15 @@ clean: build: mkdir build ppm --no-intro --cerror --compile="src/KimchiAPI" --directory="build" + ppm --no-intro --cerror --compile="api_handler" --directory="build" update: ppm --generate-package="src/KimchiAPI" install: ppm --no-intro --no-prompt --fix-conflict --install="build/net.intellivoid.kimchi_api.ppm" + ppm --no-intro --no-prompt --fix-conflict --install="build/net.intellivoid.test_api.ppm" install_fast: - ppm --no-intro --no-prompt --fix-conflict --skip-dependencies --install="build/net.intellivoid.kimchi_api.ppm" \ No newline at end of file + ppm --no-intro --no-prompt --fix-conflict --skip-dependencies --install="build/net.intellivoid.kimchi_api.ppm" + ppm --no-intro --no-prompt --fix-conflict --skip-dependencies --install="build/net.intellivoid.test_api.ppm" \ No newline at end of file diff --git a/api_frontend/.htaccess b/api_frontend/.htaccess new file mode 100644 index 0000000..e27b817 --- /dev/null +++ b/api_frontend/.htaccess @@ -0,0 +1,3 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule . index.php [L] \ No newline at end of file diff --git a/api_frontend/index.php b/api_frontend/index.php new file mode 100644 index 0000000..3062953 --- /dev/null +++ b/api_frontend/index.php @@ -0,0 +1,6 @@ +ResourcesPath = $resources_path; + $this->ConfigurationFilePath = $this->ResourcesPath . DIRECTORY_SEPARATOR . 'configuration.json'; + + if(file_exists($this->ConfigurationFilePath) == false) + throw new IOException('The API configuration file \'configuration.json\' does not exist'); + + $DecodedConfiguration = json_decode(file_get_contents($this->ConfigurationFilePath), true); + + if($DecodedConfiguration == false) + throw new InternalServerException('Cannot read configuration file, ' . json_last_error_msg()); + + $this->Configuration = Configuration::fromArray($DecodedConfiguration); + $this->Router = new Router(); + } + + /** + * Initializes the Kimchi API server. + * + * @return void + * @throws ApiException + * @throws ConnectionBlockedException + * @throws DatabaseException + */ + public function initialize() + { + if(defined('KIMCHI_API_INITIALIZED')) + throw new ApiException('Cannot initialize ' . $this->Configuration->Name . ', another API is already initialized'); + + define('KIMCHI_API_RESOURCES_PATH', $this->ResourcesPath); + define('KIMCHI_API_CONFIGURATION_PATH', $this->ConfigurationFilePath); + define('KIMCHI_API_NAME', $this->Configuration->Name); + define('KIMCHI_API_ROOT_PATH', $this->Configuration->ServerConfiguration->RootPath); + define('KIMCHI_API_SIGNATURES', $this->Configuration->ServerConfiguration->ApiSignature); + define('KIMCHI_API_FRAMEWORK_SIGNATURE', $this->Configuration->ServerConfiguration->FrameworkSignature); + define('KIMCHI_API_LOGGING_ENABLED', $this->Configuration->ServerConfiguration->LoggingEnabled); + define('KIMCHI_API_HEADERS', $this->Configuration->ServerConfiguration->Headers); + + $this->defineClientDefinitions(); + $this->defineRoutes(); + + define('KIMCHI_API_INITIALIZED', 1); + } + + /** + * @return Configuration + */ + public function getConfiguration(): Configuration + { + return $this->Configuration; + } + + /** + * @return string + */ + public function getResourcesPath(): string + { + return $this->ResourcesPath; + } + + /** + * @return string + */ + public function getConfigurationFilePath(): string + { + return $this->ConfigurationFilePath; + } + + /** + * @return Router + */ + public function getRouter(): Router + { + return $this->Router; + } + + /** + * @return void + * @throws \KimchiAPI\Exceptions\RouterException + */ + private function defineRoutes() + { + foreach($this->Configuration->Versions as $version) + { + foreach($version->Methods as $method) + { + $full_path = '/' . $version->Version . '/' . $method->Path; + $this->Router->map(implode('|', $method->Methods), $full_path, function() use ($version, $method, $full_path) + { + print($full_path); + exit(); + }, $version->Version . '/' . $method->Class); + } + } + } + + /** + * @throws DatabaseException + * @throws ConnectionBlockedException + */ + private function defineClientDefinitions() + { + define('KIMCHI_CLIENT_IP_ADDRESS', Client::getClientIP()); + + if($this->Configuration->ServerConfiguration->KhmEnabled) + { + $khm = new khm(); + $IdentifiedClient = $khm->identify(); + + define('KIMCHI_KHM_ENABLED', true); + define('KIMCHI_KHM_FIREWALL', $this->Configuration->ServerConfiguration->FirewallDeny); + define('KIMCHI_KHM_FLAGS', $IdentifiedClient->Flags); + + foreach($this->Configuration->ServerConfiguration->FirewallDeny as $item) + { + if(in_array($item, $IdentifiedClient->Flags)) + { + throw new ConnectionBlockedException('Firewall block rule ' . $item); + } + } + } + else + { + define('KIMCHI_KHM_ENABLED', false); + define('KIMCHI_KHM_FIREWALL', null); + define('KIMCHI_KHM_FLAGS', null); + } + } + } \ No newline at end of file diff --git a/src/KimchiAPI/Classes/Request.php b/src/KimchiAPI/Classes/Request.php new file mode 100644 index 0000000..09f0c6d --- /dev/null +++ b/src/KimchiAPI/Classes/Request.php @@ -0,0 +1,166 @@ + '[0-9]++', + 'a' => '[0-9A-Za-z]++', + 'h' => '[0-9A-Fa-f]++', + '*' => '.+?', + '**' => '.++', + '' => '[^/\.]++' + ); + + /** + * Create router in one call from config. + * + * @param array $routes + * @param string $basePath + * @param array $matchTypes + * @throws RouterException + */ + public function __construct(array $routes=[], string $basePath = '', array $matchTypes=[]) + { + $this->addRoutes($routes); + $this->setBasePath($basePath); + $this->addMatchTypes($matchTypes); + } + + /** + * Retrieves all routes. + * Useful if you want to process or display routes. + * @return array All routes. + */ + public function getRoutes(): array + { + return $this->routes; + } + + /** + * Add multiple routes at once from array in the following format: + * + * $routes = array( + * array($method, $route, $target, $name) + * ); + * + * @param array $routes + * @return void + * @throws RouterException + */ + public function addRoutes(array $routes) + { + /** @noinspection PhpConditionAlreadyCheckedInspection */ + if(!is_array($routes) && !$routes instanceof Traversable) + { + throw new RouterException('Routes should be an array or an instance of Traversable'); + } + + foreach($routes as $route) + { + call_user_func_array(array($this, 'map'), $route); + } + } + + /** + * Set the base path. + * Useful if you are running your application from a subdirectory. + * @param $basePath + */ + public function setBasePath($basePath) + { + $this->basePath = $basePath; + } + + /** + * Add named match types. It uses array_merge so keys can be overwritten. + * + * @param array $matchTypes The key is the name and the value is the regex. + */ + public function addMatchTypes(array $matchTypes) + { + $this->matchTypes = array_merge($this->matchTypes, $matchTypes); + } + + /** + * Map a route to a target + * + * @param string $method One of 5 HTTP Methods, or a pipe-separated list of multiple HTTP Methods (GET|POST|PATCH|PUT|DELETE) + * @param string $route The route regex, custom regex must start with an @. You can use multiple pre-set regex filters, like [i:id] + * @param mixed $target The target where this route should point to. Can be anything. + * @param string|null $name Optional name of this route. Supply if you want to reverse route this url in your application. + * @throws RouterException + * @noinspection PhpMissingParamTypeInspection + * @noinspection PhpUnnecessaryCurlyVarSyntaxInspection + * @noinspection RedundantSuppression + */ + public function map(string $method, string $route, $target, string $name=null) + { + $route = KIMCHI_API_ROOT_PATH . $route; + $this->routes[] = array($method, $route, $target, $name); + + if($name) + { + if(isset($this->namedRoutes[$name])) + { + throw new RouterException("Can not redeclare route '{$name}'"); + } + + $this->namedRoutes[$name] = $route; + } + } + + /** + * Reversed routing + * + * Generate the URL for a named route. Replace regexes with supplied parameters + * + * @param string $routeName The name of the route. + * @param array $params + * @return string The URL of the route with named parameters in place. + * @throws RouterException + * @noinspection PhpUnnecessaryCurlyVarSyntaxInspection + */ + public function generate(string $routeName, array $params=[]): string + { + + // Check if named route exists + if(!isset($this->namedRoutes[$routeName])) + { + throw new RouterException("Route '{$routeName}' does not exist."); + } + + // Replace named parameters + $route = $this->namedRoutes[$routeName]; + + // prepend base path to route url again + $url = $this->basePath . $route; + + /** @noinspection RegExpRedundantEscape */ + if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) + { + + foreach($matches as $index => $match) + { + /** @noinspection PhpUnusedLocalVariableInspection */ + list($block, $pre, $type, $param, $optional) = $match; + + if ($pre) + { + $block = substr($block, 1); + } + + if(isset($params[$param])) + { + // Part is found, replace for param value + $url = str_replace($block, $params[$param], $url); + } + elseif ($optional && $index !== 0) + { + // Only strip proceeding slash if it's not at the base + $url = str_replace($pre . $block, '', $url); + } + else + { + // Strip match block + $url = str_replace($block, '', $url); + } + } + + } + + return $url; + } + + /** + * Match a given Request Url against stored routes + * @param string|null $requestUrl + * @param string|null $requestMethod + * @return array|boolean Array with route information on success, false on failure (no match). + * @noinspection PhpIssetCanBeReplacedWithCoalesceInspection + */ + public function match(?string $requestUrl=null, string $requestMethod = null) + { + + $params = array(); + /** @noinspection PhpUnusedLocalVariableInspection */ + $match = false; + + // set Request Url if it isn't passed as parameter + if($requestUrl === null) + { + $requestUrl = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/'; + } + + // strip base path from request url + $requestUrl = substr($requestUrl, strlen($this->basePath)); + + // Strip query string (?a=b) from Request Url + /** @noinspection SpellCheckingInspection */ + if (($strpos = strpos($requestUrl, '?')) !== false) + { + $requestUrl = substr($requestUrl, 0, $strpos); + } + + // set Request Method if it isn't passed as a parameter + if($requestMethod === null) + { + $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; + } + + foreach($this->routes as $handler) + { + + list($methods, $route, $target, $name) = $handler; + $method_match = (stripos($methods, $requestMethod) !== false); + + // Method did not match, continue to next route. + if (!$method_match) continue; + + if ($route === '*') + { + // * wildcard (matches all) + $match = true; + } + elseif (isset($route[0]) && $route[0] === '@') + { + // @ regex delimiter + $pattern = '`' . substr($route, 1) . '`u'; + $match = preg_match($pattern, $requestUrl, $params) === 1; + } + elseif (($position = strpos($route, '[')) === false) + { + // No params in url, do string comparison + $match = strcmp($requestUrl, $route) === 0; + } + else + { + // Compare longest non-param string with url + if (strncmp($requestUrl, $route, $position) !== 0) + { + continue; + } + $regex = $this->compileRoute($route); + $match = preg_match($regex, $requestUrl, $params) === 1; + } + + if ($match) + { + + if ($params) + { + foreach($params as $key => $value) + { + if(is_numeric($key)) unset($params[$key]); + } + } + + Request::setDefinedDynamicParameters($params); + + return array( + 'target' => $target, + 'params' => $params, + 'name' => $name + ); + } + } + return false; + } + + /** + * Compile the regex for a given route (EXPENSIVE) + * @param $route + * @return string + */ + protected function compileRoute($route): string + { + /** @noinspection RegExpRedundantEscape */ + if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) + { + + $matchTypes = $this->matchTypes; + foreach($matches as $match) + { + list($block, $pre, $type, $param, $optional) = $match; + + if (isset($matchTypes[$type])) + { + $type = $matchTypes[$type]; + } + + if ($pre === '.') + { + $pre = '\.'; + } + + $optional = $optional !== '' ? '?' : null; + + //Older versions of PCRE require the 'P' in (?P) + $pattern = '(?:' + . ($pre !== '' ? $pre : null) + . '(' + . ($param !== '' ? "?P<$param>" : null) + . $type + . ')' + . $optional + . ')' + . $optional; + + $route = str_replace($block, $pattern, $route); + } + + } + return "`^(?J)$route$`u"; + } + + } \ No newline at end of file diff --git a/src/KimchiAPI/Exceptions/ApiException.php b/src/KimchiAPI/Exceptions/ApiException.php new file mode 100644 index 0000000..67b22e8 --- /dev/null +++ b/src/KimchiAPI/Exceptions/ApiException.php @@ -0,0 +1,21 @@ +message = $message; + $this->code = $code; + } + } \ No newline at end of file diff --git a/src/KimchiAPI/Exceptions/ConnectionBlockedException.php b/src/KimchiAPI/Exceptions/ConnectionBlockedException.php new file mode 100644 index 0000000..51cc305 --- /dev/null +++ b/src/KimchiAPI/Exceptions/ConnectionBlockedException.php @@ -0,0 +1,21 @@ +message = $message; + $this->code = $code; + } + } \ No newline at end of file diff --git a/src/KimchiAPI/Exceptions/RouterException.php b/src/KimchiAPI/Exceptions/RouterException.php new file mode 100644 index 0000000..3ca00f7 --- /dev/null +++ b/src/KimchiAPI/Exceptions/RouterException.php @@ -0,0 +1,21 @@ +message = $message; + $this->code = $code; + } + } \ No newline at end of file diff --git a/src/KimchiAPI/KimchiAPI.php b/src/KimchiAPI/KimchiAPI.php index 2027cf9..689a85d 100644 --- a/src/KimchiAPI/KimchiAPI.php +++ b/src/KimchiAPI/KimchiAPI.php @@ -1,14 +1,25 @@ commands_paths, true)) { @@ -115,17 +126,17 @@ * @param string $command * @param string $filepath * - * @return Command|null + * @return Method|null */ - public function getCommandObject(string $command, string $filepath = ''): ?Command + public function getCommandObject(string $command, string $filepath = ''): ?Method { if (isset($this->commands_objects[$command])) { return $this->commands_objects[$command]; } - $which = [Command::AUTH_SYSTEM]; - $which[] = Command::AUTH_USER; + $which = [Method::AUTH_SYSTEM]; + $which[] = Method::AUTH_USER; foreach ($which as $auth) { @@ -163,7 +174,7 @@ } // Start with default namespace. - $command_namespace = __NAMESPACE__ . '\\Commands\\' . $auth . 'Commands'; + $command_namespace = __NAMESPACE__ . '\\Methods\\' . $auth . 'Methods'; // Check if we can get the namespace from the file (if passed). if ($filepath && !($command_namespace = Converter::getFileNamespace($filepath))) @@ -171,7 +182,7 @@ return null; } - $command_class = $command_namespace . '\\' . Converter::ucFirstUnicode($command) . 'Command'; + $command_class = $command_namespace . '\\' . Converter::ucFirstUnicode($command) . 'Method'; if (class_exists($command_class)) { @@ -180,4 +191,65 @@ return null; } + + /** + * @param string $package + * @param bool $import_dependencies + * @param bool $throw_error + * @throws AutoloaderException + * @throws Exceptions\ApiException + * @throws Exceptions\ConnectionBlockedException + * @throws Exceptions\InternalServerException + * @throws IOException + * @throws InvalidComponentException + * @throws InvalidPackageLockException + * @throws PackageNotFoundException + * @throws VersionNotFoundException + * @throws DatabaseException + */ + public static function exec(string $package, bool $import_dependencies=true, bool $throw_error=true) + { + $decoded = explode('==', $package); + if($decoded[1] == 'latest') + $decoded[1] = ppm::getPackageLock()->getPackage($decoded[0])->getLatestVersion(); + $path = ppm::getPackageLock()->getPackage($decoded[0])->getPackagePath($decoded[1]); // Find the package path + ppm::import($decoded[0], $decoded[1], $import_dependencies, $throw_error); // Import dependencies + + $API = new API($path); + $API->initialize(); + self::handleRequest($API); + } + + /** + * Handles the request to the API + * + * @param API $API + * @param string|null $requestUrl + * @param string|null $requestMethod + * @return void + */ + public static function handleRequest(API $API, ?string $requestUrl=null, string $requestMethod = null) + { + $match = $API->getRouter()->match($requestUrl, $requestMethod); + + // call closure or throw 404 status + if(is_array($match) && is_callable($match['target'])) + { + try + { + call_user_func_array($match['target'], array_values($match['params'])); + } + catch(Exception $e) + { + var_dump($e); + exit(); + } + } + else + { + var_dump($API->getRouter()->getRoutes()); + print("404"); + exit(); + } + } } \ No newline at end of file diff --git a/src/KimchiAPI/Objects/Configuration.php b/src/KimchiAPI/Objects/Configuration.php index 81ade76..c028edf 100644 --- a/src/KimchiAPI/Objects/Configuration.php +++ b/src/KimchiAPI/Objects/Configuration.php @@ -1,14 +1,75 @@ Versions as $version) + $versions[] = $version->toArray(); + + return [ + 'name' => $this->Name, + 'configuration' => $this->ServerConfiguration->toArray(), + 'versions' => $versions + ]; + } + + /** + * Constructs object from an array configuration + * + * @param array $data + * @return Configuration + */ + public static function fromArray(array $data): Configuration + { + $configuration_object = new Configuration(); + + if(isset($data['name'])) + $configuration_object->Name = $data['name']; + + if(isset($data['configuration'])) + $configuration_object->ServerConfiguration = ServerConfiguration::fromArray($data['configuration']); + + if(isset($data['versions'])) + { + foreach($data['versions'] as $version) + $configuration_object->Versions[] = VersionConfiguration::fromArray($version); + } + + return $configuration_object; + } } \ No newline at end of file diff --git a/src/KimchiAPI/Objects/Configuration/MethodConfiguration.php b/src/KimchiAPI/Objects/Configuration/MethodConfiguration.php new file mode 100644 index 0000000..a24fe39 --- /dev/null +++ b/src/KimchiAPI/Objects/Configuration/MethodConfiguration.php @@ -0,0 +1,65 @@ + $this->Methods, + 'path' => $this->Path, + 'class' => $this->Class + ]; + } + + /** + * Constructs object from an array representation + * + * @param array $data + * @return MethodConfiguration + */ + public static function fromArray(array $data): MethodConfiguration + { + $MethodConfigurationObject = new MethodConfiguration(); + + if(isset($data['methods'])) + $MethodConfigurationObject->Methods = $data['methods']; + + if(isset($data['path'])) + $MethodConfigurationObject->Path = $data['path']; + + if(isset($data['class'])) + $MethodConfigurationObject->Class = $data['class']; + + return $MethodConfigurationObject; + } + } \ No newline at end of file diff --git a/src/KimchiAPI/Objects/Configuration/ServerConfiguration.php b/src/KimchiAPI/Objects/Configuration/ServerConfiguration.php index 1a6fa3d..8d92e16 100644 --- a/src/KimchiAPI/Objects/Configuration/ServerConfiguration.php +++ b/src/KimchiAPI/Objects/Configuration/ServerConfiguration.php @@ -1,5 +1,7 @@ (bool)$this->LoggingEnabled, + 'root_path' => $this->RootPath, + 'framework_signature' => (bool)$this->FrameworkSignature, + 'api_signature' => (bool)$this->ApiSignature, + 'headers' => $this->Headers, + 'khm_enabled' => (bool)$this->KhmEnabled, + 'firewall_deny' => $this->FirewallDeny + ]; + } + + /** + * Constructs object from an array representation of the object + * + * @param array $data + * @return ServerConfiguration + */ + public static function fromArray(array $data): ServerConfiguration + { + $ServerConfigurationObject = new ServerConfiguration(); + + if(isset($data['logging_enabled'])) + $ServerConfigurationObject->LoggingEnabled = $data['logging_enabled']; + + if(isset($data['root_path'])) + $ServerConfigurationObject->RootPath = $data['root_path']; + + if(isset($data['framework_signature'])) + $ServerConfigurationObject->FrameworkSignature = $data['framework_signature']; + + if(isset($data['api_signature'])) + $ServerConfigurationObject->ApiSignature = $data['api_signature']; + + if(isset($data['headers'])) + $ServerConfigurationObject->Headers = $data['headers']; + + if(isset($data['khm_enabled'])) + $ServerConfigurationObject->KhmEnabled = (bool)$data['khm_enabled']; + + if(isset($data['firewall_deny'])) + $ServerConfigurationObject->FirewallDeny = $data['firewall_deny']; + + return $ServerConfigurationObject; + } } \ No newline at end of file diff --git a/src/KimchiAPI/Objects/Configuration/VersionConfiguration.php b/src/KimchiAPI/Objects/Configuration/VersionConfiguration.php new file mode 100644 index 0000000..c37711a --- /dev/null +++ b/src/KimchiAPI/Objects/Configuration/VersionConfiguration.php @@ -0,0 +1,68 @@ +Methods as $method) + $methods_array[] = $method->toArray(); + + return [ + 'version' => $this->Version, + 'enabled' => (bool)$this->Enabled, + 'methods' => $methods_array + ]; + } + + /** + * Constructs object from an array representation of the object + * + * @param array $data + * @return VersionConfiguration + */ + public static function fromArray(array $data): VersionConfiguration + { + $version_configuration = new VersionConfiguration(); + + if(isset($data['version'])) + $version_configuration->Version = $data['version']; + + if(isset($data['enabled'])) + $version_configuration->Enabled = (bool)$data['enabled']; + + if(isset($data['methods'])) + { + foreach($data['methods'] as $method) + $version_configuration->Methods[] = MethodConfiguration::fromArray($method); + } + + return $version_configuration; + } + } \ No newline at end of file diff --git a/src/KimchiAPI/Utilities/Client.php b/src/KimchiAPI/Utilities/Client.php new file mode 100644 index 0000000..84cbc50 --- /dev/null +++ b/src/KimchiAPI/Utilities/Client.php @@ -0,0 +1,81 @@ +