Skip to content

Commit ef46285

Browse files
Merge pull request #43 from OneBusAway/ui-redesign-2
Vend DB disk-space stats via authenticated JAX-RS endpoint
2 parents 344f4d3 + e7a5f38 commit ef46285

7 files changed

Lines changed: 549 additions & 45 deletions

File tree

transitclockWebapp/pom.xml

Lines changed: 91 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
@@ -60,6 +63,40 @@
6063
<version>3.26.3</version>
6164
<scope>test</scope>
6265
</dependency>
66+
<dependency>
67+
<groupId>org.mockito</groupId>
68+
<artifactId>mockito-core</artifactId>
69+
<version>5.12.0</version>
70+
<scope>test</scope>
71+
</dependency>
72+
<!-- Jersey Test Framework: same usage and jupiter-exclusion
73+
rationale as transitclockApi/pom.xml. Spins the JAX-RS layer
74+
in-process on Grizzly2 so DbDiskSpaceResource can be
75+
exercised end-to-end without Tomcat. -->
76+
<dependency>
77+
<groupId>org.glassfish.jersey.test-framework</groupId>
78+
<artifactId>jersey-test-framework-core</artifactId>
79+
<version>${jersey.version}</version>
80+
<scope>test</scope>
81+
<exclusions>
82+
<exclusion>
83+
<groupId>org.junit.jupiter</groupId>
84+
<artifactId>junit-jupiter</artifactId>
85+
</exclusion>
86+
</exclusions>
87+
</dependency>
88+
<dependency>
89+
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
90+
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
91+
<version>${jersey.version}</version>
92+
<scope>test</scope>
93+
<exclusions>
94+
<exclusion>
95+
<groupId>org.junit.jupiter</groupId>
96+
<artifactId>junit-jupiter</artifactId>
97+
</exclusion>
98+
</exclusions>
99+
</dependency>
63100
<dependency>
64101
<groupId>jakarta.servlet</groupId>
65102
<artifactId>jakarta.servlet-api</artifactId>
@@ -87,13 +124,67 @@
87124
<groupId>TheTransitClock</groupId>
88125
<artifactId>transitclockCore</artifactId>
89126
<version>3.0.0-SNAPSHOT</version>
127+
<!-- Strip the pre-Jakarta Jersey 2.11 (2014) jars that
128+
transitclockTraccarClient drags in. The webapp doesn't
129+
invoke Traccar's HTTP client, but jersey-media-json-jackson
130+
2.11 ships a META-INF/services entry registering
131+
org.glassfish.jersey.jackson.internal.DefaultJacksonJaxbJsonProvider,
132+
which Jersey 3.1's WebApiApplication picks up at servlet
133+
init. Instantiating that 2.x provider against the 2.8.9
134+
jackson chain blows up on a missing JacksonFeature class,
135+
killing the entire JAX-RS context. Excluding here keeps
136+
Core's own build/runtime untouched (exclusions are
137+
per-consumer). -->
138+
<exclusions>
139+
<exclusion>
140+
<groupId>org.glassfish.jersey.core</groupId>
141+
<artifactId>jersey-client</artifactId>
142+
</exclusion>
143+
<exclusion>
144+
<groupId>org.glassfish.jersey.media</groupId>
145+
<artifactId>jersey-media-json-jackson</artifactId>
146+
</exclusion>
147+
<exclusion>
148+
<groupId>org.glassfish.jersey.media</groupId>
149+
<artifactId>jersey-media-multipart</artifactId>
150+
</exclusion>
151+
</exclusions>
90152
</dependency>
91153
<dependency>
92154
<groupId>org.glassfish.web</groupId>
93155
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
94156
<version>3.0.1</version>
95157
</dependency>
96158

