Repository: alphagov/govuk-chat
Pull request #170: Implement initial Chat API endpoint GET answer
Author: login: davidgisbey
Created: 2025-04-16T10:53:39Z | Merged: 2025-04-23T16:23:30Z

## Description

This PR introduces the initial implementation of the `GET /answer` endpoint for the GOV.UK Chat API. It: 

- Adds the OpenAI specification based on Kev's [Gist](https://gist.github.com/kevindew/9ee643132182ab4dc927c4c8ca46d8eb)
- Adds `blueprinter` as a dependency and adds the required blueprints for the endpoint (answer, answer source and generic error)
- Adds the endpoint (controller, route etc.)
- Adds `committee` and configured it to validate API responses at runtime
- Adds an `api-user` permission to the AdminUser model and auths the API endpoints based on the presence of the permission

For now you can access the API endpoints locally by adding the permission to the first admin user in your local DB. It's been added to the seeds though so when the db is reset it will work as expected.

A few things to note:

- I know we will need to rename the `AdminUser` model since we will have people who auth via a token to use the API who are not admins (currently when they use the token for the first time they will be persisted to the DB as an AdminUser)

I've carded up doing this as a separate bit of work [here](https://trello.com/c/zsmJJZfa/2420-chat-api-update-adminuser-to-signonuser), but am trying to get the rest of the cards for this unblocked first. 

