Skip to content

Commit ffd13a8

Browse files
Add /api/* JAX-RS layer to webapp with db-disk-space endpoint
Bootstraps Jersey 3.1.7 in transitclockWebapp under @ApplicationPath("api"), distinct from transitclockApi's /v1/* surface. First endpoint is GET /api/status/db-disk-space?a=<agency>&k=<apiKey>, which moves the inline Postgres catalog SQL out of dbDiskSpace.jsp into a shared DbDiskSpaceQuery helper (used by both the JSP and the REST endpoint) and gates external access via ApiKeyManager — the same key namespace transitclockApi uses. Pom needed two surprises documented inline: (1) excluding the pre-Jakarta Jersey 2.11 jars that transitclockTraccarClient drags in transitively, because their SPI-registered Jackson provider crashes Jersey 3.1's servlet init; (2) intentionally not pulling in jersey-media-json-jackson, because its jackson-databind 2.17.x bump breaks Hibernate's JacksonIntegration against the rest of the 2.8.9 chain.
1 parent 344f4d3 commit ffd13a8

5 files changed

Lines changed: 210 additions & 45 deletions

File tree

transitclockWebapp/pom.xml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
<properties>
1313
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
14+
<!-- Keep in sync with transitclockApi: jakarta-namespace Jersey
15+
line implementing Jakarta REST 3.1 / Jakarta EE 10. -->
16+
<jersey.version>3.1.7</jersey.version>
1417
</properties>
1518

1619
<!-- See root pom.xml: pins JAXB 2.x build-POM transitives to working
@@ -87,13 +90,67 @@
8790
<groupId>TheTransitClock</groupId>
8891
<artifactId>transitclockCore</artifactId>
8992
<version>3.0.0-SNAPSHOT</version>
93+
<!-- Strip the pre-Jakarta Jersey 2.11 (2014) jars that
94+
transitclockTraccarClient drags in. The webapp doesn't
95+
invoke Traccar's HTTP client, but jersey-media-json-jackson
96+
2.11 ships a META-INF/services entry registering
97+
org.glassfish.jersey.jackson.internal.DefaultJacksonJaxbJsonProvider,
98+
which Jersey 3.1's WebApiApplication picks up at servlet
99+
init. Instantiating that 2.x provider against the 2.8.9
100+
jackson chain blows up on a missing JacksonFeature class,
101+
killing the entire JAX-RS context. Excluding here keeps
102+
Core's own build/runtime untouched (exclusions are
103+
per-consumer). -->
104+
<exclusions>
105+
<exclusion>
106+
<groupId>org.glassfish.jersey.core</groupId>
107+
<artifactId>jersey-client</artifactId>
108+
</exclusion>
109+
<exclusion>
110+
<groupId>org.glassfish.jersey.media</groupId>
111+
<artifactId>jersey-media-json-jackson</artifactId>
112+
</exclusion>
113+
<exclusion>
114+
<groupId>org.glassfish.jersey.media</groupId>
115+
<artifactId>jersey-media-multipart</artifactId>
116+
</exclusion>
117+
</exclusions>
90118
</dependency>
91119
<dependency>
92120
<groupId>org.glassfish.web</groupId>
93121
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
94122
<version>3.0.1</version>
95123
</dependency>
96124

125+
<!-- JAX-RS for the webapp-internal JSON layer served at /api/*.
126+
Distinct from transitclockApi's /v1/* surface: this layer is
127+
for endpoints that talk to the DB directly rather than to
128+
Core via RMI (e.g. db-disk-space). -->
129+
<dependency>
130+
<groupId>jakarta.ws.rs</groupId>
131+
<artifactId>jakarta.ws.rs-api</artifactId>
132+
<version>3.1.0</version>
133+
</dependency>
134+
<dependency>
135+
<groupId>org.glassfish.jersey.containers</groupId>
136+
<artifactId>jersey-container-servlet</artifactId>
137+
<version>${jersey.version}</version>
138+
</dependency>
139+
<dependency>
140+
<groupId>org.glassfish.jersey.inject</groupId>
141+
<artifactId>jersey-hk2</artifactId>
142+
<version>${jersey.version}</version>
143+
</dependency>
144+
<!-- Intentionally NOT pulling in a JSON-binding provider
145+
(jersey-media-json-jackson / jersey-media-moxy). Jersey 3.1.7's
146+
jersey-media-json-jackson forces jackson-databind to 2.17.x while
147+
transitclockCore's transitive Jackson chain stays at 2.8.9, which
148+
breaks Hibernate's JacksonIntegration static init at runtime.
149+
Endpoints that need to vend JSON should return a pre-serialized
150+
String wrapped in Response.ok(...).type(APPLICATION_JSON), the
151+
way DbDiskSpaceResource does. Revisit if/when an endpoint needs
152+
POJO marshalling and the core Jackson chain is unified. -->
153+
97154
</dependencies>
98155
<build>
99156
<!-- Set the name of the war file -->
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package org.transitclock.reports;
2+
3+
import java.sql.SQLException;
4+
5+
/**
6+
* Postgres-catalog queries for per-table on-disk size, returned as
7+
* Google Charts DataTable JSON. Used by status/dbDiskSpace.jsp (server-side
8+
* render) and by the JAX-RS endpoint at /api/status/db-disk-space (external
9+
* authorized clients). Centralized here so the SQL doesn't drift between
10+
* the two callers.
11+
*/
12+
public final class DbDiskSpaceQuery {
13+
14+
// Two queries unioned so the "Total:" row stays at the bottom regardless
15+
// of how the table is later sorted client-side. The outer SELECT drops
16+
// the ordering column and renames the rest to human-readable headers.
17+
private static final String TOTALS_SQL =
18+
"SELECT relname AS \"Table Name\", "
19+
+ " total_size AS \"Total Size\", "
20+
+ " total_bytes AS \"Total Bytes\" "
21+
+ " FROM "
22+
+ "((SELECT relname , "
23+
+ " pg_size_pretty(pg_total_relation_size(C.oid)) AS total_size, "
24+
+ " pg_total_relation_size(C.oid) AS total_bytes, "
25+
+ " 1 AS ordering"
26+
+ " FROM pg_class C "
27+
+ " LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
28+
+ " WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
29+
+ " AND C.relkind <> 'i' "
30+
+ " AND nspname !~ '^pg_toast' "
31+
+ ") "
32+
+ "UNION "
33+
+ "SELECT 'Total:', "
34+
+ " pg_size_pretty(SUM(pg_relation_size(C.oid))), "
35+
+ " SUM(pg_relation_size(C.oid)), "
36+
+ " 2 as ordering "
37+
+ " FROM pg_class C "
38+
+ " LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
39+
+ " WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
40+
+ ") AS needed_alias_name "
41+
+ "ORDER BY ordering, \"Total Bytes\" DESC";
42+
43+
// Detail view: one row per user relation (tables and indexes). Toast
44+
// tables are filtered out as standalone rows; their bytes still roll
45+
// into pg_total_relation_size for the owning table.
46+
private static final String DETAILS_SQL =
47+
"SELECT relname AS \"Table Name\", "
48+
+ "pg_size_pretty(pg_total_relation_size(C.oid)) AS \"Total Size\", "
49+
+ "pg_total_relation_size(C.oid) AS \"Total Bytes\" "
50+
+ "FROM pg_class C "
51+
+ "LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
52+
+ "WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
53+
+ " AND nspname !~ '^pg_toast' "
54+
+ "ORDER BY pg_total_relation_size(C.oid) DESC";
55+
56+
private DbDiskSpaceQuery() {}
57+
58+
public static String getTotalsJson(String agencyId) throws SQLException {
59+
return ChartGenericJsonQuery.getJsonString(agencyId, TOTALS_SQL, null, null);
60+
}
61+
62+
public static String getDetailsJson(String agencyId) throws SQLException {
63+
return ChartGenericJsonQuery.getJsonString(agencyId, DETAILS_SQL);
64+
}
65+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.transitclock.web.api;
2+
3+
import jakarta.ws.rs.GET;
4+
import jakarta.ws.rs.Path;
5+
import jakarta.ws.rs.Produces;
6+
import jakarta.ws.rs.QueryParam;
7+
import jakarta.ws.rs.WebApplicationException;
8+
import jakarta.ws.rs.core.MediaType;
9+
import jakarta.ws.rs.core.Response;
10+
import jakarta.ws.rs.core.Response.Status;
11+
import java.sql.SQLException;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
import org.transitclock.db.webstructs.ApiKeyManager;
15+
import org.transitclock.reports.DbDiskSpaceQuery;
16+
17+
/**
18+
* GET /api/status/db-disk-space?a=&lt;agency&gt;&amp;k=&lt;apiKey&gt;
19+
*
20+
* Returns Postgres on-disk size info for the given agency's DB. Body is
21+
* {"totals": &lt;Google Charts DataTable JSON&gt;, "details": &lt;ditto&gt;}; either
22+
* member may be null if the underlying query produced no rows. Requires a
23+
* valid API key — the same key namespace transitclockApi uses (managed via
24+
* the CreateAPIKey shaded jar).
25+
*/
26+
@Path("status/db-disk-space")
27+
public class DbDiskSpaceResource {
28+
29+
private static final Logger logger = LoggerFactory.getLogger(DbDiskSpaceResource.class);
30+
31+
@GET
32+
@Produces(MediaType.APPLICATION_JSON)
33+
public Response get(@QueryParam("a") String agencyId,
34+
@QueryParam("k") String apiKey) {
35+
requireValidKey(apiKey);
36+
if (agencyId == null || agencyId.isEmpty()) {
37+
throw error(Status.BAD_REQUEST, "Missing required query parameter 'a' (agency id)");
38+
}
39+
40+
try {
41+
String totals = DbDiskSpaceQuery.getTotalsJson(agencyId);
42+
String details = DbDiskSpaceQuery.getDetailsJson(agencyId);
43+
// Both query results are already valid JSON (or null); concatenate
44+
// raw to avoid a parse/re-serialize round-trip.
45+
String body = "{\"totals\":" + (totals != null ? totals : "null")
46+
+ ",\"details\":" + (details != null ? details : "null") + "}";
47+
return Response.ok(body).type(MediaType.APPLICATION_JSON).build();
48+
} catch (SQLException | RuntimeException e) {
49+
// Log the full stack server-side; return a generic body so we
50+
// don't leak Postgres error text (schema names, role names,
51+
// connection URLs) to API clients.
52+
logger.error("db-disk-space query failed for agency={}", agencyId, e);
53+
throw error(Status.INTERNAL_SERVER_ERROR, "Database query failed; see server logs.");
54+
}
55+
}
56+
57+
private static void requireValidKey(String key) {
58+
if (key == null || key.isEmpty() || !ApiKeyManager.getInstance().isKeyValid(key)) {
59+
throw error(Status.UNAUTHORIZED, "Missing or invalid API key (query param 'k')");
60+
}
61+
}
62+
63+
private static WebApplicationException error(Status status, String message) {
64+
return new WebApplicationException(
65+
Response.status(status).entity(message).type(MediaType.TEXT_PLAIN).build());
66+
}
67+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.transitclock.web.api;
2+
3+
import jakarta.ws.rs.ApplicationPath;
4+
import org.glassfish.jersey.server.ResourceConfig;
5+
6+
/**
7+
* Webapp-internal JSON layer mounted at /api/*. Distinct from
8+
* transitclockApi's /v1/* surface: this layer is for endpoints that talk
9+
* to the DB directly (e.g. db-disk-space) rather than to a Core JVM via
10+
* RMI. JAX-RS resource classes live in this package and are discovered
11+
* by Jersey's package scan.
12+
*/
13+
@ApplicationPath("api")
14+
public class WebApiApplication extends ResourceConfig {
15+
public WebApiApplication() {
16+
packages("org.transitclock.web.api");
17+
}
18+
}

transitclockWebapp/src/main/webapp/status/dbDiskSpace.jsp

Lines changed: 3 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,13 @@
1-
<%@ page import="org.transitclock.reports.ChartGenericJsonQuery" %>
1+
<%@ page import="org.transitclock.reports.DbDiskSpaceQuery" %>
22
<%
33
String agencyId = request.getParameter("a");
44
if (agencyId == null || agencyId.isEmpty()) {
55
response.getWriter().write("You must specify agency in query string (e.g. ?a=mbta)");
66
return;
77
}
88
9-
// This query is rather complicated. Want the values in order but also
10-
// want total at end. Using two queries and a union to do this but
11-
// need to also use an ordering column so that the total will always
12-
// be at the end. And then need to do a select on the whole result
13-
// to get rid of the ordering column and to provde human readable
14-
// column titles like "Table Size".
15-
String sql =
16-
"SELECT relname AS \"Table Name\", "
17-
+ " total_size AS \"Total Size\", "
18-
+ " total_bytes AS \"Total Bytes\" "
19-
+ " FROM "
20-
+ "((SELECT relname , "
21-
+ " pg_size_pretty(pg_total_relation_size(C.oid)) AS total_size, "
22-
+ " pg_total_relation_size(C.oid) AS total_bytes, "
23-
+ " 1 AS ordering"
24-
+ " FROM pg_class C "
25-
+ " LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
26-
+ " WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
27-
+ " AND C.relkind <> 'i' "
28-
+ " AND nspname !~ '^pg_toast' "
29-
+ ") "
30-
+ "UNION "
31-
+ "SELECT 'Total:', "
32-
+ " pg_size_pretty(SUM(pg_relation_size(C.oid))), "
33-
+ " SUM(pg_relation_size(C.oid)), "
34-
+ " 2 as ordering "
35-
+ " FROM pg_class C "
36-
+ " LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
37-
+ " WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
38-
+ ") AS needed_alias_name "
39-
+ "ORDER BY ordering, \"Total Bytes\" DESC";
40-
41-
String sql2 =
42-
"SELECT relname AS \"Table Name\", "
43-
+ "pg_size_pretty(pg_total_relation_size(C.oid)) AS \"Total Size\", "
44-
+ "pg_total_relation_size(C.oid) AS \"Total Bytes\" "
45-
+ "FROM pg_class C "
46-
+ "LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
47-
+ "WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
48-
+ " AND nspname !~ '^pg_toast' "
49-
+ "ORDER BY pg_total_relation_size(C.oid) DESC";
50-
51-
pageContext.setAttribute("jsonData1", ChartGenericJsonQuery.getJsonString(agencyId, sql, null, null));
52-
pageContext.setAttribute("jsonData2", ChartGenericJsonQuery.getJsonString(agencyId, sql2));
9+
pageContext.setAttribute("jsonData1", DbDiskSpaceQuery.getTotalsJson(agencyId));
10+
pageContext.setAttribute("jsonData2", DbDiskSpaceQuery.getDetailsJson(agencyId));
5311
%>
5412
<t:layout>
5513
<jsp:attribute name="title"><fmt:message key="div.ddsu" /></jsp:attribute>

0 commit comments

Comments
 (0)