159+
<!-- JAX-RS for the webapp-internal JSON layer served at /api/*.
160+
Distinct from transitclockApi's /v1/* surface: this layer is
161+
for endpoints that talk to the DB directly rather than to
162+
Core via RMI (e.g. db-disk-space). -->
163+
<dependency>
164+
<groupId>jakarta.ws.rs</groupId>
165+
<artifactId>jakarta.ws.rs-api</artifactId>
166+
<version>3.1.0</version>
167+
</dependency>
168+
<dependency>
169+
<groupId>org.glassfish.jersey.containers</groupId>
170+
<artifactId>jersey-container-servlet</artifactId>
171+
<version>${jersey.version}</version>
172+
</dependency>
173+
<dependency>
174+
<groupId>org.glassfish.jersey.inject</groupId>
175+
<artifactId>jersey-hk2</artifactId>
176+
<version>${jersey.version}</version>
177+
</dependency>
178+
<!-- Intentionally NOT pulling in a JSON-binding provider
179+
(jersey-media-json-jackson / jersey-media-moxy). Jersey 3.1.7's
180+
jersey-media-json-jackson forces jackson-databind to 2.17.x while
181+
transitclockCore's transitive Jackson chain stays at 2.8.9, which
182+
breaks Hibernate's JacksonIntegration static init at runtime.
183+
Endpoints that need to vend JSON should return a pre-serialized
184+
String wrapped in Response.ok(...).type(APPLICATION_JSON), the
185+
way DbDiskSpaceResource does. Revisit if/when an endpoint needs
186+
POJO marshalling and the core Jackson chain is unified. -->
187+
97188
</dependencies>
98189
<build>
99190
<!-- Set the name of the war file -->
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
// Total row must use the same size function (pg_total_relation_size:
34+
// main + indexes + TOAST) and the same WHERE filters as the per-row
35+
// inner SELECT, otherwise it under- or double-counts.
36+
+ "SELECT 'Total:', "
37+
+ " pg_size_pretty(SUM(pg_total_relation_size(C.oid))), "
38+
+ " SUM(pg_total_relation_size(C.oid)), "
39+
+ " 2 as ordering "
40+
+ " FROM pg_class C "
41+
+ " LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
42+
+ " WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
43+
+ " AND C.relkind <> 'i' "
44+
+ " AND nspname !~ '^pg_toast' "
45+
+ ") AS needed_alias_name "
46+
+ "ORDER BY ordering, \"Total Bytes\" DESC";
47+
48+
// Detail view: one row per user relation (tables and indexes). Toast
49+
// tables are filtered out as standalone rows; their bytes still roll
50+
// into pg_total_relation_size for the owning table.
51+
private static final String DETAILS_SQL =
52+
"SELECT relname AS \"Table Name\", "
53+
+ "pg_size_pretty(pg_total_relation_size(C.oid)) AS \"Total Size\", "
54+
+ "pg_total_relation_size(C.oid) AS \"Total Bytes\" "
55+
+ "FROM pg_class C "
56+
+ "LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) "
57+
+ "WHERE nspname NOT IN ('pg_catalog', 'information_schema') "
58+
+ " AND nspname !~ '^pg_toast' "
59+
+ "ORDER BY pg_total_relation_size(C.oid) DESC";
60+
61+
private DbDiskSpaceQuery() {}
62+
63+
public static String getTotalsJson(String agencyId) throws SQLException {
64+
return ChartGenericJsonQuery.getJsonString(agencyId, TOTALS_SQL);
65+
}
66+
67+
public static String getDetailsJson(String agencyId) throws SQLException {
68+
return ChartGenericJsonQuery.getJsonString(agencyId, DETAILS_SQL);
69+
}
70+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 java.util.function.Predicate;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
import org.transitclock.db.webstructs.ApiKeyManager;
16+
import org.transitclock.reports.DbDiskSpaceQuery;
17+
18+
/**
19+
* GET /api/status/db-disk-space?a=&lt;agency&gt;&amp;k=&lt;apiKey&gt;
20+
*
21+
* Returns Postgres on-disk size info for the given agency's DB. Body is
22+
* {"totals": &lt;Google Charts DataTable JSON&gt;, "details": &lt;ditto&gt;}; either
23+
* member may be null if the underlying query produced no rows. Requires a
24+
* valid API key — the same key namespace transitclockApi uses (managed via
25+
* the CreateAPIKey shaded jar).
26+
*/
27+
@Path("status/db-disk-space")
28+
public class DbDiskSpaceResource {
29+
30+
private static final Logger logger = LoggerFactory.getLogger(DbDiskSpaceResource.class);
31+
32+
@FunctionalInterface
33+
interface AgencyQuery {
34+
String run(String agencyId) throws SQLException;
35+
}
36+
37+
private final AgencyQuery totalsFn;
38+
private final AgencyQuery detailsFn;
39+
private final Predicate<String> apiKeyValidator;
40+
41+
public DbDiskSpaceResource() {
42+
this(DbDiskSpaceQuery::getTotalsJson,
43+
DbDiskSpaceQuery::getDetailsJson,
44+
k -> ApiKeyManager.getInstance().isKeyValid(k));
45+
}
46+
47+
DbDiskSpaceResource(AgencyQuery totalsFn,
48+
AgencyQuery detailsFn,
49+
Predicate<String> apiKeyValidator) {
50+
this.totalsFn = totalsFn;
51+
this.detailsFn = detailsFn;
52+
this.apiKeyValidator = apiKeyValidator;
53+
}
54+
55+
@GET
56+
@Produces(MediaType.APPLICATION_JSON)
57+
public Response get(@QueryParam("a") String agencyId,
58+
@QueryParam("k") String apiKey) {
59+
requireValidKey(apiKey);
60+
if (agencyId == null || agencyId.isEmpty()) {
61+
throw error(Status.BAD_REQUEST, "Missing required query parameter 'a' (agency id)");
62+
}
63+
64+
try {
65+
String totals = totalsFn.run(agencyId);
66+
String details = detailsFn.run(agencyId);
67+
// Both query results are already valid JSON (or null); concatenate
68+
// raw to avoid a parse/re-serialize round-trip.
69+
String body = "{\"totals\":" + (totals != null ? totals : "null")
70+
+ ",\"details\":" + (details != null ? details : "null") + "}";
71+
return Response.ok(body).type(MediaType.APPLICATION_JSON).build();
72+
} catch (SQLException | RuntimeException e) {
73+
// Log the full stack server-side; return a generic body so we
74+
// don't leak Postgres error text (schema names, role names,
75+
// connection URLs) to API clients.
76+
logger.error("db-disk-space query failed for agency={}", agencyId, e);
77+
throw error(Status.INTERNAL_SERVER_ERROR, "Database query failed; see server logs.");
78+
}
79+
}
80+
81+
private void requireValidKey(String key) {
82+
if (key == null || key.isEmpty() || !apiKeyValidator.test(key)) {
83+
throw error(Status.UNAUTHORIZED, "Missing or invalid API key (query param 'k')");
84+
}
85+
}
86+
87+
private static WebApplicationException error(Status status, String message) {
88+
return new WebApplicationException(
89+
Response.status(status).entity(message).type(MediaType.TEXT_PLAIN).build());
90+
}
91+
}
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)