diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+See https://github.com/freckle/wai-middleware-openapi/releases
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Renaissance Learning Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,128 @@
+# WAI OpenAPI Middleware
+
+Validates request and response bodies against your service's OpenAPI spec.
+
+This is useful in a non-Servant web application, where OpenAPI specs cannot be
+generated from the API implementation itself, and so must be maintained
+manually.
+
+## Usage
+
+```hs
+import Network.Wai (Middleware)
+import Network.Wai.Middleware.OpenApi qualified as OpenApi
+
+middleware :: Middleware
+middleware =
+  thisMiddleware
+    . thatMiddleware
+    . OpenApi.validate openApi
+    . theOtherMiddleware
+
+-- Defined in your /docs or /openapi.json Handler
+openApi :: OpenApi
+openApi = undefined
+```
+
+Default behavior:
+
+- If a request body is invalid, a 400 is returned
+- If a response body is invalid, a 500 is returned
+- In both cases, the validation errors are included in the response body
+
+This is useful if you,
+
+1. Are confident your server is currently complying with spec
+2. Trust your spec enough to reject all invalid-according-to-it requests
+3. Trust your observability to catch the 5xx increase any future
+   response-validity bug would cause
+
+If all or some of these are not true, see the next section.
+
+## Configuring
+
+The `validate` function is equivalent to,
+
+```hs
+validateRequests defaultOnRequestErrors
+  . validateResponses defaultOnResponseErrors
+```
+
+Where those "on" functions take the appropriate error type and return a
+`Middleware`. The reason it returns a middleware is so that it can decide if it
+should take over the response or let it run normally:
+
+```hs
+defaultOnRequestErrors :: RequestErrors -> Middleware
+defaultOnRequestErrors = \case
+  RequestSchemaNotFound {} -> id -- respond normally
+  RequestIsNotJson {} -> id
+  RequestInvalid _ errs -> \_ _ respond ->
+    respond $ clientErrorResponse errs -- respond with an error
+```
+
+Implementing and using a function like this would be how you:
+
+1. Decide to error on missing schema, etc
+2. Change the shape of of the JSON errors
+3. Emit non-JSON errors
+
+## Evaluation
+
+When first implementing this, you probably want to log invalid cases but still
+respond normally. To support this use-case, the library ships replacements for
+`defaultOn*` that are named `evaluateOn*`. These functions take an action to
+apply to the errors (presumably to log them) and then responds normally.
+
+```hs
+validateRequests (evaluateOnRequestErrors logIt)
+  . validateResponses (evaluateOnResponseErrors logIt)
+
+logIt :: Show e => e -> IO ()
+logIt = undefined
+```
+
+The action is necessarily `IO` because we're in a WAI middleware context.
+
+## Performance & Sampling
+
+This middleware may add a performance tax depending on the size of your typical
+requests, responses, and OpenAPI spec itself. If you are concerned, we recommend
+enabling this middleware on a sampling of requests.
+
+For example,
+
+```hs
+openApiMiddleware :: OpenApi.Settings -> Middleware
+openApiMiddleware settings =
+  -- Only validate 20% of requests
+  sampledMiddleware 20 $ OpenApi.validate spec
+
+sampledMiddleware :: Int -> Middleware -> Middleware
+sampledMiddleware percent m app request respond = do
+  roll <- randomRIO (0, 100)
+  if percent <= roll
+    then m app request respond
+    else app request respond
+```
+
+> [!NOTE]
+> We will likely upstream `sampledMiddleware` to `wai-extra` at some point.
+
+## Release
+
+To trigger a release, merge a commit to `main` that follows [Conventional
+Commits][]. In short,
+
+- `fix:` to trigger a patch release
+- `feat:` to trigger a minor release
+- `<type>!:` or use a `BREAKING CHANGE:` footer to trigger a major release
+
+We don't enforce conventional commits generally (though you are free do so),
+it's only required if you want to trigger release.
+
+[conventional commits]: https://www.conventionalcommits.org/en/v1.0.0/#summary
+
+---
+
+[CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE)
diff --git a/src/Network/Wai/Middleware/OpenApi.hs b/src/Network/Wai/Middleware/OpenApi.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Wai/Middleware/OpenApi.hs
@@ -0,0 +1,184 @@
+{-# LANGUAGE CPP #-}
+
+module Network.Wai.Middleware.OpenApi
+  ( validate
+  , RequestErrors (..)
+  , ResponseErrors (..)
+  , SchemaNotFound (..)
+  , ValidationErrors (..)
+  , validateRequests
+  , defaultOnRequestErrors
+  , evaluateOnRequestErrors
+  , validateResponses
+  , defaultOnResponseErrors
+  , evaluateOnResponseErrors
+  ) where
+
+import Prelude
+
+import Control.Lens ((^.))
+import Control.Monad.Except
+import Control.Monad.State
+import Data.Aeson (Value, eitherDecode)
+import Data.ByteString.Builder (toLazyByteString)
+import Data.ByteString.Lazy qualified as BSL
+import Data.IORef (atomicModifyIORef, newIORef, readIORef)
+import Data.List.NonEmpty qualified as NE
+import Data.OpenApi (Definitions, OpenApi, Schema)
+import Data.OpenApi qualified as OpenApi
+import Network.Wai (Middleware, Request, Response)
+import Network.Wai qualified as Wai
+import Network.Wai.Middleware.OpenApi.PathMap qualified as PathMap
+import Network.Wai.Middleware.OpenApi.Schema
+import Network.Wai.Middleware.OpenApi.Validate
+import Network.Wai.Middleware.OpenApi.ValidationError
+
+#if !MIN_VERSION_mtl(2, 3, 1)
+-- <https://hackage-content.haskell.org/package/mtl-2.3.2/docs/src/Control.Monad.Error.Class.html#modifyError>
+modifyError :: MonadError e' m => (e -> e') -> ExceptT e m a -> m a
+modifyError f m = runExceptT m >>= either (throwError . f) pure
+#endif
+
+-- | Validate using 'defaultOnRequestErrors' and 'defaultOnResponseErrors'
+validate :: OpenApi -> Middleware
+validate spec =
+  validateRequests spec defaultOnRequestErrors
+    . validateResponses spec defaultOnResponseErrors
+
+data RequestErrors
+  = -- | The OpenAPI spec doesn't contain a schema for this request
+    --
+    -- The 'SchemaNotFound' component will attempt to describe what about the
+    -- request and/or spec prevented finding schema.
+    RequestSchemaNotFound SchemaNotFound
+  | -- | The request was not JSON
+    --
+    -- Components are the raw body and error.
+    RequestIsNotJson BSL.ByteString String
+  | -- | The request was considered invalid
+    --
+    -- Components are parsed body and errors.
+    RequestInvalid Value ValidationErrors
+  deriving stock (Show)
+
+defaultOnRequestErrors :: RequestErrors -> Middleware
+defaultOnRequestErrors = \case
+  RequestSchemaNotFound {} -> id
+  RequestIsNotJson {} -> id
+  RequestInvalid _ errs -> \_ _ respond ->
+    respond $ clientErrorResponse errs
+
+-- | Run the given action and proceed normally
+evaluateOnRequestErrors
+  :: (RequestErrors -> IO ()) -> RequestErrors -> Middleware
+evaluateOnRequestErrors f errs app request respond = do
+  f errs
+  app request respond
+
+data ResponseErrors
+  = -- | The OpenAPI spec doesn't contain a schema for this response
+    ResponseSchemaNotFound SchemaNotFound
+  | -- | The response was not JSON
+    --
+    -- Components are the raw body and error.
+    ResponseIsNotJson BSL.ByteString String
+  | -- | The response was considered invalid
+    --
+    -- Components are parsed body and errors.
+    ResponseInvalid Value ValidationErrors
+  deriving stock (Show)
+
+defaultOnResponseErrors :: ResponseErrors -> Middleware
+defaultOnResponseErrors = \case
+  ResponseSchemaNotFound {} -> id
+  ResponseIsNotJson {} -> id
+  ResponseInvalid _ errs -> \_ _ respond ->
+    respond $ serverErrorResponse errs
+
+-- | Run the given action and proceed normally
+evaluateOnResponseErrors
+  :: (ResponseErrors -> IO ()) -> ResponseErrors -> Middleware
+evaluateOnResponseErrors f errs app request respond = do
+  f errs
+  app request respond
+
+validateRequests :: OpenApi -> (RequestErrors -> Middleware) -> Middleware
+validateRequests spec onErrors app request0 respond = do
+  result <- runValidateT request0 $ do
+    schema <-
+      modifyError RequestSchemaNotFound
+        $ lookupRequestSchema spec pathMap
+    bytes <- previewRequestBody
+    body <- decodeBody RequestIsNotJson bytes
+    validateBody RequestInvalid definitions schema body
+
+  case result of
+    (Left errs, request1) -> onErrors errs app request1 respond
+    (Right (), request1) -> app request1 respond
+ where
+  pathMap = PathMap.fromOpenApi spec
+  definitions = spec ^. OpenApi.components . OpenApi.schemas
+
+validateResponses :: OpenApi -> (ResponseErrors -> Middleware) -> Middleware
+validateResponses spec onErrors app request0 respond = do
+  app request0 $ \response -> do
+    result <- runValidateT request0 $ do
+      let status = Wai.responseStatus response
+      schema <-
+        modifyError ResponseSchemaNotFound
+          $ lookupResponseSchema status spec pathMap
+      bytes <- getResponseBody response
+      body <- decodeBody ResponseIsNotJson bytes
+      validateBody ResponseInvalid definitions schema body
+
+    case result of
+      (Left errs, request1) -> onErrors errs app request1 respond
+      (Right (), _) -> respond response
+ where
+  pathMap = PathMap.fromOpenApi spec
+  definitions = spec ^. OpenApi.components . OpenApi.schemas
+
+decodeBody
+  :: MonadError e m
+  => (BSL.ByteString -> String -> e)
+  -> BSL.ByteString
+  -> m Value
+decodeBody toError bytes =
+  either (throwError . toError bytes) pure $ eitherDecode bytes
+
+validateBody
+  :: MonadError e m
+  => (Value -> ValidationErrors -> e)
+  -> Definitions Schema
+  -> Schema
+  -> Value
+  -> m ()
+validateBody toError definitions schema body =
+  maybe (pure ()) (throwError . toError body . ValidationErrors)
+    . NE.nonEmpty
+    $ OpenApi.validateJSON definitions schema body
+
+-- | Strictly consume the request body, then mark it as un-consumed
+--
+-- <https://hackage.haskell.org/package/wai-middleware-validation-0.1.0.2/docs/src/Network.Wai.Middleware.Validation.html#getRequestBody>
+previewRequestBody :: (MonadIO m, MonadState Request m) => m BSL.ByteString
+previewRequestBody = do
+  request <- get
+  body <- liftIO $ Wai.strictRequestBody request
+  ref <- liftIO $ newIORef body
+
+  -- Update request to mark body as un-consumed
+  let newRequestBody = atomicModifyIORef ref (BSL.empty,)
+  put $ Wai.setRequestBodyChunks (BSL.toStrict <$> newRequestBody) request
+
+  pure body
+
+getResponseBody :: MonadIO m => Response -> m BSL.ByteString
+getResponseBody response = liftIO $ withBody $ \streamingBody -> do
+  ref <- newIORef mempty
+  streamingBody
+    (\b -> atomicModifyIORef ref $ \acc -> (acc <> b, ()))
+    (pure ())
+  toLazyByteString <$> readIORef ref
+ where
+  (_, _, withBody) = Wai.responseToStream response
diff --git a/src/Network/Wai/Middleware/OpenApi/PathMap.hs b/src/Network/Wai/Middleware/OpenApi/PathMap.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Wai/Middleware/OpenApi/PathMap.hs
@@ -0,0 +1,83 @@
+module Network.Wai.Middleware.OpenApi.PathMap
+  ( PathMap
+  , fromOpenApi
+  , lookup
+  ) where
+
+import Prelude hiding (lookup)
+
+import Control.Applicative ((<|>))
+import Control.Lens ((^?))
+import Control.Monad (guard)
+import Data.Bifunctor (first)
+import Data.ByteString (ByteString)
+import Data.ByteString.Char8 qualified as BS8
+import Data.Function (on)
+import Data.HashMap.Strict.InsOrd qualified as IOHM
+import Data.List (find)
+import Data.List.NonEmpty (nonEmpty)
+import Data.List.NonEmpty qualified as NE
+import Data.Maybe (fromMaybe)
+import Data.OpenApi (OpenApi, PathItem)
+import Data.OpenApi qualified as OpenApi
+import System.FilePath.Posix qualified as Posix
+
+newtype PathMap = PathMap
+  { unwrap :: [(TemplatedPath, PathItem)]
+  }
+
+fromOpenApi :: OpenApi -> PathMap
+fromOpenApi spec =
+  PathMap $ case spec ^? OpenApi.paths of
+    Nothing -> []
+    Just ps -> map (first toTemplatedPath) $ IOHM.toList ps
+
+lookup :: ByteString -> PathMap -> Maybe PathItem
+lookup bs pm =
+  fmap snd $ find (matchExact tp . fst) ps <|> find (matchTemplated tp . fst) ps
+ where
+  tp = toTemplatedPath $ BS8.unpack bs
+  ps = pm.unwrap
+
+matchExact :: TemplatedPath -> TemplatedPath -> Bool
+matchExact = matchComponents (==) `on` (.unwrap)
+
+matchTemplated :: TemplatedPath -> TemplatedPath -> Bool
+matchTemplated = matchComponents go `on` (.unwrap)
+ where
+  go = curry $ \case
+    (Exact l, Exact r) -> l == r
+    (ParameterValue, _) -> True
+    (_, ParameterValue) -> True
+
+newtype TemplatedPath = TemplatedPath
+  { unwrap :: [TemplatedPathComponent]
+  }
+  deriving stock (Eq)
+
+toTemplatedPath :: FilePath -> TemplatedPath
+toTemplatedPath =
+  TemplatedPath
+    . map toTemplatedPathComponent
+    . Posix.splitDirectories
+
+data TemplatedPathComponent
+  = Exact FilePath
+  | ParameterValue
+  deriving stock (Eq)
+
+toTemplatedPathComponent :: FilePath -> TemplatedPathComponent
+toTemplatedPathComponent s = fromMaybe (Exact s) $ do
+  ne <- nonEmpty s
+  guard $ NE.head ne == '{'
+  guard $ NE.last ne == '}'
+  pure ParameterValue
+
+matchComponents
+  :: (TemplatedPathComponent -> TemplatedPathComponent -> Bool)
+  -> [TemplatedPathComponent]
+  -> [TemplatedPathComponent]
+  -> Bool
+matchComponents f as bs
+  | length as /= length bs = False
+  | otherwise = and $ zipWith f as bs
diff --git a/src/Network/Wai/Middleware/OpenApi/Schema.hs b/src/Network/Wai/Middleware/OpenApi/Schema.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Wai/Middleware/OpenApi/Schema.hs
@@ -0,0 +1,140 @@
+module Network.Wai.Middleware.OpenApi.Schema
+  ( SchemaNotFound (..)
+  , lookupRequestSchema
+  , lookupResponseSchema
+  ) where
+
+import Prelude
+
+import Control.Applicative (asum)
+import Control.Lens (Lens', ix, to, (^?), _Just)
+import Control.Monad.Except
+import Control.Monad.State
+import Data.ByteString (ByteString)
+import Data.HashMap.Strict.InsOrd (InsOrdHashMap)
+import Data.Maybe (fromMaybe)
+import Data.OpenApi
+  ( Components
+  , Definitions
+  , HasContent
+  , MediaTypeObject
+  , OpenApi
+  , Operation
+  , PathItem
+  , Referenced
+  , Schema
+  )
+import Data.OpenApi qualified as OpenApi
+import Data.OpenApi.Schema.Generator qualified as OpenApi (dereference)
+import Network.HTTP.Media (MediaType)
+import Network.HTTP.Types.Method (StdMethod (..), parseMethod)
+import Network.HTTP.Types.Status (Status, statusCode)
+import Network.Wai (Request)
+import Network.Wai qualified as Wai
+import Network.Wai.Middleware.OpenApi.PathMap (PathMap)
+import Network.Wai.Middleware.OpenApi.PathMap qualified as PathMap
+
+data SchemaNotFound
+  = -- | The OpenAPI spec doesn't have this path at all
+    MissingPath ByteString
+  | -- | The specified path does not have this operation (method)
+    MissingOperation ByteString
+  | -- | The specified operation defines no request body
+    MissingRequestBody
+  | -- | The specified operation defines no response for this status
+    MissingResponseStatus Status
+  | -- | The specified body or response defines no JSON content
+    --
+    -- We are only able to validate JSON schema. If you are configuring things
+    -- such that 'SchemaNotFound' are errors, you likely still want to avoid
+    -- erroring on this one.
+    MissingContentTypeJson
+  deriving stock (Show)
+
+lookupRequestSchema
+  :: (MonadError SchemaNotFound m, MonadState Request m)
+  => OpenApi
+  -> PathMap
+  -> m Schema
+lookupRequestSchema spec =
+  lookupOperationSchema spec $ \operation -> do
+    note MissingRequestBody
+      $ operation
+        ^? OpenApi.requestBody
+          . _Just
+          . to (dereference spec OpenApi.requestBodies)
+
+lookupResponseSchema
+  :: (MonadError SchemaNotFound m, MonadState Request m)
+  => Status
+  -> OpenApi
+  -> PathMap
+  -> m Schema
+lookupResponseSchema status spec =
+  lookupOperationSchema spec $ \operation -> do
+    ref <- note (MissingResponseStatus status) $ do
+      responses <- operation ^? OpenApi.responses
+      asum
+        [ responses ^? ix (statusCode status)
+        , responses ^? OpenApi.default_ . _Just
+        ]
+    pure $ dereference spec OpenApi.responses ref
+
+lookupOperationSchema
+  :: ( HasContent body (InsOrdHashMap MediaType MediaTypeObject)
+     , MonadError SchemaNotFound m
+     , MonadState Request m
+     )
+  => OpenApi
+  -> (Operation -> m body)
+  -> PathMap
+  -> m Schema
+lookupOperationSchema spec getBody pathMap = do
+  request <- get
+  pathItem <- getPathItem request pathMap
+  operation <- getOperation request pathItem
+  body <- getBody operation
+  note MissingContentTypeJson
+    $ body
+      ^? OpenApi.content
+        . ix "application/json"
+        . OpenApi.schema
+        . _Just
+        . to (dereference spec OpenApi.schemas)
+
+getPathItem
+  :: MonadError SchemaNotFound m
+  => Request
+  -> PathMap
+  -> m PathItem
+getPathItem request = note (MissingPath path) . PathMap.lookup path
+ where
+  path = Wai.rawPathInfo request
+
+getOperation
+  :: MonadError SchemaNotFound m
+  => Request
+  -> PathItem
+  -> m Operation
+getOperation request pathItem =
+  note (MissingOperation method) $ case parseMethod method of
+    Right DELETE -> pathItem ^? OpenApi.delete . _Just
+    Right GET -> pathItem ^? OpenApi.get . _Just
+    Right PATCH -> pathItem ^? OpenApi.patch . _Just
+    Right POST -> pathItem ^? OpenApi.post . _Just
+    Right PUT -> pathItem ^? OpenApi.put . _Just
+    Right HEAD -> Nothing
+    Right TRACE -> Nothing
+    Right CONNECT -> Nothing
+    Right OPTIONS -> Nothing
+    Left _ -> Nothing
+ where
+  method = Wai.requestMethod request
+
+dereference :: OpenApi -> Lens' Components (Definitions a) -> Referenced a -> a
+dereference spec componentL = OpenApi.dereference definitions
+ where
+  definitions = fromMaybe mempty $ spec ^? OpenApi.components . componentL
+
+note :: MonadError e m => e -> Maybe a -> m a
+note e = maybe (throwError e) pure
diff --git a/src/Network/Wai/Middleware/OpenApi/Validate.hs b/src/Network/Wai/Middleware/OpenApi/Validate.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Wai/Middleware/OpenApi/Validate.hs
@@ -0,0 +1,28 @@
+module Network.Wai.Middleware.OpenApi.Validate
+  ( ValidateT (..)
+  , runValidateT
+  ) where
+
+import Prelude
+
+import Control.Monad.Except
+import Control.Monad.State
+import Network.Wai (Request)
+
+newtype ValidateT e m a = ValidateT
+  { unwrap :: ExceptT e (StateT Request m) a
+  }
+  deriving newtype
+    ( Applicative
+    , Functor
+    , Monad
+    , MonadError e
+    , MonadIO
+    , MonadState Request
+    )
+
+runValidateT
+  :: Request
+  -> ValidateT e m a
+  -> m (Either e a, Request)
+runValidateT request f = runStateT (runExceptT f.unwrap) request
diff --git a/src/Network/Wai/Middleware/OpenApi/ValidationError.hs b/src/Network/Wai/Middleware/OpenApi/ValidationError.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/Wai/Middleware/OpenApi/ValidationError.hs
@@ -0,0 +1,46 @@
+module Network.Wai.Middleware.OpenApi.ValidationError
+  ( ValidationErrors (..)
+
+    -- * JSON error responses
+  , Error (..)
+  , clientErrorResponse
+  , serverErrorResponse
+  , errorResponse
+  ) where
+
+import Prelude
+
+import Data.Aeson (ToJSON, encode)
+import Data.List.NonEmpty (NonEmpty)
+import Data.OpenApi.Schema.Validation qualified as OpenApi
+import Data.Text (Text, pack)
+import GHC.Generics (Generic)
+import Network.HTTP.Types.Header (hContentType)
+import Network.HTTP.Types.Status (Status, status400, status500)
+import Network.Wai (Response, responseLBS)
+
+newtype ValidationErrors = ValidationErrors
+  { unwrap :: NonEmpty OpenApi.ValidationError
+  }
+  deriving newtype (Show)
+
+data Error = Error
+  { message :: Text
+  , details :: NonEmpty Text
+  }
+  deriving stock (Generic)
+  deriving anyclass (ToJSON)
+
+clientErrorResponse :: ValidationErrors -> Response
+clientErrorResponse errors =
+  errorResponse status400 "Bad Request" $ pack <$> errors.unwrap
+
+serverErrorResponse :: ValidationErrors -> Response
+serverErrorResponse errors =
+  errorResponse status500 "Internal Server Error" $ pack <$> errors.unwrap
+
+errorResponse :: Status -> Text -> NonEmpty Text -> Response
+errorResponse status msg =
+  responseLBS status [(hContentType, "application/json")]
+    . encode
+    . Error msg
diff --git a/tests/Network/Wai/Middleware/OpenApi/PathMapSpec.hs b/tests/Network/Wai/Middleware/OpenApi/PathMapSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Network/Wai/Middleware/OpenApi/PathMapSpec.hs
@@ -0,0 +1,90 @@
+module Network.Wai.Middleware.OpenApi.PathMapSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Control.Arrow ((&&&))
+import Control.Lens ((&), (.~), (?~))
+import Data.HashMap.Strict.InsOrd qualified as IOHM
+import Data.OpenApi (OpenApi, PathItem)
+import Data.OpenApi qualified as OpenApi
+import Data.Text (pack)
+import Network.Wai.Middleware.OpenApi.PathMap qualified as PathMap
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "lookup" $ do
+    specify "OpenApi precedence example" $ do
+      let pathMap =
+            PathMap.fromOpenApi
+              $ testOpenApi
+                [ "/pets/{petId}"
+                , "/pets/mine"
+                ]
+
+      PathMap.lookup "/pets/42" pathMap
+        `shouldBe` Just (testPathItem "/pets/{petId}")
+
+      PathMap.lookup "/pets/mine" pathMap
+        `shouldBe` Just (testPathItem "/pets/mine")
+
+    specify "Chris' example" $ do
+      let pathMap =
+            PathMap.fromOpenApi
+              $ testOpenApi
+                [ "/book/{bookId}/cover"
+                , "/book/{bookId}/text"
+                , "/{resource}/{resourceId}/text"
+                , "/{resource}/{resourceId}/ratings"
+                ]
+
+      PathMap.lookup "/book/5/text" pathMap
+        `shouldBe` Just (testPathItem "/book/{bookId}/text")
+
+      PathMap.lookup "/article/5/text" pathMap
+        `shouldBe` Just (testPathItem "/{resource}/{resourceId}/text")
+
+      -- Chris says s/b Nothing
+      -- Spec says s/b (4)
+      -- Naive implementation, apparently, agrees with spec
+      PathMap.lookup "/book/5/ratings" pathMap
+        `shouldBe` Just (testPathItem "/{resource}/{resourceId}/ratings")
+
+    -- These tests are to show what happens, but the spec says its ambiguous so
+    -- if we do something to the implementation that breaks these, that's OK.
+    context "ambiguous according to spec" $ do
+      specify "books" $ do
+        let pathMap =
+              PathMap.fromOpenApi
+                $ testOpenApi
+                  [ "/{entity}/me"
+                  , "/books/{id}"
+                  ]
+
+        PathMap.lookup "/books/5" pathMap
+          `shouldBe` Just (testPathItem "/books/{id}")
+
+        PathMap.lookup "/books/me" pathMap
+          `shouldBe` Just (testPathItem "/{entity}/me")
+
+      specify "pet-stores" $ do
+        let pathMap =
+              PathMap.fromOpenApi
+                $ testOpenApi
+                  [ "/pet-stores/{petStoreId}/pets/mine"
+                  , "/pet-stores/{petStoreId}/pets/{petId}"
+                  ]
+
+        PathMap.lookup "/pet-stores/5/pets/3" pathMap
+          `shouldBe` Just (testPathItem "/pet-stores/{petStoreId}/pets/{petId}")
+
+        PathMap.lookup "/pet-stores/5/pets/mine" pathMap
+          `shouldBe` Just (testPathItem "/pet-stores/{petStoreId}/pets/mine")
+
+testOpenApi :: [String] -> OpenApi
+testOpenApi ps = mempty & OpenApi.paths .~ IOHM.fromList (map (id &&& testPathItem) ps)
+
+testPathItem :: String -> PathItem
+testPathItem p = mempty & OpenApi.summary ?~ pack p
diff --git a/tests/Network/Wai/Middleware/OpenApiSpec.hs b/tests/Network/Wai/Middleware/OpenApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Network/Wai/Middleware/OpenApiSpec.hs
@@ -0,0 +1,70 @@
+module Network.Wai.Middleware.OpenApiSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Data.Aeson (encode)
+import Network.Wai
+import Network.Wai.Middleware.OpenApi qualified as OpenApi
+import Network.Wai.Test hiding (request)
+import Test.Hspec
+import TestApp
+
+spec :: Spec
+spec = do
+  let
+    app :: Application
+    app = OpenApi.validate testOpenApi testApp
+
+  describe "validate requests" $ do
+    let request = (setPath defaultRequest "/tests") {requestMethod = "POST"}
+
+    it "responds 400 for invalid request bodies" $ do
+      withSession app $ do
+        sresponse <-
+          srequest
+            $ SRequest
+              { simpleRequest = request
+              , simpleRequestBody = "{}"
+              }
+
+        assertStatus 400 sresponse
+
+    it "passes through valid bodies" $ do
+      withSession app $ do
+        sresponse <-
+          srequest
+            $ SRequest
+              { simpleRequest = request
+              , simpleRequestBody = encode $ NewTest "foo"
+              }
+
+        assertStatus 201 sresponse
+        assertBody (encode $ OK True) sresponse
+
+  describe "validate responses" $ do
+    it "responds 500 for invalid response bodies" $ do
+      let request = setPath defaultRequest "/tests/42" -- broken
+      withSession app $ do
+        sresponse <-
+          srequest
+            $ SRequest
+              { simpleRequest = request
+              , simpleRequestBody = ""
+              }
+
+        assertStatus 500 sresponse
+
+    it "passes through valid bodies" $ do
+      let request = setPath defaultRequest "/tests/99"
+      withSession app $ do
+        sresponse <-
+          srequest
+            $ SRequest
+              { simpleRequest = request
+              , simpleRequestBody = ""
+              }
+
+        assertStatus 200 sresponse
+        assertBody (encode $ Test "99") sresponse
diff --git a/tests/Spec.hs b/tests/Spec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover -Wno-missing-export-lists #-}
diff --git a/tests/TestApp.hs b/tests/TestApp.hs
new file mode 100644
--- /dev/null
+++ b/tests/TestApp.hs
@@ -0,0 +1,131 @@
+module TestApp
+  ( NewTest (..)
+  , Test (..)
+  , OK (..)
+  , testApp
+  , testOpenApi
+  ) where
+
+import Prelude
+
+import Control.Lens (at, (&), (.~), (?~))
+import Data.Aeson (FromJSON, ToJSON, encode)
+import Data.HashMap.Strict.InsOrd qualified as IOHM
+import Data.OpenApi (OpenApi, ToSchema, declareSchemaRef)
+import Data.OpenApi qualified as OpenApi
+import Data.OpenApi.Declare (evalDeclare, looks)
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Network.HTTP.Types.Header (hContentType)
+import Network.HTTP.Types.Method (Method)
+import Network.HTTP.Types.Status
+  ( Status
+  , status200
+  , status201
+  , status404
+  , status405
+  )
+import Network.Wai
+
+newtype NewTest = NewTest
+  { name :: Text
+  }
+  deriving stock (Generic)
+  deriving anyclass (FromJSON, ToJSON, ToSchema)
+
+newtype Test = Test
+  { name :: Text
+  }
+  deriving stock (Generic)
+  deriving anyclass (FromJSON, ToJSON, ToSchema)
+
+newtype OK = OK
+  { ok :: Bool
+  }
+  deriving stock (Generic)
+  deriving anyclass (FromJSON, ToJSON, ToSchema)
+
+testApp :: Application
+testApp request respond =
+  respond $ dispatch (pathInfo request) (requestMethod request)
+
+dispatch :: [Text] -> Method -> Response
+dispatch = \case
+  ["tests"] -> dispatchTests
+  ["tests", x] -> dispatchTest x
+  _ -> const response404
+
+dispatchTests :: Method -> Response
+dispatchTests = \case
+  "POST" -> responseJSON status201 $ OK True
+  _ -> response405
+
+dispatchTest :: Text -> Method -> Response
+dispatchTest name = \case
+  "GET" -> case name of
+    "42" -> responseLBS status200 [] "{}" -- invalid
+    _ -> responseJSON status200 $ Test name
+  _ -> response405
+
+responseJSON :: ToJSON a => Status -> a -> Response
+responseJSON status =
+  responseLBS status [(hContentType, "application/json")] . encode
+
+response404 :: Response
+response404 = responseLBS status404 [] "Not Found"
+
+response405 :: Response
+response405 = responseLBS status405 [] "Bad Method"
+
+testOpenApi :: OpenApi
+testOpenApi = flip evalDeclare mempty $ do
+  newTestRef <- declareSchemaRef $ Proxy @NewTest
+  testRef <- declareSchemaRef $ Proxy @Test
+  okRef <- declareSchemaRef $ Proxy @OK
+
+  looks $ \d ->
+    mempty
+      & OpenApi.components . OpenApi.schemas .~ d
+      & OpenApi.components . OpenApi.requestBodies
+        .~ IOHM.fromList
+          [
+            ( "Test"
+            , mempty
+                & OpenApi.content . at "application/json"
+                  ?~ (mempty & OpenApi.schema ?~ newTestRef)
+            )
+          ]
+      & OpenApi.paths
+        .~ IOHM.fromList
+          [
+            ( "/tests"
+            , mempty
+                & OpenApi.post
+                  ?~ ( mempty
+                         & OpenApi.requestBody
+                           ?~ OpenApi.Ref (OpenApi.Reference "Test")
+                         & at 201
+                           ?~ ( "OK"
+                                  & OpenApi._Inline
+                                    . OpenApi.content
+                                    . at "application/json"
+                                    ?~ (mempty & OpenApi.schema ?~ okRef)
+                              )
+                     )
+            )
+          ,
+            ( "/tests/{name}"
+            , mempty
+                & OpenApi.get
+                  ?~ ( mempty
+                         & at 200
+                           ?~ ( "OK"
+                                  & OpenApi._Inline
+                                    . OpenApi.content
+                                    . at "application/json"
+                                    ?~ (mempty & OpenApi.schema ?~ testRef)
+                              )
+                     )
+            )
+          ]
diff --git a/wai-middleware-openapi.cabal b/wai-middleware-openapi.cabal
new file mode 100644
--- /dev/null
+++ b/wai-middleware-openapi.cabal
@@ -0,0 +1,101 @@
+cabal-version:   1.18
+name:            wai-middleware-openapi
+version:         0.1.0.0
+license:         MIT
+license-file:    LICENSE
+maintainer:      Freckle Education
+synopsis:        TODO
+description:     TODO
+category:        Web
+build-type:      Simple
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+library
+    exposed-modules:
+        Network.Wai.Middleware.OpenApi
+        Network.Wai.Middleware.OpenApi.PathMap
+        Network.Wai.Middleware.OpenApi.Schema
+        Network.Wai.Middleware.OpenApi.Validate
+        Network.Wai.Middleware.OpenApi.ValidationError
+
+    hs-source-dirs:     src
+    other-modules:      Paths_wai_middleware_openapi
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction NoPostfixOperators
+        OverloadedRecordDot OverloadedStrings QuasiQuotes TypeFamilies
+
+    ghc-options:
+        -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe
+
+    build-depends:
+        aeson >=2.0.3.0,
+        base >=4.16.4.0 && <5,
+        bytestring >=0.11.4.0,
+        filepath >=1.4.2.2,
+        http-media >=0.8.0.0,
+        http-types >=0.12.3,
+        insert-ordered-containers >=0.2.5.2,
+        lens >=5.1.1,
+        mtl >=2.2.2,
+        openapi3 >=3.2.3,
+        text >=1.2.5.0,
+        wai >=3.2.4
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+test-suite spec
+    type:               exitcode-stdio-1.0
+    main-is:            Spec.hs
+    hs-source-dirs:     tests
+    other-modules:
+        Network.Wai.Middleware.OpenApi.PathMapSpec
+        Network.Wai.Middleware.OpenApiSpec
+        TestApp
+        Paths_wai_middleware_openapi
+
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction NoPostfixOperators
+        OverloadedRecordDot OverloadedStrings QuasiQuotes TypeFamilies
+
+    ghc-options:
+        -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe -threaded
+        -rtsopts -with-rtsopts=-N
+
+    build-depends:
+        aeson >=2.0.3.0,
+        base >=4.16.4.0 && <5,
+        hspec >=2.9.7,
+        http-types >=0.12.3,
+        insert-ordered-containers >=0.2.5.2,
+        lens >=5.1.1,
+        openapi3 >=3.2.3,
+        text >=1.2.5.0,
+        wai >=3.2.4,
+        wai-extra >=3.1.13.0,
+        wai-middleware-openapi
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
