settings = $settings; } /** * The OpenAPI document as pretty JSON. Served as application/json, so slashes * are left unescaped for readability. * * @return string */ public function json() { return (string) wp_json_encode( $this->document(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ); } /** * Build the document from the live site: one read collection per content type * that is both agent-indexed and actually exposed by the REST API. * * @return array */ public function document() { $resources = array(); foreach ( Content::post_types() as $type ) { $obj = get_post_type_object( $type ); if ( ! $obj || empty( $obj->show_in_rest ) ) { continue; // Only types the REST API really serves can be described. } $namespace = ! empty( $obj->rest_namespace ) ? $obj->rest_namespace : 'wp/v2'; $base = ! empty( $obj->rest_base ) ? $obj->rest_base : $type; $label = Content::label( $type ); $resources[] = array( 'path' => '/' . trim( (string) $namespace, '/' ) . '/' . $base, 'type' => $type, 'label' => $label, 'single' => self::singular( $label ), ); } $name = $this->settings->display_name(); $name = '' !== $name ? $name : get_bloginfo( 'name' ); return self::build( $resources, array( /* translators: %s: site name. */ 'title' => sprintf( __( '%s — content API', 'agentimus' ), $name ), 'version' => defined( 'AGENTIMUS_VERSION' ) ? AGENTIMUS_VERSION : '1.0', 'description' => __( 'Read-only access to this site’s published content via the WordPress REST API. This document describes the API that already exists; it does not add new endpoints.', 'agentimus' ), 'server' => rest_url(), ) ); } /** * Pure builder: assemble an OpenAPI 3.1 array from read collections and doc * metadata. WP-free so it can be unit-tested directly. * * @param array $resources [ { path, label, single }, … ]. * @param array $info { title, version, description, server }. * @return array */ public static function build( array $resources, array $info ) { $doc = array( 'openapi' => self::OPENAPI_VERSION, 'info' => array( 'title' => (string) ( isset( $info['title'] ) ? $info['title'] : 'Content API' ), 'version' => (string) ( isset( $info['version'] ) ? $info['version'] : '1.0' ), 'description' => (string) ( isset( $info['description'] ) ? $info['description'] : '' ), ), 'servers' => array( array( 'url' => rtrim( (string) ( isset( $info['server'] ) ? $info['server'] : '' ), '/' ) ), ), 'paths' => array(), 'components' => array( 'schemas' => array( 'ContentItem' => self::content_item_schema(), 'Error' => self::error_schema(), ), ), ); foreach ( $resources as $r ) { $path = '/' . ltrim( (string) $r['path'], '/' ); $label = isset( $r['label'] ) ? (string) $r['label'] : 'items'; $one = isset( $r['single'] ) ? (string) $r['single'] : $label; // GET /{namespace}/{base} — list. $doc['paths'][ $path ] = array( 'get' => array( 'operationId' => self::operation_id( 'list', $path ), 'tags' => array( $label ), 'summary' => sprintf( 'List %s', $label ), 'description' => sprintf( 'Returns published %s, newest first, as the WordPress REST API serves them. Supports page/per_page pagination and full-text search.', strtolower( $label ) ), 'parameters' => array( self::query_param( 'page', 'integer', 'Page of the result set.' ), self::query_param( 'per_page', 'integer', 'Items per page (1–100).' ), self::query_param( 'search', 'string', 'Limit results to those matching a string.' ), ), 'responses' => array( '200' => self::json_response( sprintf( 'A page of %s.', $label ), array( 'type' => 'array', 'items' => array( '$ref' => '#/components/schemas/ContentItem' ), ) ), '400' => self::json_response( 'Invalid parameter.', array( '$ref' => '#/components/schemas/Error' ) ), ), ), ); // GET /{namespace}/{base}/{id} — single. $doc['paths'][ $path . '/{id}' ] = array( 'get' => array( 'operationId' => self::operation_id( 'get', $path . '/{id}' ), 'tags' => array( $label ), 'summary' => sprintf( 'Get a single %s by id', $one ), 'description' => sprintf( 'Returns one published %s by its numeric id, as the WordPress REST API serves it.', strtolower( $one ) ), 'parameters' => array( array( 'name' => 'id', 'in' => 'path', 'required' => true, 'schema' => array( 'type' => 'integer' ), ), ), 'responses' => array( '200' => self::json_response( sprintf( 'The requested %s.', $one ), array( '$ref' => '#/components/schemas/ContentItem' ) ), '404' => self::json_response( 'Not found.', array( '$ref' => '#/components/schemas/Error' ) ), ), ), ); } return $doc; } /** * A deterministic operationId derived from the verb and the literal path, so * every operation carries the unique, [A-Za-z0-9_]-safe name function-calling * toolchains key on ("/wp/v2/posts" → "list_wp_v2_posts"). Paths are unique per * document, so ids collide only if paths do. * * @param string $verb Operation verb prefix ("list", "get"). * @param string $path The OpenAPI path, template braces included. * @return string */ private static function operation_id( $verb, $path ) { return $verb . '_' . strtolower( trim( (string) preg_replace( '/[^A-Za-z0-9]+/', '_', (string) $path ), '_' ) ); } /** * A query parameter object. * * @param string $name Parameter name. * @param string $type JSON Schema type. * @param string $desc Description. * @return array */ private static function query_param( $name, $type, $desc ) { return array( 'name' => $name, 'in' => 'query', 'required' => false, 'description' => $desc, 'schema' => array( 'type' => $type ), ); } /** * A 200-style application/json response with the given schema. * * @param string $description Response description. * @param array $schema JSON Schema (object or $ref). * @return array */ private static function json_response( $description, array $schema ) { return array( 'description' => $description, 'content' => array( 'application/json' => array( 'schema' => $schema ), ), ); } /** * A small subset of the WP REST post object — the fields an agent reads. Not * exhaustive (the live API returns more); enough to be a useful contract. * * @return array */ private static function content_item_schema() { $rendered = array( 'type' => 'object', 'properties' => array( 'rendered' => array( 'type' => 'string' ) ), ); return array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'date' => array( 'type' => 'string', 'format' => 'date-time' ), 'modified' => array( 'type' => 'string', 'format' => 'date-time' ), 'slug' => array( 'type' => 'string' ), 'status' => array( 'type' => 'string' ), 'link' => array( 'type' => 'string', 'format' => 'uri' ), 'title' => $rendered, 'excerpt' => $rendered, 'content' => $rendered, ), ); } /** * The typed error shape every WordPress REST error actually has — documented * so agents can branch on `code` instead of parsing prose. Describing what * already exists, not adding behaviour. * * @return array */ private static function error_schema() { return array( 'type' => 'object', 'description' => 'The WordPress REST error object.', 'properties' => array( 'code' => array( 'type' => 'string', 'description' => 'Machine-readable error code, e.g. rest_post_invalid_id.', ), 'message' => array( 'type' => 'string', 'description' => 'Human-readable message.', ), 'data' => array( 'type' => 'object', 'properties' => array( 'status' => array( 'type' => 'integer' ) ), ), ), 'required' => array( 'code', 'message' ), ); } /** * Crude singularisation for a summary string ("Posts" → "Post"). Cosmetic only. * * @param string $label Plural label. * @return string */ private static function singular( $label ) { return (string) preg_replace( '/s$/i', '', (string) $label ); } }