$args body, headers, v1 (bool), timeout, raw (bool). * * @return array{ok:bool, status:int, data:mixed, code:string, message:string} */ public function request( string $method, string $path, array $args = array() ): array { $v1 = (bool) ( $args['v1'] ?? false ); // The server's address for the platform, which is not always the // browser's — see Connection::api_internal_base(). $base = $this->connection->api_internal_base() . ( $v1 ? '/v1' : '' ); $url = $base . '/workspaces/' . rawurlencode( $this->connection->workspace_id() ) . $path; $headers = array_merge( array( 'Content-Type' => 'application/json' ), (array) ( $args['headers'] ?? array() ) ); $request = array( 'method' => $method, 'headers' => $headers, 'timeout' => (int) ( $args['timeout'] ?? self::TIMEOUT ), ); if ( array_key_exists( 'body', $args ) ) { $request['body'] = wp_json_encode( $args['body'] ); } return $this->send( $url, $request, (bool) ( $args['raw'] ?? false ) ); } /** * Calls the Knowledge app rather than the API. * * Connecting a site happens there — the token exchange and provisioning — * and neither call is workspace-scoped, so neither fits request(). Form * bodies go through `form`, because OAuth's token endpoint takes * `application/x-www-form-urlencoded` and not JSON. * * @param string $method HTTP method. * @param string $path Path from the app root, e.g. `/api/oauth/token`. * @param array $args form, body, headers, timeout. * * @return array{ok:bool, status:int, data:mixed, code:string, message:string} */ public function app_request( string $method, string $path, array $args = array() ): array { $request = array( 'method' => $method, 'headers' => (array) ( $args['headers'] ?? array() ), 'timeout' => (int) ( $args['timeout'] ?? self::TIMEOUT ), ); if ( array_key_exists( 'form', $args ) ) { // An array body is what tells WordPress to form-encode it; setting // Content-Type by hand here would be the way to get that wrong. $request['body'] = (array) $args['form']; } elseif ( array_key_exists( 'body', $args ) ) { $request['headers']['Content-Type'] = 'application/json'; $request['body'] = wp_json_encode( $args['body'] ); } // The internal base, not the browser's: these two calls are made by PHP, // and on a local setup the server may not reach the app at the address // the administrator's browser does. return $this->send( $this->connection->app_internal_base() . $path, $request ); } /** * Makes the call and turns whatever comes back into one shape. * * @param string $url Absolute URL. * @param array $request Arguments for wp_remote_request(). * @param bool $raw Hand back the body as text, unparsed. * * @return array{ok:bool, status:int, data:mixed, code:string, message:string} */ private function send( string $url, array $request, bool $raw = false ): array { $response = wp_remote_request( $url, $request ); if ( is_wp_error( $response ) ) { // Unreachable is not the same as refused, and callers choose // different fallbacks for each. return array( 'ok' => false, 'status' => 0, 'data' => null, 'code' => 'TRANSPORT', 'message' => $response->get_error_message(), ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); // The agent answers in `text/event-stream`, which is not JSON and must // not be run through a JSON parser that would quietly return null for // the whole answer. Its caller asks for the text and reads the frames // itself — see Agent. $data = $raw ? $body : ( '' === $body ? null : json_decode( $body, true ) ); if ( $status >= 200 && $status < 300 ) { return array( 'ok' => true, 'status' => $status, 'data' => $data, 'code' => '', 'message' => '', ); } $fields = is_array( $data ) ? $data : array(); // Three shapes of failure, read in one place: // {"code":"RATE_LIMITED"} the contract's machine-readable errors // {"error":"Workspace not found"} the API's ordinary refusals // {"error":"invalid_grant","error_description":"…"} OAuth, from the Knowledge app // In the third, `error` IS the code — which is how a caller can tell an // expired approval from a genuine failure without reading a sentence. $code = (string) ( $fields['code'] ?? '' ); if ( '' === $code && isset( $fields['error'], $fields['error_description'] ) ) { $code = (string) $fields['error']; } // The body is only usable as a message when it is the JSON the contract // describes. A proxy, a load balancer or a 404 from the wrong URL answers // with an HTML page, and putting that in a notice means showing an // administrator a whole document where a sentence belongs. $message = (string) ( $fields['error_description'] ?? $fields['error'] ?? '' ); if ( '' === $message ) { $message = sprintf( /* translators: %d: an HTTP status code. */ __( 'Sharely answered %d with nothing this plugin could read.', 'sharelyai' ), $status ); } return array( 'ok' => false, 'status' => $status, 'data' => $data, 'code' => $code, 'message' => $message, ); } /** * Seconds to wait after a 429, from Retry-After when the platform sends it. * * @param array $result A failed request() result. */ public static function retry_after( array $result ): int { $after = (int) ( $result['data']['retryAfter'] ?? 0 ); return $after > 0 ? $after : 60; } /** * Mints a bearer from the API key. * * With no roles this is the sync credential and it sees everything, so it * never goes near a browser. With roles it reads as that visitor. * * @param string[] $customer_role_ids Roles the token should carry. * * @return array{ok:bool, status:int, data:mixed, code:string, message:string} */ public function mint_bearer( array $customer_role_ids = array() ): array { return $this->request( 'POST', '/generate-access-key-token', array( 'headers' => array( 'x-api-key' => $this->connection->api_key() ), // An empty object, not an empty array: a workspace with RBAC off // refuses a roles key it did not ask for. 'body' => $customer_role_ids ? array( 'customerRoleIds' => array_values( $customer_role_ids ) ) : new \stdClass(), ) ); } }