- I've tried as much as possible to mirror our implementation for Chat where possible. Are couple of examples are:
  - using a single `ConversationsController` with shared functionality rather than `AnswerContoller#show` 
  - consistent error messaging (using `Conversation not found` rather than using `error.message` within the rescue

Im going to leave a couple of comments on bit's i'm less sure on and would appreciate feedback on.

## Trello card

https://trello.com/c/fTlGZAtH/2421-chat-api-add-initial-api-endpoint-get-answer

## Useful resources 

- We’re using the OpenAPI spec defined by Kev in this [Gist](https://gist.github.com/kevindew/9ee643132182ab4dc927c4c8ca46d8eb)

- We’ve decided on using [Blueprinter](https://github.com/procore-oss/blueprinter/blob/main/README.md) for serialisation. It was spiked out in this [PR](https://github.com/alphagov/govuk-chat/pull/153/files)

- We’re using [Committee](https://github.com/interagent/committee) to ensure we keep our responses in sync with the OpenAPI specification

- This [ADR](https://github.com/alphagov/govuk-chat/pull/167) discusses why we’re using them


Diff:
diff --git a/Gemfile b/Gemfile
index 66b71c544..e8854e5d7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,6 +7,7 @@ ruby "~> #{File.read(File.join(__dir__, '.ruby-version')).strip}"
 gem "rails", "8.0.2"
 
 gem "aws-sdk-bedrockruntime"
+gem "blueprinter"
 gem "bootsnap"
 gem "chartkick"
 gem "csv"
@@ -53,6 +54,7 @@ group :test do
 end
 
 group :development, :test do
+  gem "committee"
   gem "dotenv"
   gem "erb_lint", require: false
   gem "factory_bot_rails"
diff --git a/Gemfile.lock b/Gemfile.lock
index dd6e6c244..a7a8500f5 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -110,6 +110,7 @@ GEM
       parser (>= 2.4)
       smart_properties
     bigdecimal (3.1.9)
+    blueprinter (1.1.2)
     bootsnap (1.18.4)
       msgpack (~> 1.2)
     brakeman (7.0.2)
@@ -131,6 +132,10 @@ GEM
     chartkick (5.1.5)
     climate_control (1.2.0)
     coderay (1.1.3)
+    committee (5.5.3)
+      json_schema (~> 0.14, >= 0.14.3)
+      openapi_parser (~> 2.0)
+      rack (>= 1.5, < 3.2)
     concurrent-ruby (1.3.5)
     connection_pool (2.5.1)
     crack (1.0.0)
@@ -315,6 +320,7 @@ GEM
     json-schema (5.1.1)
       addressable (~> 2.8)
       bigdecimal (~> 3.1)
+    json_schema (0.21.0)
     jwt (2.10.1)
       base64
     kaminari (1.2.2)
@@ -415,6 +421,7 @@ GEM
     omniauth-oauth2 (1.8.0)
       oauth2 (>= 1.4, < 3)
       omniauth (~> 2.0)
+    openapi_parser (2.2.6)
     opensearch-ruby (3.4.0)
       faraday (>= 1.0, < 3)
       multi_json (>= 1.0)
@@ -909,10 +916,12 @@ PLATFORMS
 
 DEPENDENCIES
   aws-sdk-bedrockruntime
+  blueprinter
   bootsnap
   brakeman
   chartkick
   climate_control
+  committee
   csv
   dalli
   dartsass-rails
@@ -982,6 +991,7 @@ CHECKSUMS
   benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a
   better_html (2.1.1) sha256=046c3551d1488a3f2939a7cac6fabf2bde08c32e135c91fcd683380118e5af55
   bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc
+  blueprinter (1.1.2) sha256=e09945686d13539f9e911403b4f13cf4dcb83c951abd188bfca8754afbfb2249
   bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55
   brakeman (7.0.2) sha256=b602d91bcec6c5ce4d4bc9e081e01f621c304b7a69f227d1e58784135f333786
   builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
@@ -991,6 +1001,7 @@ CHECKSUMS
   chartkick (5.1.5) sha256=368f7fa61a3c1d731c9f5b3ebf7c49e552a5f5798776fb1811fe744f1cdbf34e
   climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5
   coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
+  committee (5.5.3) sha256=434d6f040e905f636d91a7bbfe8dc5292917b91b5ff55b192569740c67a920ca
   concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6
   connection_pool (2.5.1) sha256=ae802a90a4b5a081101b39d618e69921a9a50bea9ac3420a5b8c71f1befa3e9c
   crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49
@@ -1057,6 +1068,7 @@ CHECKSUMS
   jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
   json (2.10.2) sha256=34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06
   json-schema (5.1.1) sha256=b3829ad9bcdfc5010d8a160c4c2bebb8fed8d05d22de65648f6ba646b071f9bf
+  json_schema (0.21.0) sha256=d6e1eaf004a5185b7679eee34286a79d48e467d794ff68981d72ad23d3b2ec5b
   jwt (2.10.1) sha256=e6424ae1d813f63e761a04d6284e10e7ec531d6f701917fadcd0d9b2deaf1cc5
   kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e
   kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909
@@ -1103,6 +1115,7 @@ CHECKSUMS
   oauth2 (2.0.9) sha256=b21f9defcf52dc1610e0dfab4c868342173dcd707fd15c777d9f4f04e153f7fb
   omniauth (2.1.3) sha256=8d24e2e55c41926c96e4a93fd566bc026dfd6f2c850408748e89945a565956c2
   omniauth-oauth2 (1.8.0) sha256=b2f8e9559cc7e2d4efba57607691d6d2b634b879fc5b5b6ccfefa3da85089e78
+  openapi_parser (2.2.6) sha256=10ca80caa40997f6a6dc4412c0c0f6890b835c276e7ece4f0e4f25a45c19c584
   opensearch-ruby (3.4.0) sha256=0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d
   opentelemetry-api (1.5.0) sha256=8dcc33e7aba70b1da630065ce0db3d6f50cb8ebd2017383209739439025ea997
   opentelemetry-common (0.22.0) sha256=ce5e96a0f838d67eb70a1d32e02bdbe7b8f41767bc71994d73b299a8b0c5878d
diff --git a/app/blueprints/answer_blueprint.rb b/app/blueprints/answer_blueprint.rb
new file mode 100644
index 000000000..b8f0edea4
--- /dev/null
+++ b/app/blueprints/answer_blueprint.rb
@@ -0,0 +1,17 @@
+class AnswerBlueprint < Blueprinter::Base
+  identifier :id
+
+  field :message
+  field :created_at do |answer, _options|
+    answer.created_at.iso8601
+  end
+  field :useful, if: ->(_field_name, answer, _options) { answer.feedback.present? } do |answer, _options|
+    answer.feedback.useful
+  end
+
+  field :sources, if: ->(_field_name, answer, _options) { answer.sources.used.present? } do |answer, _options|
+    answer.group_used_answer_sources_by_base_path.map do |source|
+      { url: source[:href], title: source[:title] }
+    end
+  end
+end
diff --git a/app/blueprints/generic_error_blueprint.rb b/app/blueprints/generic_error_blueprint.rb
new file mode 100644
index 000000000..2a69f3385
--- /dev/null
+++ b/app/blueprints/generic_error_blueprint.rb
@@ -0,0 +1,3 @@
+class GenericErrorBlueprint < Blueprinter::Base
+  field :message
+end
diff --git a/app/controllers/api/v0/conversations_controller.rb b/app/controllers/api/v0/conversations_controller.rb
new file mode 100644
index 000000000..6fb6092e7
--- /dev/null
+++ b/app/controllers/api/v0/conversations_controller.rb
@@ -0,0 +1,16 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action { authorise_user!(SignonUser::Permissions::CONVERSATION_API) }
+
+  def answer
+    conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                               .find(params[:conversation_id])
+    question = conversation.questions.find(params[:question_id])
+    answer = question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+end
diff --git a/app/helpers/answers_helper.rb b/app/helpers/answers_helper.rb
index fc1756440..194898475 100644
--- a/app/helpers/answers_helper.rb
+++ b/app/helpers/answers_helper.rb
@@ -10,23 +10,6 @@ def render_answer_message(message, skip_sanitize: false)
     end
   end
 
-  def group_used_answer_sources_by_base_path(answer)
-    sources_by_base_path = answer.sources.used.group_by(&:base_path)
-
-    sources_by_base_path.map do |base_path, group|
-      result = group.first
-      path = group.count == 1 ? result.exact_path : base_path
-
-      title = result.title
-      title += ": #{result.heading}" if group.count == 1 && result.heading.present?
-
-      {
-        href: "#{Plek.website_root}#{path}",
-        title:,
-      }
-    end
-  end
-
   def show_question_limit_system_message?(user)
     return false if user.nil?
     return false if user.unlimited_question_allowance?
diff --git a/app/models/answer.rb b/app/models/answer.rb
index b7b144189..f017c9e6c 100644
--- a/app/models/answer.rb
+++ b/app/models/answer.rb
@@ -174,4 +174,21 @@ def use_in_rephrasing?
   def set_sources_as_unused
     sources.each { |source| source.used = false }
   end
+
+  def group_used_answer_sources_by_base_path
+    sources_by_base_path = sources.used.group_by(&:base_path)
+
+    sources_by_base_path.map do |base_path, group|
+      result = group.first
+      path = group.count == 1 ? result.exact_path : base_path
+
+      title = result.title
+      title += ": #{result.heading}" if group.count == 1 && result.heading.present?
+
+      {
+        href: "#{Plek.website_root}#{path}",
+        title:,
+      }
+    end
+  end
 end
diff --git a/app/models/signon_user.rb b/app/models/signon_user.rb
index 00464d864..218d52c74 100644
--- a/app/models/signon_user.rb
+++ b/app/models/signon_user.rb
@@ -3,6 +3,7 @@ class SignonUser < ApplicationRecord
 
   module Permissions
     ADMIN_AREA = "admin-area".freeze
+    CONVERSATION_API = "conversation-api".freeze
     DEVELOPER_TOOLS = "developer-tools".freeze
   end
 end
diff --git a/app/views/conversations/_answer.html.erb b/app/views/conversations/_answer.html.erb
index 96c0f37d7..a77e190f1 100644
--- a/app/views/conversations/_answer.html.erb
+++ b/app/views/conversations/_answer.html.erb
@@ -1,6 +1,6 @@
 <%= render "components/conversation_message", {
   id: dom_id(answer),
-  sources: group_used_answer_sources_by_base_path(answer),
+  sources: answer.group_used_answer_sources_by_base_path,
   message: answer.message,
   question_message: answer.question.message,
   feedback_url: (answer_feedback_path(answer) if answer.feedback.blank?),
diff --git a/config/application.rb b/config/application.rb
index dd5f55b6f..ace3c1b7b 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -90,6 +90,7 @@ class Application < Rails::Application
     config.action_dispatch.rescue_responses.merge!(
       "Search::ChunkedContentRepository::NotFound" => :not_found,
       "ThrottledRequest" => :too_many_requests,
+      "Committee::InvalidResponse" => :internal_server_error,
     )
 
     config.exceptions_app = routes
diff --git a/config/initializers/committee.rb b/config/initializers/committee.rb
new file mode 100644
index 000000000..786f18b7e
--- /dev/null
+++ b/config/initializers/committee.rb
@@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "docs/api_openapi_specification.yml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
diff --git a/config/routes.rb b/config/routes.rb
index 3a81fbd38..44b727b90 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -143,6 +143,12 @@
     end
   end
 
+  namespace :api, format: false, defaults: { format: "json" } do
+    namespace :v0 do
+      get "/conversation/:conversation_id/questions/:question_id/answer", to: "conversations#answer", as: :answer_question
+    end
+  end
+
   scope via: :all do
     match "/400" => "errors#bad_request"
     match "/403" => "errors#forbidden"
diff --git a/db/seeds.rb b/db/seeds.rb
index 528d560b5..526b409c3 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1 +1,7 @@
-SignonUser.find_or_create_by!(name: "chat_user", email: "chat.user@dev.gov.uk").update!(uid: SecureRandom.uuid, permissions: %w[developer-tools admin-area])
+SignonUser.find_or_create_by!(
+  name: "chat_user",
+  email: "chat.user@dev.gov.uk",
+).update!(
+  uid: SecureRandom.uuid,
+  permissions: %w[developer-tools admin-area conversation-api],
+)
diff --git a/docs/api_openapi_specification.yml b/docs/api_openapi_specification.yml
new file mode 100644
index 000000000..15a32d498
--- /dev/null
+++ b/docs/api_openapi_specification.yml
@@ -0,0 +1,336 @@
+openapi: "3.0.4"
+info:
+  title: GOV.UK Chat API
+  description: |
+    Initial, proof of concept of documenting what an API for GOV.UK Chat
+    could be that reflects current system modelling.
+    It is intended to help shape chats with the app team about capabilities
+    of chat in app and inform design decisions.
+
+    Worth noting that onboarding aspects of conversation (Informing user of
+    limitations and accepting) are not included as within GOV.UK Chat these
+    are a UI construct and not a part of core domain constructs.
+  version: "0.1.0"
+servers:
+  - url: https://chat.publishing.service.gov.uk/api/v0
+paths:
+  /conversation:
+    post:
+      summary: Start conversation
+      description: Create a conversation by posting an initial question
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              "$ref": "#/components/schemas/UserQuestion"
+      responses:
+        "201":
+          description: Successfully created first question in a conversation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/PendingQuestion"
+        "422":
+          description: |
+            Validation error on question submission (such as PII in question)
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ValidationError"
+
+  /conversation/{conversation_id}:
+    get:
+      summary: Retrieve a conversation
+      description: |
+        Accesses the questions of a conversation should a conversation with
+        the id be available.
+      parameters:
+        - name: conversation_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+      responses:
+        "200":
+          description: |
+            Returns a list of AnsweredQuestions with potentially one PendingQuestion.
+            Is limited to returning 500 questions for a conversation and only
+            questions asked within last 30 days.
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Conversation"
+        "404":
+          description: |
+            Either a conversation never existed with this id or has now expired
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/GenericError"
+
+    put:
+      summary: Update a conversation with a new question
+      description: |
+        Add an additional question to a conversation, requires a conversation
+        to not have a pending question.
+      parameters:
+        - name: conversation_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              "$ref": "#/components/schemas/UserQuestion"
+      responses:
+        "201":
+          description: Successfully added a new question
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/PendingQuestion"
+        "422":
+          description: |
+            Validation error on question submission (such as PII in question
+            or user already has a pending question)
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ValidationError"
+
+  /conversation/{conversation_id}/questions/{question_id}/answer:
+    get:
+      summary: Look up the answer to a question
+      description:
+        This endpoint is intended to be polled while awaiting an answer to a
+        question
+      parameters:
+        - name: conversation_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+        - name: question_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+      responses:
+        "200":
+          description: The answer is available and is returned
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Answer"
+        "202":
+          description: The answer is still being generated
+        "404":
+          description: Conversation or question do not exist
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/GenericError"
+
+  /conversation/{conversation_id}/answers/{answer_id}/feedback:
+    post:
+      summary: Provide user feedback on an individual answer
+      description: |
+        A user can provide feedback on an answer defining it as useful or not,
+        once a user has set this it cannot be changed or removed
+      parameters:
+        - name: conversation_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+        - name: answer_id
+          in: path
+          required: true
+          schema:
+            type: string
+            format: uuid
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required:
+                - useful
+              properties:
+                useful:
+                  type: boolean
+      responses:
+        "201":
+          description: Feedback successfully submitted
+        "422":
+          description: Validation error processing the feedback
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ValidationError"
+        "404":
+          description: Conversation or answer does not exist
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/GenericError"
+
+security:
+  - bearerAuth: []
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      description: |
+        GOV.UK Signon issued bearer token which is used to authenticate the
+        client application (e.g. GOV.UK App) and not an individual end user
+      scheme: bearer
+  schemas:
+    UserQuestion:
+      type: object
+      required:
+        - user_question
+      properties:
+        user_question:
+          type: string
+    Conversation:
+      type: object
+      required:
+        - id
+        - answered_questions
+        - created_at
+      properties:
+        answered_questions:
+          type: array
+          items:
+            $ref: "#/components/schemas/AnsweredQuestion"
+        pending_question:
+          $ref: "#/components/schemas/PendingQuestion"
+        created_at:
+          type: string
+          format: date-time
+    AnsweredQuestion:
+      type: object
+      required:
+        - id
+        - conversation_id
+        - message
+        - answer
+        - created_at
+      properties:
+        id:
+          type: string
+          format: uuid
+        conversation_id:
+          type: string
+          format: uuid
+        message:
+          description: The question the user provided in plain text
+          type: string
+        answer:
+          $ref: "#/components/schemas/Answer"
+        created_at:
+          type: string
+          format: date-time
+    PendingQuestion:
+      type: object
+      required:
+        - id
+        - conversation_id
+        - message
+        - answer_url
+        - created_at
+      properties:
+        id:
+          type: string
+          format: uuid
+        conversation_id:
+          type: string
+          format: uuid
+        message:
+          description: The question the user provided in plain text
+          type: string
+        answer_url:
+          description: |
+            A URL that can be polled to check whether the answer is available
+          type: string
+          format: uri
+        created_at:
+          type: string
+          format: date-time
+    Answer:
+      type: object
+      required:
+        - id
+        - created_at
+        - message
+      properties:
+        id:
+          type: string
+          format: uuid
+        created_at:
+          type: string
+          format: date-time
+        message:
+          description: |
+            The LLM generated answer returned in markdown format.
+          type: string
+        useful:
+          description: |
+            Whether a user has flagged this answer as useful or not
+          type: boolean
+        sources:
+          type: array
+          items:
+            $ref: "#/components/schemas/AnswerSource"
+    AnswerSource:
+      type: object
+      required:
+        - title
+        - url
+      properties:
+        title:
+          description: Title of the GOV.UK document used
+          type: string
+        heading:
+          description: Heading of the section used for content
+          type: string
+        url:
+          description: |
+            URL of the page on GOV.UK that was used to generate answer
+            with potentially a fragment to appropriate section
+          type: string
+          format: uri
+    GenericError:
+      type: object
+      required:
+        - message
+      properties:
+        message:
+          type: string
+    ValidationError:
+      type: object
+      required:
+        - message
+        - fields
+      properties:
+        message:
+          type: string
+        fields:
+          description: |
+            an object structure of field name to an array of errors
+          type: object
+          additionalProperties:
+            type: array
+            items:
+              type: string
diff --git a/spec/blueprints/answer_blueprint_spec.rb b/spec/blueprints/answer_blueprint_spec.rb
new file mode 100644
index 000000000..d2373d6f5
--- /dev/null
+++ b/spec/blueprints/answer_blueprint_spec.rb
@@ -0,0 +1,56 @@
+RSpec.describe AnswerBlueprint do
+  let(:answer) { create(:answer) }
+
+  describe ".render_as_json" do
+    it "generates the correct JSON for an Answer with no sources" do
+      expected_json = {
+        id: answer.id,
+        created_at: answer.created_at.iso8601,
+        message: answer.message,
+      }.as_json
+      output_json = described_class.render_as_json(Answer.includes(%i[sources feedback]).find(answer.id))
+
+      expect(output_json).to eq(expected_json)
+    end
+
+    it "generates the correct JSON for an Answer with used sources" do
+      answer_source = create(:answer_source, answer:)
+
+      expected_json = {
+        id: answer.id,
+        created_at: answer.created_at.iso8601,
+        message: answer.message,
+        sources: [
+          {
+            title: "#{answer_source.title}: #{answer_source.heading}",
+            url: answer_source.url,
+          },
+        ],
+      }.as_json
+      output_json = described_class.render_as_json(Answer.includes(%i[sources feedback]).find(answer.id))
+
+      expect(output_json).to eq(expected_json)
+    end
+
+    it "does not include unused sources in the JSON" do
+      create(:answer_source, answer:, used: false)
+      output_json = described_class.render_as_json(Answer.includes(%i[sources feedback]).find(answer.id))
+      expect(output_json.keys).not_to include("sources")
+    end
+
+    context "when answer feedback is present" do
+      it "includes it in the JSON" do
+        create(:answer_feedback, answer:)
+        expected_json = {
+          id: answer.id,
+          created_at: answer.created_at.iso8601,
+          message: answer.message,
+          useful: true,
+        }.as_json
+        output_json = described_class.render_as_json(Answer.includes(%i[sources feedback]).find(answer.id))
+
+        expect(output_json).to eq(expected_json)
+      end
+    end
+  end
+end
diff --git a/spec/blueprints/generic_error_blueprint_spec.rb b/spec/blueprints/generic_error_blueprint_spec.rb
new file mode 100644
index 000000000..ffa35cad9
--- /dev/null
+++ b/spec/blueprints/generic_error_blueprint_spec.rb
@@ -0,0 +1,8 @@
+RSpec.describe GenericErrorBlueprint do
+  describe ".render_as_json" do
+    it "renders the correct JSON based on the message passed in" do
+      expect(described_class.render_as_json(message: "test-message"))
+        .to eq({ message: "test-message" }.as_json)
+    end
+  end
+end
diff --git a/spec/factories/signon_user_factory.rb b/spec/factories/signon_user_factory.rb
index c530f8ff4..07052b458 100644
--- a/spec/factories/signon_user_factory.rb
+++ b/spec/factories/signon_user_factory.rb
@@ -5,5 +5,9 @@
     trait :admin do
       permissions { %w[admin-area] }
     end
+
+    trait :conversation_api do
+      permissions { %w[conversation-api] }
+    end
   end
 end
diff --git a/spec/helpers/answers_helper_spec.rb b/spec/helpers/answers_helper_spec.rb
index f35e77da0..e3ae855e9 100644
--- a/spec/helpers/answers_helper_spec.rb
+++ b/spec/helpers/answers_helper_spec.rb
@@ -23,67 +23,6 @@
     end
   end
 
-  describe "#group_used_answer_sources_by_base_path" do
-    context "when there is one source per base path" do
-      let(:answer) do
-        create(:answer, sources: [
-          create(
-            :answer_source,
-            base_path: "/childcare-provider",
-            exact_path: "/childcare-provider/how-to-get-a-childcare-provider",
-            title: "Childcare providers",
-            heading: "How to get a childcare provider",
-          ),
-        ])
-      end
-
-      it "builds the sources using the exact path and including the heading" do
-        expect(helper.group_used_answer_sources_by_base_path(answer)).to contain_exactly(
-          {
-            href: "#{Plek.website_root}/childcare-provider/how-to-get-a-childcare-provider",
-            title: "Childcare providers: How to get a childcare provider",
-          },
-        )
-      end
-
-      it "filters out unused sources" do
-        answer.sources << create(:answer_source, used: false, answer:)
-
-        expect(helper.group_used_answer_sources_by_base_path(answer).length).to eq 1
-      end
-    end
-
-    context "when there are multiple sources per base path" do
-      let(:answer) do
-        create(:answer, sources: [
-          create(
-            :answer_source,
-            base_path: "/childcare-provider",
-            exact_path: "/childcare-provider/how-to-get-a-childcare-provider",
-            title: "Childcare providers",
-            heading: "How to get a childcare provider",
-          ),
-          create(
-            :answer_source,
-            base_path: "/childcare-provider",
-            exact_path: "/childcare-provider/how-much-it-costs",
-            title: "Childcare providers",
-            heading: "How much it costs",
-          ),
-        ])
-      end
-
-      it "builds the sources using the base path and excluding the heading" do
-        expect(helper.group_used_answer_sources_by_base_path(answer)).to contain_exactly(
-          {
-            href: "#{Plek.website_root}/childcare-provider",
-            title: "Childcare providers",
-          },
-        )
-      end
-    end
-  end
-
   describe "#show_question_limit_system_message?" do
     it "returns false when no user is present" do
       expect(helper.show_question_limit_system_message?(nil)).to be(false)
diff --git a/spec/models/answer_spec.rb b/spec/models/answer_spec.rb
index 7ccd0121a..fe9e396db 100644
--- a/spec/models/answer_spec.rb
+++ b/spec/models/answer_spec.rb
@@ -244,4 +244,65 @@
       expect(answer.sources.all?(&:used?)).to be(false)
     end
   end
+
+  describe "#group_used_answer_sources_by_base_path" do
+    context "when there is one source per base path" do
+      let(:answer) do
+        create(:answer, sources: [
+          create(
+            :answer_source,
+            base_path: "/childcare-provider",
+            exact_path: "/childcare-provider/how-to-get-a-childcare-provider",
+            title: "Childcare providers",
+            heading: "How to get a childcare provider",
+          ),
+        ])
+      end
+
+      it "builds the sources using the exact path and including the heading" do
+        expect(answer.group_used_answer_sources_by_base_path).to contain_exactly(
+          {
+            href: "#{Plek.website_root}/childcare-provider/how-to-get-a-childcare-provider",
+            title: "Childcare providers: How to get a childcare provider",
+          },
+        )
+      end
+
+      it "filters out unused sources" do
+        answer.sources << create(:answer_source, used: false, answer:)
+
+        expect(answer.group_used_answer_sources_by_base_path.length).to eq 1
+      end
+    end
+
+    context "when there are multiple sources per base path" do
+      let(:answer) do
+        create(:answer, sources: [
+          create(
+            :answer_source,
+            base_path: "/childcare-provider",
+            exact_path: "/childcare-provider/how-to-get-a-childcare-provider",
+            title: "Childcare providers",
+            heading: "How to get a childcare provider",
+          ),
+          create(
+            :answer_source,
+            base_path: "/childcare-provider",
+            exact_path: "/childcare-provider/how-much-it-costs",
+            title: "Childcare providers",
+            heading: "How much it costs",
+          ),
+        ])
+      end
+
+      it "builds the sources using the base path and excluding the heading" do
+        expect(answer.group_used_answer_sources_by_base_path).to contain_exactly(
+          {
+            href: "#{Plek.website_root}/childcare-provider",
+            title: "Childcare providers",
+          },
+        )
+      end
+    end
+  end
 end
diff --git a/spec/requests/api/v0/conversations_spec.rb b/spec/requests/api/v0/conversations_spec.rb
new file mode 100644
index 000000000..873f64c0b
--- /dev/null
+++ b/spec/requests/api/v0/conversations_spec.rb
@@ -0,0 +1,81 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  shared_examples "responds with forbidden if user doesn't have conversation-api permission" do |path, method|
+    let(:route_params) { {} }
+    let(:params) { {} }
+
+    describe "responds with forbidden if user doesn't have conversation-api permission" do
+      it "returns with 403 for #{path}" do
+        login_as(create(:signon_user))
+        process(method.to_sym, public_send(path.to_sym, *route_params), params:, as: :json)
+
+        expect(response).to have_http_status(:forbidden)
+      end
+    end
+  end
+
+  describe "middleware ensures adherance to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        create(:answer, question:)
+        allow(AnswerBlueprint).to receive(:render).and_return({}.to_json)
+
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:internal_server_error)
+        expect(response.body).to include("Sorry, there is a problem with GOV.UK Chat")
+      end
+    end
+  end
+
+  describe "GET :answer" do
+    it_behaves_like "responds with forbidden if user doesn't have conversation-api permission",
+                    :api_v0_answer_question_path,
+                    :get do
+      let(:route_params) { [create(:conversation), create(:question)] }
+    end
+
+    context "when an answer has been generated for the question" do
+      let!(:answer) { create(:answer, question:) }
+
+      it "returns a success status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:ok)
+      end
+
+      it "returns the expected JSON" do
+        get api_v0_answer_question_path(conversation, question)
+
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        expected_response = AnswerBlueprint.render_as_json(eager_loaded_answer)
+        expect(JSON.parse(response.body)).to eq(expected_response)
+      end
+
+      it "returns the correct JSON for answer sources" do
+        source = create(:answer_source, answer:)
+
+        get api_v0_answer_question_path(conversation, question)
+
+        expect(JSON.parse(response.body)["sources"])
+          .to eq([{ url: source.url, title: "#{source.title}: #{source.heading}" }.as_json])
+      end
+    end
+
+    context "when an answer has not been generated for the question" do
+      it "returns an accepted status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:accepted)
+      end
+
+      it "returns an empty JSON response" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(JSON.parse(response.body)).to eq({})
+      end
+    end
+  end
+end


Reviews:
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:36:21Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:48:13Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T11:52:45Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces the initial implementation of the GET /answer endpoint for the GOV.UK Chat API. It adds the endpoint with its supporting controller, routes, and blueprints, integrates Committee for response validation, and updates tests and seeds to support the new API permission.

### Reviewed Changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                            | Description                                                                                  |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------- |
| spec/requests/api/v0/conversations_spec.rb      | Adds integration tests for the /answer endpoint covering various response scenarios.         |
| spec/models/answer_spec.rb                        | Adds tests for the new Answer#group_used_answer_sources_by_base_path method in the model.      |
| spec/helpers/answers_helper_spec.rb               | Removes tests for the helper method that has been refactored into the Answer model.           |
| spec/factories/admin_user_factory.rb             | Introduces the :api_user trait to include the new API permission.                             |
| spec/blueprints/generic_error_blueprint_spec.rb    | Provides tests to verify the JSON output of the GenericErrorBlueprint.                        |
| spec/blueprints/answer_blueprint_spec.rb           | Verifies the JSON output for answer objects, including conditions for sources and feedback.    |
| db/seeds.rb                                      | Updates the AdminUser seeds to add the api-user permission.                                   |
| config/routes.rb                                 | Adds the new route for GET /answer under the API v0 namespace.                                |
| config/initializers/committee.rb                 | Configures the Committee middleware to validate responses against the OpenAPI spec.           |
| app/views/conversations/_answer.html.erb          | Updates the view to use the new model method for grouping answer sources.                     |
| app/models/answer.rb                             | Implements the group_used_answer_sources_by_base_path method to standardize answer source output.|
| app/models/admin_user.rb                         | Adds a new API_USER constant to support API endpoint authorization.                           |
| app/helpers/answers_helper.rb                    | Removes the now redundant helper method for grouping answer sources.                          |
| app/controllers/api/v0/conversations_controller.rb | Implements the answer action including user authorization and error handling for missing data.  |
| app/blueprints/generic_error_blueprint.rb         | Defines a blueprint for error messages consistent with the OpenAPI spec.                      |
| app/blueprints/answer_blueprint.rb                | Sets up the blueprint for answer objects, including conditional fields for sources and feedback.|
| Gemfile                                          | Adds dependencies on blueprinter and committee for serialization and validation.              |
</details>


<details>
<summary>Files not reviewed (1)</summary>

* **spec/support/api/govuk_chat_api_specification.yaml**: Language not supported
</details>



state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T15:47:44Z
user: login: chaecramb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T11:17:55Z
user: login: chaecramb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T11:28:35Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:02:03Z
user: login: chaecramb
body: Looks good. Just a couple of comments that are more queries.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:23:50Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:50:50Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:52:39Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T13:10:13Z
user: login: kevindew
body: Looks great, nice job. I've got a few actionable comments there and I'll have a quick research about the error stuff, maybe we should have a quick chat about it.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T11:00:50Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T11:17:58Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T11:19:11Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T11:21:48Z
user: login: kevindew
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T13:04:02Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T15:28:53Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T15:47:01Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T11:26:20Z
user: login: kevindew
body: Looks great, I think there's a couple of things to move around and I'd suggest we pull out the ErrorController stuff into a separate PR to get this one through quicker.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T15:45:24Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:58:56Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T15:59:03Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:00:53Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:05:18Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:05:38Z
user: login: kevindew
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T16:08:15Z
user: login: kevindew
body: Nice one 🚀 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T16:09:35Z
user: login: davidgisbey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T16:13:09Z

Review comments:
diff_hunk: @@ -14,6 +15,10 @@ def answer
 
 private
 
+  def authorise_user
+    authorise_user!("api-user")
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: `api-user` seems a bit over generic to me as we may add additional apis down the line i.e. this may become the `ConversationsApi`. I'm struggling to come up with a better name though since for now we just have the one API.

Does anyone have thoughts on a better name or do you think it's fine as is for now and we would just update the permission name down the line if required?
created_at: 2025-04-16T11:36:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "spec/support/api/govuk_chat_api_specification.yaml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
path: config/initializers/committee.rb
user: login: davidgisbey
body: I thought i'd just lay out my thoughts on why i've done this and see if anyone disagrees/has any input.

This middleware runs on responses at runtime and during tests. This means that if any of our API request specs don't adhere to the schema the test will fail with 

<img width="995" alt="image" src="https://github.com/user-attachments/assets/fe947360-b54d-4cc7-b6e5-0448cd0896e4" />

etc.

Which unless i'm missing something really just means that we need a test that checks we get a test failure when the response doesn't adhere to the schema for each endpoint in the specification.

The other point i'd like some feedback on is the `raise: true`.

If don't set this it defaults to false. This means that the response the client gets would contain 

`#/components/schemas/Answer/properties/created_at Value: "2025-04-16 11:51:34 +0100" is not conformant with date-time format` but we will not be informed via Sentry. It'll just log it and silently fail.

Since users of the API cannot actually fix anything, i figured it'd be better to raise it so we get pinged on Slack that there's an issue.

Other options are: 

- don't run the middleware and rely on testing each endpoint like [this](https://github.com/alphagov/govuk-chat/pull/163/commits/bf604652441e6e37c7e4dc70016d866a8f40e22c#diff-822488d4d54203be47042a30ff6f804a348624128bef23500021ec2704fa4455R27-R30) 
- setting raise to false and relying on API consumers to inform us when there are issues

To me it makes more sense for things to fail loudly and quickly if there's an issue, but it would mean if something was shipped with a minor issue it might end up causing an incident.

For example, say 1 minor fairly unimportant field was missing from the schema all requests would hit the error rather than us delivering the payload with the missing field.

Realistically though due to the middleware that should be caught prior to merge provided we've got good test coverage of the combinations of responses we can deliver for the endpoint. 
created_at: 2025-04-16T11:48:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,6 +3,7 @@ class AdminUser < ApplicationRecord
 
   module Permissions
     ADMIN_AREA = "admin-area".freeze
path: app/models/admin_user.rb
user: login: davidgisbey
body: I've noted this in the PR description, but we're going to need to rename this model since not all users persisted in our DB will be admin users:

- some users will arrive from Signon with a full account and realistically will be admin users
- the app with auth via bearer token and will not be an admin user. They'll only be consuming the API 

There's a card for this [here](https://trello.com/c/zsmJJZfa/2420-chat-api-update-adminuser-to-signonuser) 
created_at: 2025-04-16T11:52:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +15,10 @@ def answer
 
 private
 
+  def authorise_user
+    authorise_user!("api-user")
path: app/controllers/api/v0/conversations_controller.rb
user: login: chaecramb
body: Yeah, see what you mean. I suppose it could be something like `conversation-api-access` down the line, but being that specific now seems preemptive.

It seems reasonable to keep it generic just now, and update when needed.
created_at: 2025-04-17T11:17:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "spec/support/api/govuk_chat_api_specification.yaml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
path: config/initializers/committee.rb
user: login: chaecramb
body: I'd agree, being stricter with `raise: true` seems the better default to me, to ensure adherence to the OpenAPI spec. 

The only downside seems to be that it requires thorough test coverage to avoid accidental failures, but then that's not really much of a downside, since we should do it anyway. Whereas `raise: false` would give us less visibility of problems.
created_at: 2025-04-17T11:28:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+class AnswerBlueprint < Blueprinter::Base
+  identifier :id
+
+  field :message
+  field :created_at do |answer, _options|
+    answer.created_at.iso8601
+  end
+  field :useful, if: ->(_field_name, answer, _options) { answer.feedback.present? } do |answer, _options|
+    answer.feedback.useful
+  end
+
+  field :sources, if: ->(_field_name, answer, _options) { answer.sources.used.present? } do |answer, _options|
+    answer.group_used_answer_sources_by_base_path.map do |source|
+      source.transform_keys { |key| key == :href ? :url : key }
path: app/blueprints/answer_blueprint.rb
user: login: chaecramb
body: I think the keys are fixed, right? So rather than a transform, could building the hash directly be simpler? Or are you anticipating sources could be extended with more fields?

```
{ url: source[:href], title: source[:title] }
```
created_at: 2025-04-17T11:42:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +15,10 @@ def answer
 
 private
 
+  def authorise_user
+    authorise_user!("api-user")
path: app/controllers/api/v0/conversations_controller.rb
user: login: chaecramb
body: Would it be worth referencing the `API_USER` constant you added to AdminUser::Permissions though, instead of duplicating the raw string here?
created_at: 2025-04-17T11:58:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "spec/support/api/govuk_chat_api_specification.yaml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
path: config/initializers/committee.rb
user: login: davidgisbey
body: Thanks Chae. From what i remember working on the Publishing team when there's an issue with schema adherence it raises there too so i suspect it's fine
created_at: 2025-04-17T12:02:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
+
+  def answer
+    answer = @question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+
+private
+
+  def authorise_user
+    authorise_user!("api-user")
+  end
+
+  def find_conversation
+    @conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                                .find(params[:conversation_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Conversation not found"), status: :not_found
+  end
+
+  def find_question
+    @question = @conversation.questions.find(params[:question_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Question not found"), status: :not_found
path: app/controllers/api/v0/conversations_controller.rb
user: login: chaecramb
body: Not sure is this actually matters, but the API spec has a description "Conversation or question do not exist".

Differentiating between "Question not found" and "Conversation not found" as you do, seems more helpful, but is this something that should be updated in the OpenAPI spec? I don't have experience with it, so not sure if it's supposed to reflect all messages exactly.
created_at: 2025-04-17T12:08:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
+
+  def answer
+    answer = @question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+
+private
+
+  def authorise_user
+    authorise_user!("api-user")
+  end
+
+  def find_conversation
+    @conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                                .find(params[:conversation_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Conversation not found"), status: :not_found
+  end
+
+  def find_question
+    @question = @conversation.questions.find(params[:question_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Question not found"), status: :not_found
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: So in an OpenAPI spec the description just describes the so for example a 422 returned on the conversation POST endpoint has the description of 

```
"422": 
          description: |
            Validation error on question submission (such as PII in question)
```

So it's descriptive rather than prescriptive of behavior. Hopefully that makes sense but let me know if it doesn't
created_at: 2025-04-17T12:50:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+class AnswerBlueprint < Blueprinter::Base
+  identifier :id
+
+  field :message
+  field :created_at do |answer, _options|
+    answer.created_at.iso8601
+  end
+  field :useful, if: ->(_field_name, answer, _options) { answer.feedback.present? } do |answer, _options|
+    answer.feedback.useful
+  end
+
+  field :sources, if: ->(_field_name, answer, _options) { answer.sources.used.present? } do |answer, _options|
+    answer.group_used_answer_sources_by_base_path.map do |source|
+      source.transform_keys { |key| key == :href ? :url : key }
path: app/blueprints/answer_blueprint.rb
user: login: davidgisbey
body: Great shout this far simpler 👍 
created_at: 2025-04-17T12:52:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +15,10 @@ def answer
 
 private
 
+  def authorise_user
+    authorise_user!("api-user")
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: yea great shout!
created_at: 2025-04-17T13:10:12Z
author_association: CONTRIBUTOR
path: spec/support/api/govuk_chat_api_specification.yaml
user: login: kevindew
body: Rather than having this in the spec/support directory I think we should have it in the docs directory of the repository. 

I imagine we should use openapi in the name too. So could be docs/api_openapi_specification.yml (I think all or nearly all of the yaml files in this repo have the yml extension)
created_at: 2025-04-22T10:27:04Z
author_association: MEMBER
diff_hunk: @@ -1 +1 @@
-AdminUser.find_or_create_by!(name: "chat_user", email: "chat.user@dev.gov.uk").update!(uid: SecureRandom.uuid, permissions: %w[developer-tools admin-area])
+AdminUser.find_or_create_by!(name: "chat_user", email: "chat.user@dev.gov.uk").update!(uid: SecureRandom.uuid, permissions: %w[developer-tools admin-area api-user])
path: db/seeds.rb
user: login: kevindew
body: I think we should have a conversation-api permission rather than api-user. conversation addition to scope that it's not all api but conversation, and dropping of user to reflect that we're granting a permission rather than a role.
created_at: 2025-04-22T10:29:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
+
+  def answer
+    answer = @question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+
+private
+
+  def authorise_user
+    authorise_user!("api-user")
+  end
+
+  def find_conversation
+    @conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                                .find(params[:conversation_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Conversation not found"), status: :not_found
+  end
+
+  def find_question
+    @question = @conversation.questions.find(params[:question_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Question not found"), status: :not_found
path: app/controllers/api/v0/conversations_controller.rb
user: login: kevindew
body: Ah yeah it is interesting as to whether comittee would complain about these or not given they're not in the spec, I guess it's cool with them if it's not erroring? 

I did wonder how many HTTP status codes are appropriate for an OpenAPI file, it feels redundant to do all the basic ones for every request. 

I do think it seems a bit heavy having these individual catches though and I expect we could make things more generic so we don't have to repeat the logic. Something like this would be looser:

```
"ActiveRecord::RecordNotFound" do |exception|
  render json: GenericErrorBlueprint.render(message: "Resource not found"), status: :not_found
  end
```

However I feel like we can probably do something even more generic that hooks into Rails ActionDispatch::RescueResponses. Will have a look after this review.
created_at: 2025-04-22T10:43:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
path: app/controllers/api/v0/conversations_controller.rb
user: login: kevindew
body: I expect we could benefit from this being in a controller that we subclass from. I'd probably suggest having a ConversationsApiBaseController so we can specify the conversations API permission.
created_at: 2025-04-22T10:51:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
path: app/controllers/api/v0/conversations_controller.rb
user: login: kevindew
body: I expect with us having a simpler not found mechanism then this could be a bit simpler too (maybe part of the method?)
created_at: 2025-04-22T10:54:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "spec/support/api/govuk_chat_api_specification.yaml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
path: config/initializers/committee.rb
user: login: kevindew
body: Yeah I think having it raise is better than it returning a user error. 

What does a user get in that type of error? a generic Rails 500?
created_at: 2025-04-22T10:58:46Z
author_association: MEMBER
diff_hunk: @@ -1 +1 @@
-AdminUser.find_or_create_by!(name: "chat_user", email: "chat.user@dev.gov.uk").update!(uid: SecureRandom.uuid, permissions: %w[developer-tools admin-area])
+AdminUser.find_or_create_by!(name: "chat_user", email: "chat.user@dev.gov.uk").update!(uid: SecureRandom.uuid, permissions: %w[developer-tools admin-area api-user])
path: db/seeds.rb
user: login: davidgisbey
body: yea makes sense to me 👍 
created_at: 2025-04-22T11:17:58Z
author_association: CONTRIBUTOR
path: spec/support/api/govuk_chat_api_specification.yaml
user: login: davidgisbey
body: yea sorry i did have to move it over when i was playing around with Rswag::UI then forgot to do it on my main branch  and cherry-picked the commit. Will sort that
created_at: 2025-04-22T11:19:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+require "committee"
+
+Rails.application.config.middleware.use Committee::Middleware::ResponseValidation,
+                                        schema_path: "spec/support/api/govuk_chat_api_specification.yaml",
+                                        prefix: "/api/v0",
+                                        strict_reference_validation: true,
+                                        raise: true
path: config/initializers/committee.rb
user: login: davidgisbey
body: yea it returns a 500 but does return a v similar message to PubAPI . So not a generic error. 

![image](https://github.com/user-attachments/assets/08e59d9b-a9ed-435b-8370-83fd64eb88f7)

created_at: 2025-04-22T11:21:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
+
+  def answer
+    answer = @question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+
+private
+
+  def authorise_user
+    authorise_user!("api-user")
+  end
+
+  def find_conversation
+    @conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                                .find(params[:conversation_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Conversation not found"), status: :not_found
+  end
+
+  def find_question
+    @question = @conversation.questions.find(params[:question_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Question not found"), status: :not_found
path: app/controllers/api/v0/conversations_controller.rb
user: login: kevindew
body: So I've had a little look and think about it. 

I think the approach I'd suggest doing is that we have a check in ErrorsController for it. I imagine the most Railsy approach would be to do the standard responds_to 

```
  def not_found
    respond_to do |format|
      format.json do
        render json: GenericErrorBlueprint.render(message: "Resource not found"),
               status: :not_found
      end
      format.any { render status: :not_found, formats: :html }
    end
  end
```

and do the same for each method in ErrorsController.

If we need to do anything more complex (like different ones for different API versions) or if we hit compatibility annoyances (since we return JSON in the app too) we could potentially query `request.original_fullpath.start_with?("/api")` but I think `respond_to` might be a more idiomatic Rails idiomatic place to start from.
created_at: 2025-04-22T13:04:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
+  before_action :find_conversation
+  before_action :find_question
+
+  def answer
+    answer = @question.answer
+
+    if answer.present?
+      render json: AnswerBlueprint.render(answer), status: :ok
+    else
+      render json: {}, status: :accepted
+    end
+  end
+
+private
+
+  def authorise_user
+    authorise_user!("api-user")
+  end
+
+  def find_conversation
+    @conversation = Conversation.includes(questions: { answer: %i[sources feedback] })
+                                .find(params[:conversation_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Conversation not found"), status: :not_found
+  end
+
+  def find_question
+    @question = @conversation.questions.find(params[:question_id])
+  rescue ActiveRecord::RecordNotFound
+    render json: GenericErrorBlueprint.render(message: "Question not found"), status: :not_found
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: cool yea that'll def work for now 👍 I'll make the change 
created_at: 2025-04-22T15:28:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: So this ones interesting because i'm not sure we're going to end up with more than 1 controller for our initial implementation of the API.

I've mirrored the implementation of our other chat controller where possible. i.e gone for `conversations#answer` rather than `answers#show` so we can share functionality between actions (finding convos, questions, answers etc.). 

I do expect that down the line we will need a base controller though so happy to implement one now if you'd prefer it 👍 Lmk what you think 

Btw this is now `before_action { authorise_user!(SignonUser::Permissions::CONVERSATION_API) }` 
created_at: 2025-04-22T15:47:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+class Api::V0::ConversationsController < ApplicationController
+  before_action :authorise_user
path: app/controllers/api/v0/conversations_controller.rb
user: login: davidgisbey
body: we've agreed to leave it as is for now with the expectation that we will add a base controller if/when a new controllers added.
created_at: 2025-04-23T11:26:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,7 +14,13 @@ def forbidden
   end
 
   def not_found
-    render status: :not_found, formats: :html
+    respond_to do |format|
+      format.json do
+        render json: GenericErrorBlueprint.render(message: "Resource not found"),
+               status: :not_found
+      end
+      format.any { render status: :not_found, formats: :html }
+    end
path: app/controllers/errors_controller.rb
user: login: kevindew
body: So I think we should change all of the methods in this controller to have this conditional. I think maybe it's best to do that as a separate PR?
created_at: 2025-04-23T14:59:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: ```suggestion
  describe "middleware ensures adhere to the OpenAPI specification" do
```

To reflect that we don't want to test every endpoint (I imagine we'll move this out of this controller later if we end up with multiple controllers)
created_at: 2025-04-23T15:34:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: This seems quite involved. I wonder if we could just do: `allow(AnswerBlueprint).to receive(:render).and_return({})` 
created_at: 2025-04-23T15:35:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: I think this raising an actual exception seems incorrect. I think we want to set this up in Rails rescue_responses: https://github.com/alphagov/govuk-chat/blob/beba043922091350b0ef38bf23650dd453e53e78/config/application.rb#L90 to be an internal server error so that we can test actual HTTP response (even though it is a bit weird configuring a rescue response for 500)
created_at: 2025-04-23T15:39:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
+            expect(error.message).to match(/missing required parameters: created_at/)
+          end
+      end
+    end
+  end
+
+  describe "GET :answer" do
+    context "when an answer has been generated for the question" do
+      let!(:answer) { create(:answer, question:) }
+
+      it "returns a success status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:ok)
+      end
+
+      it "returns the expected JSON" do
+        get api_v0_answer_question_path(conversation, question)
+
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        expected_response = AnswerBlueprint.render_as_json(eager_loaded_answer)
+        expect(JSON.parse(response.body)).to eq(expected_response)
+      end
+
+      it "returns the correct JSON for answer sources" do
+        source = create(:answer_source, answer:)
+
+        get api_v0_answer_question_path(conversation, question)
+
+        expect(JSON.parse(response.body)["sources"])
+          .to eq([{ url: source.url, title: "#{source.title}: #{source.heading}" }.as_json])
+      end
+    end
+
+    context "when an answer has not been generated for the question" do
+      it "returns an accepted status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:accepted)
+      end
+
+      it "returns an empty JSON response" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(JSON.parse(response.body)).to eq({})
+      end
+    end
+
+    context "when the user does not have the correct permissions to use the API" do
+      it "returns a forbidden status" do
+        login_as(create(:signon_user))
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:forbidden)
+      end
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: I think it'd be good to move this out of the action block and have it as a shared example that can be easily applied to actions - something like: https://github.com/alphagov/govuk-chat/blob/beba043922091350b0ef38bf23650dd453e53e78/spec/requests/onboarding_spec.rb#L2

so we don't accidentally create a precedent of writing a test for it on each action. I expect if/when we have multiple controllers we'd just test this once but seems worth testing each if we've got it defined at controller level.
created_at: 2025-04-23T15:43:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
path: spec/requests/api/v0/conversations_spec.rb
user: login: davidgisbey
body: yea i think it'll go to base if that's the case i guess
created_at: 2025-04-23T15:58:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
path: spec/requests/api/v0/conversations_spec.rb
user: login: davidgisbey
body: cool have sorted
created_at: 2025-04-23T15:59:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,7 +14,13 @@ def forbidden
   end
 
   def not_found
-    render status: :not_found, formats: :html
+    respond_to do |format|
+      format.json do
+        render json: GenericErrorBlueprint.render(message: "Resource not found"),
+               status: :not_found
+      end
+      format.any { render status: :not_found, formats: :html }
+    end
path: app/controllers/errors_controller.rb
user: login: davidgisbey
body: okay i've removed for now. I'll add it in a follow up PR 👍 
created_at: 2025-04-23T16:00:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
path: spec/requests/api/v0/conversations_spec.rb
user: login: davidgisbey
body: cool have done. Since it's now returning our 500 from the errors controller but the follow up PR will sort that 
created_at: 2025-04-23T16:05:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
+            expect(error.message).to match(/missing required parameters: created_at/)
+          end
+      end
+    end
+  end
+
+  describe "GET :answer" do
+    context "when an answer has been generated for the question" do
+      let!(:answer) { create(:answer, question:) }
+
+      it "returns a success status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:ok)
+      end
+
+      it "returns the expected JSON" do
+        get api_v0_answer_question_path(conversation, question)
+
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        expected_response = AnswerBlueprint.render_as_json(eager_loaded_answer)
+        expect(JSON.parse(response.body)).to eq(expected_response)
+      end
+
+      it "returns the correct JSON for answer sources" do
+        source = create(:answer_source, answer:)
+
+        get api_v0_answer_question_path(conversation, question)
+
+        expect(JSON.parse(response.body)["sources"])
+          .to eq([{ url: source.url, title: "#{source.title}: #{source.heading}" }.as_json])
+      end
+    end
+
+    context "when an answer has not been generated for the question" do
+      it "returns an accepted status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:accepted)
+      end
+
+      it "returns an empty JSON response" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(JSON.parse(response.body)).to eq({})
+      end
+    end
+
+    context "when the user does not have the correct permissions to use the API" do
+      it "returns a forbidden status" do
+        login_as(create(:signon_user))
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:forbidden)
+      end
path: spec/requests/api/v0/conversations_spec.rb
user: login: davidgisbey
body: cool i've added one in the follow up PR but i can cherry-pick that into this commit
created_at: 2025-04-23T16:05:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
+            expect(error.message).to match(/missing required parameters: created_at/)
+          end
+      end
+    end
+  end
+
+  describe "GET :answer" do
+    context "when an answer has been generated for the question" do
+      let!(:answer) { create(:answer, question:) }
+
+      it "returns a success status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:ok)
+      end
+
+      it "returns the expected JSON" do
+        get api_v0_answer_question_path(conversation, question)
+
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        expected_response = AnswerBlueprint.render_as_json(eager_loaded_answer)
+        expect(JSON.parse(response.body)).to eq(expected_response)
+      end
+
+      it "returns the correct JSON for answer sources" do
+        source = create(:answer_source, answer:)
+
+        get api_v0_answer_question_path(conversation, question)
+
+        expect(JSON.parse(response.body)["sources"])
+          .to eq([{ url: source.url, title: "#{source.title}: #{source.heading}" }.as_json])
+      end
+    end
+
+    context "when an answer has not been generated for the question" do
+      it "returns an accepted status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:accepted)
+      end
+
+      it "returns an empty JSON response" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(JSON.parse(response.body)).to eq({})
+      end
+    end
+
+    context "when the user does not have the correct permissions to use the API" do
+      it "returns a forbidden status" do
+        login_as(create(:signon_user))
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:forbidden)
+      end
path: spec/requests/api/v0/conversations_spec.rb
user: login: kevindew
body: Feel free to sort it in that PR
created_at: 2025-04-23T16:08:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,73 @@
+RSpec.describe "Api::V0::ConversationsController" do
+  let(:conversation) { create(:conversation) }
+  let(:question) { create(:question, conversation:) }
+
+  before do
+    login_as(create(:signon_user, :conversation_api))
+  end
+
+  describe "endpoints adhere to the OpenAPI specification" do
+    context "when the response returned does not conform to the OpenAPI specification" do
+      it "raises an error and returns the invalid params in the error message" do
+        answer = create(:answer, question:)
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        answer_blueprint = AnswerBlueprint.render_as_hash(eager_loaded_answer)
+        answer_blueprint.delete(:created_at)
+        allow(AnswerBlueprint).to receive(:render).and_return(answer_blueprint)
+
+        expect { get api_v0_answer_question_path(conversation, question) }
+          .to raise_error(Committee::InvalidResponse) do |error|
+            expect(error.message).to match(/missing required parameters: created_at/)
+          end
+      end
+    end
+  end
+
+  describe "GET :answer" do
+    context "when an answer has been generated for the question" do
+      let!(:answer) { create(:answer, question:) }
+
+      it "returns a success status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:ok)
+      end
+
+      it "returns the expected JSON" do
+        get api_v0_answer_question_path(conversation, question)
+
+        eager_loaded_answer = Answer.includes(:sources, :feedback).find(answer.id)
+        expected_response = AnswerBlueprint.render_as_json(eager_loaded_answer)
+        expect(JSON.parse(response.body)).to eq(expected_response)
+      end
+
+      it "returns the correct JSON for answer sources" do
+        source = create(:answer_source, answer:)
+
+        get api_v0_answer_question_path(conversation, question)
+
+        expect(JSON.parse(response.body)["sources"])
+          .to eq([{ url: source.url, title: "#{source.title}: #{source.heading}" }.as_json])
+      end
+    end
+
+    context "when an answer has not been generated for the question" do
+      it "returns an accepted status" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:accepted)
+      end
+
+      it "returns an empty JSON response" do
+        get api_v0_answer_question_path(conversation, question)
+        expect(JSON.parse(response.body)).to eq({})
+      end
+    end
+
+    context "when the user does not have the correct permissions to use the API" do
+      it "returns a forbidden status" do
+        login_as(create(:signon_user))
+        get api_v0_answer_question_path(conversation, question)
+        expect(response).to have_http_status(:forbidden)
+      end
path: spec/requests/api/v0/conversations_spec.rb
user: login: davidgisbey
body: just sorted it here since other people are rebased off of it
created_at: 2025-04-23T16:13:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: davidgisbey
created_at: 2025-04-16T15:48:01Z
body: well that wasn't much use 😂 
author_association: CONTRIBUTOR
user: login: davidgisbey
created_at: 2025-04-17T12:52:06Z
body: great shout on `API_USER` 👍 
author_association: CONTRIBUTOR
user: login: davidgisbey
created_at: 2025-04-23T08:19:10Z
body: Thanks for the review @kevindew. I've addressed the comments except this [one](https://github.com/alphagov/govuk-chat/pull/170#discussion_r2054394008) as i've mentioned i think we will only be using one controller in the short to mid term, but v happy to implement the base controller if you'd like. 
author_association: CONTRIBUTOR
user: login: davidgisbey
created_at: 2025-04-23T12:12:38Z
body: @kevindew just an fyi i made one change after we spoke based on our convo. I moved the test that requests the response is valid to it's [own describe block](https://github.com/alphagov/govuk-chat/pull/170/commits/644299435ff6fd7d6439ff51d91c8bf1e51cee89#diff-822488d4d54203be47042a30ff6f804a348624128bef23500021ec2704fa4455R9-R24)
author_association: CONTRIBUTOR