Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file. This change log follows the conventions
of [keepachangelog.com](http://keepachangelog.com/).

## 0.4.0 - 2026-05-01

### Changed

- **Breaking:** Removed connector-level CORS configuration (`:allowed-origins` option is no longer supported). CORS and other default interceptors must now be configured per-route in the consuming application using `io.pedestal.connector/with-default-interceptors` or Pedestal route-level interceptors.

## 0.3.8 - 2026-05-01

### Added
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ The service component accepts configuration through the `:service` key in your c
| `:host` | String | Yes | — | The host address to bind the server to (e.g., `"0.0.0.0"`). |
| `:port` | Integer | Yes | — | The port number to listen on (e.g., `8080`). |
| `:idle-timeout-ms` | Integer | No | `30000` | Jetty idle timeout in milliseconds. Connections idle beyond this duration are closed. |
| `:allowed-origins` | Collection\<String> | No | Allow all origins | A collection of allowed origin strings for CORS. When omitted or empty, all origins are allowed. |
| `:min-threads` | Integer | No | `8` | Minimum number of threads kept alive in the Jetty thread pool. |
| `:max-threads` | Integer | No | `50` | Maximum number of concurrent threads. Acts as a concurrency cap for both platform and virtual thread modes. |
| `:max-queue-size` | Integer | No | `200` | Maximum number of requests that can queue while all threads are busy (platform threads only). Requests beyond this limit are rejected with HTTP 503. |
Expand All @@ -48,7 +47,6 @@ Uses Jetty's `QueuedThreadPool` backed by a `BlockingArrayQueue` of size `:max-q
{:service {:host "0.0.0.0"
:port 8080
:idle-timeout-ms 60000
:allowed-origins ["https://example.com" "https://app.example.com"]
:min-threads 8
:max-threads 200
:max-queue-size 500
Expand All @@ -66,7 +64,31 @@ Uses Jetty's `QueuedThreadPool` backed by a `BlockingArrayQueue` of size `:max-q

> **Note:** If `:idle-timeout-ms` is not provided, a default of **30 seconds** (`30000` ms) is applied to prevent stalled connections from tying up server resources.

> **Note:** If `:allowed-origins` is not provided or is empty, the server will accept requests from **any origin**. In production, explicitly list trusted origins to prevent unwanted cross-origin access.
## Interceptors

CORS and other default request interceptors must be configured **in your consuming application**, not at the component level.

The service component provides two built-in interceptors:
- `error-handler-interceptor` — handles exception-to-response conversion.
- `components-interceptor` — injects the Integrant components map into the request context.

To add CORS, authentication, rate limiting, or other cross-cutting concerns, use one of these approaches in your application:

**Option 1: Per-route interceptors**

Define interceptors on individual routes in your route definitions:
```clojure
["/api/resource"
{:get {:handler my-handler
:interceptors [cors-interceptor auth-interceptor]}}]
```

**Option 2: Connector-level default interceptors**

Use `io.pedestal.connector/with-default-interceptors` in your route setup to apply interceptors to all routes:
```clojure
(io.pedestal.connector/with-default-interceptors connector :allowed-origins cors-origins)
```

## License

Expand Down
2 changes: 1 addition & 1 deletion project.clj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
(defproject net.clojars.macielti/service "0.3.8"
(defproject net.clojars.macielti/service "0.4.0"

:description "Service is a Pedestal service Integrant component"

Expand Down
16 changes: 3 additions & 13 deletions src/service/component.clj
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
(def ^:private default-max-threads 50)
(def ^:private default-max-queue-size 200)

(def ^:private allow-all-origins (constantly true))

(defn- java-version []
(let [version (System/getProperty "java.version")
major-version (Integer/parseInt (first (str/split version #"\.")))]
Expand All @@ -37,15 +35,9 @@
60000
(BlockingArrayQueue. max-queue-size)))))

(defn ^:private allowed-origins
"Returns the allowed-origins value for Pedestal.
When a collection of origin strings is provided, returns it as a set.
Otherwise, allows all origins."
[origins]
(if (not-empty origins)
(set origins)
allow-all-origins))

; CORS and other default request interceptors must be configured per-route in the consuming application.
; Use `io.pedestal.connector/with-default-interceptors` or Pedestal route-level interceptors
; to add CORS, authentication, or other cross-cutting concerns to your routes.
(defmethod ig/init-key ::service
[_ {:keys [components]}]
(log/info :starting ::service)
Expand All @@ -57,14 +49,12 @@
use-virtual-threads (if (contains? service-config :use-virtual-threads)
(:use-virtual-threads service-config)
true)
allowed-origins' (:allowed-origins service-config)
connector (-> {:host (:host service-config)
:port (:port service-config)
:type :jetty
:router :sawtooth
:initial-context {}
:join? false}
(io.pedestal.connector/with-default-interceptors :allowed-origins (allowed-origins allowed-origins'))
(io.pedestal.connector/with-interceptors [io.interceptors/error-handler-interceptor
(io.interceptors/components-interceptor components)])
(io.pedestal.connector/with-routes (:routes components))
Expand Down
26 changes: 20 additions & 6 deletions test/integration/component_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
[iapetos.export :as export]
[integrant.core :as ig]
[io.pedestal.connector.test :as test]
[io.pedestal.service.interceptors :as pedestal.service.interceptors]
[io.pedestal.interceptor :as pedestal.interceptor]
[matcher-combinators.test :refer [match?]]
[schema.core :as schema]
[schema.test :as s]
Expand All @@ -17,19 +17,33 @@

(def test-state (atom nil))

(def parse-json-body-interceptor
(pedestal.interceptor/interceptor
{:name ::parse-json-body
:enter (fn [ctx]
(let [body (-> ctx :request :body)
body-str (if body
(slurp body)
"")]
(assoc-in ctx [:request :json-params]
(if (str/blank? body-str)
""
(json/decode body-str true)))))}))

(def routes [["/test" :get [interceptors/http-request-in-handle-timing-interceptor
pedestal.service.interceptors/json-body
(fn [{{:keys [config]} :components}]
{:status 200
:body config})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode config)})]
:route-name :test]
["/schema-validation-interceptor-test" :post [(interceptors/wire-in-body-schema {:test schema/Str
["/schema-validation-interceptor-test" :post [parse-json-body-interceptor
(interceptors/wire-in-body-schema {:test schema/Str
(schema/optional-key :type) schema/Keyword})
pedestal.service.interceptors/json-body
(fn [{:keys [json-params]}]
(reset! test-state json-params)
{:status 200
:body {:test :schema-ok}})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode {:test :schema-ok})})]
:route-name :schema-validation-interceptor-test]])

(def system-components
Expand Down
65 changes: 0 additions & 65 deletions test/integration/cors_test.clj

This file was deleted.

29 changes: 22 additions & 7 deletions test/integration/idle_timeout_test.clj
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
(ns idle-timeout-test
(:require [clj-http.client :as http]
(:require [cheshire.core :as json]
[clj-http.client :as http]
[clojure.string :as str]
[clojure.test :refer [is testing]]
[common-clj.integrant-components.config :as component.config]
[common-clj.integrant-components.prometheus :as component.prometheus]
[common-clj.integrant-components.routes :as component.routes]
[integrant.core :as ig]
[io.pedestal.service.interceptors :as pedestal.service.interceptors]
[io.pedestal.interceptor :as pedestal.interceptor]
[matcher-combinators.test :refer [match?]]
[schema.test :as s]
[service.component :as component.service]
[service.interceptors :as interceptors])
(:import (java.net Socket SocketTimeoutException)
(java.io BufferedReader InputStreamReader PrintWriter)))

(def parse-json-body-interceptor
(pedestal.interceptor/interceptor
{:name ::parse-json-body
:enter (fn [ctx]
(let [body (-> ctx :request :body)
body-str (if body
(slurp body)
"")]
(assoc-in ctx [:request :json-params]
(if (str/blank? body-str)
""
(json/decode body-str true)))))}))

(def routes [["/test" :get [interceptors/http-request-in-handle-timing-interceptor
pedestal.service.interceptors/json-body
(fn [{{:keys [config]} :components}]
{:status 200
:body config})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode config)})]
:route-name :test]
["/slow" :get [pedestal.service.interceptors/json-body
(fn [_]
["/slow" :get [(fn [_]
(Thread/sleep 2000)
{:status 200
:body {:message "slow response"}})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode {:message "slow response"})})]
:route-name :slow]])

(def system-components
Expand Down
36 changes: 25 additions & 11 deletions test/integration/thread_pool_test.clj
Original file line number Diff line number Diff line change
@@ -1,34 +1,48 @@
(ns thread-pool-test
(:require [clj-http.client :as http]
(:require [cheshire.core :as json]
[clj-http.client :as http]
[clojure.string :as str]
[clojure.test :refer [is testing]]
[common-clj.integrant-components.config :as component.config]
[common-clj.integrant-components.prometheus :as component.prometheus]
[common-clj.integrant-components.routes :as component.routes]
[integrant.core :as ig]
[io.pedestal.service.interceptors :as pedestal.service.interceptors]
[io.pedestal.interceptor :as pedestal.interceptor]
[matcher-combinators.test :refer [match?]]
[schema.test :as s]
[service.component :as component.service]
[service.interceptors :as interceptors]))

(def parse-json-body-interceptor
(pedestal.interceptor/interceptor
{:name ::parse-json-body
:enter (fn [ctx]
(let [body (-> ctx :request :body)
body-str (if body
(slurp body)
"")]
(assoc-in ctx [:request :json-params]
(if (str/blank? body-str)
""
(json/decode body-str true)))))}))

(def routes [["/test" :get [interceptors/http-request-in-handle-timing-interceptor
pedestal.service.interceptors/json-body
(fn [{{:keys [config]} :components}]
{:status 200
:body config})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode config)})]
:route-name :test]
["/blocking" :get [pedestal.service.interceptors/json-body
(fn [_]
["/blocking" :get [(fn [_]
(Thread/sleep 500)
{:status 200
:body {:message "done"}})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode {:message "done"})})]
:route-name :blocking]
["/thread-info" :get [pedestal.service.interceptors/json-body
(fn [_]
["/thread-info" :get [(fn [_]
{:status 200
:body {:virtual? (-> (Thread/currentThread) .getClass .getName
(str/includes? "VirtualThread"))}})]
:headers {"Content-Type" "application/json;charset=UTF-8"}
:body (json/encode {:virtual? (-> (Thread/currentThread) .getClass .getName
(str/includes? "VirtualThread"))})})]
:route-name :thread-info]])

(def system-components
Expand Down
Loading