diff --git a/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy b/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy
index d2ac9bc6..21f0d5f8 100644
--- a/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy
+++ b/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy
@@ -365,8 +365,20 @@ class SimpleRestService {
ByteArrayOutputStream errorResponse = new ByteArrayOutputStream()
errorResponse << conn.getErrorStream()
response_content = errorResponse.toString()
- } else {
+ } else if(response_headers.find { it.key?.equalsIgnoreCase('Content-Type') }) {
response_content = conn.getContent().getText()
+ } else {
+ // getContent() dispatches on the Content-Type header and throws
+ // UnknownServiceException('no content-type') when the response
+ // omits it, so a response with a body but no Content-Type has to
+ // be read straight off the stream instead. This is a real
+ // response shape rather than a broken server: Express
+ // `res.status(200).end()` behind compression middleware sends a
+ // bodyless 200 chunked, with neither Content-Length nor
+ // Content-Type, and that must not look like a transport failure.
+ ByteArrayOutputStream successResponse = new ByteArrayOutputStream()
+ successResponse << conn.getInputStream()
+ response_content = successResponse.toString()
}
response_content
}
diff --git a/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy b/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy
new file mode 100644
index 00000000..849ae9ae
--- /dev/null
+++ b/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy
@@ -0,0 +1,79 @@
+/*
+ Copyright 2014-2026 Sam Gleske - https://github.com/samrocketman/jervis
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+package net.gleske.jervis.remotes
+
+import static net.gleske.jervis.remotes.SimpleRestService.apiFetch
+
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+/**
+ Responses which omit the Content-Type header are served by a real
+ loopback server rather than the static mocks. The behavior under test is
+ {@link java.net.URLConnection#getContent()} throwing
+ UnknownServiceException, which belongs to the JDK: a mock declaring
+ that it throws would only be asserting against itself.
+ */
+class SimpleRestServiceNoContentTypeTest extends GroovyTestCase {
+ HttpServer server
+ String baseUrl
+
+ @Before protected void setUp() {
+ super.setUp()
+ // mockStaticUrl replaces URL.metaClass process-wide and no test restores
+ // it, so whether a real connection is possible here depends on which
+ // classes ran first. Drop any installed mock; classes which want one
+ // install it in their own setUp.
+ GroovySystem.metaClassRegistry.removeMetaClass(URL)
+ server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
+ // Zero-length chunked responses, with no Content-Type and no
+ // Content-Length. sendResponseHeaders(code, 0) means chunked here.
+ server.createContext('/empty') { exchange ->
+ exchange.sendResponseHeaders(200, 0)
+ exchange.getResponseBody().close()
+ }
+ server.createContext('/body') { exchange ->
+ byte[] body = '{"some":"response"}'.bytes
+ exchange.sendResponseHeaders(200, 0)
+ exchange.getResponseBody().withCloseable { it << body }
+ }
+ server.start()
+ baseUrl = "http://127.0.0.1:${server.getAddress().getPort()}"
+ }
+ @After protected void tearDown() {
+ server.stop(0)
+ GroovySystem.metaClassRegistry.removeMetaClass(URL)
+ super.tearDown()
+ }
+ @Test public void test_SimpleRestService_apiFetch_empty_chunked_response_without_content_type() {
+ // A bodyless chunked 200 is a success, not a transport failure. Before
+ // this was handled it threw UnknownServiceException('no content-type').
+ Map response = apiFetch(new URL("${baseUrl}/empty"), ['Response-Map': true], 'POST', '{}')
+ assert response.response_code == 200
+ assert response.error == false
+ assert response.content == null
+ }
+ @Test public void test_SimpleRestService_apiFetch_response_body_without_content_type() {
+ // A body still parses when the server never said what it was; the
+ // Content-Type default only governs the request.
+ Map response = apiFetch(new URL("${baseUrl}/body"), ['Response-Map': true], 'POST', '{}')
+ assert response.response_code == 200
+ assert response.content == [some: 'response']
+ }
+}
diff --git a/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy b/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy
index 41224b08..d46b4f0d 100644
--- a/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy
+++ b/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy
@@ -140,7 +140,14 @@ JERVIS_MOCKS_PATH=/tmp/mocks ./gradlew clean check
},
getHeaderFields: { ->
request_meta.data = request_meta.data?.toString() ?: ''
- Map header_fields = [(null): Collections.unmodifiableList(['HTTP/1.1 200 OK'])]
+ // The recorded fixtures are JSON API responses, so the mock
+ // declares the Content-Type a real one carries. Without it
+ // every mocked response looks Content-Type-less, which is now
+ // a distinct (and separately handled) read path in apiFetch.
+ Map header_fields = [
+ (null): Collections.unmodifiableList(['HTTP/1.1 200 OK']),
+ 'Content-Type': Collections.unmodifiableList(['application/json'])
+ ]
String file = urlToMockFileName(mockedUrl, [request_meta.method, request_meta.data].join(' '), checksumMocks, checksumAlgorithm)
if(file in custom_responses.keySet()) {
//throw new Exception( custom_responses.get(file) )