diff --git a/webob/exc.py b/webob/exc.py
index 57a81b5..044c00a 100644
--- a/webob/exc.py
+++ b/webob/exc.py
@@ -165,10 +165,12 @@ References:
 
 """
 
+import json
 from string import Template
 import re
 import sys
 
+from webob.acceptparse import Accept
 from webob.compat import (
     class_types,
     text_,
@@ -250,7 +252,7 @@ ${body}''')
     empty_body = False
 
     def __init__(self, detail=None, headers=None, comment=None,
-                 body_template=None, **kw):
+                 body_template=None, json_formatter=None, **kw):
         Response.__init__(self,
                           status='%s %s' % (self.code, self.title),
                           **kw)
@@ -265,6 +267,8 @@ ${body}''')
         if self.empty_body:
             del self.content_type
             del self.content_length
+        if json_formatter is not None:
+            self.json_formatter = json_formatter
 
     def __str__(self):
         return self.detail or self.explanation
@@ -300,14 +304,31 @@ ${body}''')
         return self.html_template_obj.substitute(status=self.status,
                                                  body=body)
 
+    def json_formatter(self, body, status, title, environ):
+        return {'message': body,
+                'code': status,
+                'title': title}
+
+    def json_body(self, environ):
+        body = self._make_body(environ, no_escape)
+        jsonbody = self.json_formatter(body=body, status=self.status,
+                                       title=self.title, environ=environ)
+        return json.dumps(jsonbody)
+
     def generate_response(self, environ, start_response):
         if self.content_length is not None:
             del self.content_length
         headerlist = list(self.headerlist)
-        accept = environ.get('HTTP_ACCEPT', '')
-        if accept and 'html' in accept or '*/*' in accept:
+        accept_value = environ.get('HTTP_ACCEPT', '')
+        accept = Accept(accept_value)
+        match = accept.best_match(['application/json', 'text/html',
+                                   'text/plain'], default_match='text/plain')
+        if match == 'text/html':
             content_type = 'text/html'
             body = self.html_body(environ)
+        elif match == 'application/json':
+            content_type = 'application/json'
+            body = self.json_body(environ)
         else:
             content_type = 'text/plain'
             body = self.plain_body(environ)

Allow for JSON Exception Bodies
I'm currently working on several projects that provide a JSON API using WebOb. Currently, however, whenever we use a `webob.exc` exception to return an error to the user (e.g., `webob.exc.HTTPBadRequest`) the body of that message is always in a content-type other than what they're expecting (HTML if they don't specify an Accept header, plain-text otherwise). There doesn't seem to be a pleasant, convenient, or simple way to make it use JSON beyond something like (the untested) following code:

```py
import string

import webob.exc


class WSGIHTTPException(webob.exc.WSGIHTTPException):
    body_template_obj = string.Template('{"code", ${status}, "message": "${body}", "title": "${title}"}'
    plain_template_obj = string.Template('{"error": ${body}}')


class HTTPBadRequest(webob.exc.HTTPBadRequest, WSGIHTTPException):
    pass


class HTTPUnauthored(webob.exc.HTTPBadRequest, WSGIHTTPException):
    pass


# etc.
```

This is particularly problematic because we have to redefine all of the exceptions we want to use to doubly inherit from our new sub-classed `WSGIHTTPException` and the original. It also doesn't handle the fact that we have to basically copy and paste [generate_response][] into our subclass so that we set the appropriate content-type header.

Is it too much to ask to either:

A) Add support for JSON response bodies in `WSGIHTTPException`s, or
B) Make `WSGIHTTPException` slightly more modular so we can only override parts we need?

[generate_response]: https://github.com/Pylons/webob/blob/7f98f694e7c1a569f53fb4085d084430ee8b2cc2/webob/exc.py#L302..L323

Thanks in advance,
diff --git a/tests/test_exc.py b/tests/test_exc.py
index dcb1fed..8204783 100644
--- a/tests/test_exc.py
+++ b/tests/test_exc.py
@@ -1,3 +1,5 @@
+import json
+
 from webob.request import Request
 from webob.dec import wsgify
 from webob import exc as webob_exc
@@ -119,6 +121,57 @@ def test_WSGIHTTPException_html_body_w_comment():
         '</html>'
        )
 
+def test_WSGIHTTPException_json_body_no_comment():
+    class ValidationError(webob_exc.WSGIHTTPException):
+        code = '422'
+        title = 'Validation Failed'
+        explanation = 'Validation of an attribute failed.'
+
+    exc = ValidationError(detail='Attribute "xyz" is invalid.')
+    body = exc.json_body({})
+    eq_(json.loads(body), {
+        "code": "422 Validation Failed",
+        "title": "Validation Failed",
+        "message": "Validation of an attribute failed.<br /><br />\nAttribute"
+                   ' "xyz" is invalid.\n\n',
+    })
+
+def test_WSGIHTTPException_respects_application_json():
+    class ValidationError(webob_exc.WSGIHTTPException):
+        code = '422'
+        title = 'Validation Failed'
+        explanation = 'Validation of an attribute failed.'
+    def start_response(status, headers, exc_info=None):
+        pass
+
+    exc = ValidationError(detail='Attribute "xyz" is invalid.')
+    resp = exc.generate_response(environ={
+        'wsgi.url_scheme': 'HTTP',
+        'SERVER_NAME': 'localhost',
+        'SERVER_PORT': '80',
+        'REQUEST_METHOD': 'PUT',
+        'HTTP_ACCEPT': 'application/json',
+    }, start_response=start_response)
+    eq_(json.loads(resp[0].decode('utf-8')), {
+        "code": "422 Validation Failed",
+        "title": "Validation Failed",
+        "message": "Validation of an attribute failed.<br /><br />\nAttribute"
+                   ' "xyz" is invalid.\n\n',
+    })
+
+def test_WSGIHTTPException_allows_custom_json_formatter():
+    def json_formatter(body, status, title, environ):
+        return {"fake": True}
+    class ValidationError(webob_exc.WSGIHTTPException):
+        code = '422'
+        title = 'Validation Failed'
+        explanation = 'Validation of an attribute failed.'
+
+    exc = ValidationError(detail='Attribute "xyz" is invalid.',
+                          json_formatter=json_formatter)
+    body = exc.json_body({})
+    eq_(json.loads(body), {"fake": True})
+
 def test_WSGIHTTPException_generate_response():
     def start_response(status, headers, exc_info=None):
         pass

pip install -e .[testing]
parse_log_pytest
pytest
pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
nose==1.3.7
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
-e git+https://github.com/Pylons/webob.git@9400c049d05c8ba350daf119aa16ded24ece31f6#egg=WebOb

name: webob
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - nose==1.3.7
prefix: /opt/conda/envs/webob

tests/test_exc.py::test_WSGIHTTPException_json_body_no_comment tests/test_exc.py::test_WSGIHTTPException_respects_application_json tests/test_exc.py::test_WSGIHTTPException_allows_custom_json_formatter
tests/test_exc.py::test_noescape_null tests/test_exc.py::test_noescape_not_basestring tests/test_exc.py::test_noescape_unicode tests/test_exc.py::test_strip_tags_empty tests/test_exc.py::test_strip_tags_newline_to_space tests/test_exc.py::test_strip_tags_zaps_carriage_return tests/test_exc.py::test_strip_tags_br_to_newline tests/test_exc.py::test_strip_tags_zaps_comments tests/test_exc.py::test_strip_tags_zaps_tags tests/test_exc.py::test_HTTPException tests/test_exc.py::test_exception_with_unicode_data tests/test_exc.py::test_WSGIHTTPException_headers tests/test_exc.py::test_WSGIHTTPException_w_body_template tests/test_exc.py::test_WSGIHTTPException_w_empty_body tests/test_exc.py::test_WSGIHTTPException___str__ tests/test_exc.py::test_WSGIHTTPException_plain_body_no_comment tests/test_exc.py::test_WSGIHTTPException_html_body_w_comment tests/test_exc.py::test_WSGIHTTPException_generate_response tests/test_exc.py::test_WSGIHTTPException_call_w_body tests/test_exc.py::test_WSGIHTTPException_wsgi_response tests/test_exc.py::test_WSGIHTTPException_exception_newstyle tests/test_exc.py::test_WSGIHTTPException_exception_no_newstyle tests/test_exc.py::test_HTTPOk_head_of_proxied_head tests/test_exc.py::test_HTTPMove tests/test_exc.py::test_HTTPMove_location_not_none tests/test_exc.py::test_HTTPMove_location_newlines tests/test_exc.py::test_HTTPMove_add_slash_and_location tests/test_exc.py::test_HTTPMove_call_add_slash tests/test_exc.py::test_HTTPMove_call_query_string tests/test_exc.py::test_HTTPExceptionMiddleware_ok tests/test_exc.py::test_HTTPExceptionMiddleware_exception tests/test_exc.py::test_HTTPExceptionMiddleware_exception_exc_info_none tests/test_exc.py::test_status_map_is_deterministic
webob/exc.py
webob/exc.py