From 1808624237868703ecc7ee1f906c1b5c86b46e24 Mon Sep 17 00:00:00 2001 From: Alex Zaw Date: Sat, 16 Aug 2025 16:48:54 -0700 Subject: [PATCH] upload current sources --- .gitignore | 3 + .vscode/settings.json | 3 + pom.xml | 78 ++++++++ .../fetchapi/AuthenticationService.java | 86 +++++++++ .../fetchapi/CharacterEncodingFilter.java | 26 +++ .../dev/alexzaw/fetchapi/DatabaseManager.java | 164 ++++++++++++++++ .../dev/alexzaw/fetchapi/FetchAPIServlet.java | 163 ++++++++++++++++ .../dev/alexzaw/fetchapi/OutputFormatter.java | 47 +++++ .../dev/alexzaw/fetchapi/QueryResult.java | 62 ++++++ .../dev/alexzaw/fetchapi/StyleManager.java | 127 +++++++++++++ src/main/java/dev/alexzaw/fetchapi/Utils.java | 83 ++++++++ .../fetchapi/formatter/CsvFormatter.java | 88 +++++++++ .../fetchapi/formatter/ErrorFormatter.java | 27 +++ .../fetchapi/formatter/ExcelFormatter.java | 136 +++++++++++++ .../fetchapi/formatter/FormatterFactory.java | 25 +++ .../fetchapi/formatter/HtmlFormatter.java | 178 ++++++++++++++++++ .../fetchapi/formatter/JsonFormatter.java | 61 ++++++ .../fetchapi/formatter/ResponseFormatter.java | 11 ++ .../fetchapi/formatter/XmlFormatter.java | 90 +++++++++ src/main/webapp/WEB-INF/web.xml | 31 +++ 20 files changed, 1489 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 pom.xml create mode 100644 src/main/java/dev/alexzaw/fetchapi/AuthenticationService.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/CharacterEncodingFilter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/DatabaseManager.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/FetchAPIServlet.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/OutputFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/QueryResult.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/StyleManager.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/Utils.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/CsvFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/ErrorFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/ExcelFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/FormatterFactory.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/HtmlFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/JsonFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/ResponseFormatter.java create mode 100644 src/main/java/dev/alexzaw/fetchapi/formatter/XmlFormatter.java create mode 100644 src/main/webapp/WEB-INF/web.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9fdfb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/src/main/webapp/WEB-INF/classes +/lib +/target diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..87eac09 --- /dev/null +++ b/pom.xml @@ -0,0 +1,78 @@ + + 4.0.0 + + dev.alexzaw + fetchapi + 1.0.0 + war + FetchAPI + API for fetching data from AS400 database + + + UTF-8 + 1.8 + 1.8 + 5.2.3 + 10.7 + 20231013 + 4.0.1 + + + + + net.sf.jt400 + jt400 + ${jt400.version} + + + org.apache.poi + poi + ${poi.version} + + + org.apache.poi + poi-ooxml + ${poi.version} + + + org.json + json + ${json.version} + + + javax.servlet + javax.servlet-api + ${servlet.version} + provided + + + javax.xml.bind + jaxb-api + 2.3.1 + + + + FetchAPI + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + false + + + + + diff --git a/src/main/java/dev/alexzaw/fetchapi/AuthenticationService.java b/src/main/java/dev/alexzaw/fetchapi/AuthenticationService.java new file mode 100644 index 0000000..a91374e --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/AuthenticationService.java @@ -0,0 +1,86 @@ +package dev.alexzaw.fetchapi; + +import com.ibm.as400.access.AS400; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletRequest; + +public class AuthenticationService { + private static final Logger logger = Logger.getLogger(AuthenticationService.class.getName()); + private Properties configProps; + private Properties APITokens; + + public AuthenticationService(Properties configProps, Properties APITokens) { + this.configProps = configProps; + this.APITokens = APITokens; + } + + public boolean authenticate(HttpServletRequest req) { + String userId = req.getParameter("userId"); + String password = req.getParameter("password"); + String apiToken = req.getParameter("apiToken"); + + if ((userId == null || password == null) && apiToken == null) { + logger.warning("No credentials provided"); + return false; + } + + return userId != null && password != null + ? authenticateWithCredentials(userId, password) + : apiToken != null && validateApiToken(apiToken); + } + + public boolean authenticateWithCredentials(String userId, String password) { + AS400 system = null; + try { + system = new AS400( + configProps.getProperty("db.server"), + userId, + password + ); + system.validateSignon(); + logger.info("User authenticated successfully: " + userId); + return true; + } catch (Exception e) { + logger.log(Level.WARNING, "Authentication failed", e); + return false; + } finally { + if (system != null) { + system.disconnectAllServices(); + } + } + } + + public boolean validateApiToken(String tokenToValidate) { + try { + String decodedToken = Utils.decodeBase64(tokenToValidate); + String[] parts = decodedToken.split(":"); + + if (parts.length != 2) { + logger.warning("Invalid token format"); + return false; + } + + String identifier = parts[0]; + String uuid = parts[1]; + String validToken = APITokens.getProperty(identifier); + + if (validToken == null || validToken.trim().isEmpty()) { + logger.warning("No API token configured for: " + identifier); + return false; + } + + if (validToken.equals(uuid)) { + logger.info("API token validated for: " + identifier); + return true; + } + + logger.warning("Invalid token attempted for: " + identifier); + return false; + } catch (Exception e) { + logger.log(Level.SEVERE, "Error validating API token", e); + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/CharacterEncodingFilter.java b/src/main/java/dev/alexzaw/fetchapi/CharacterEncodingFilter.java new file mode 100644 index 0000000..35082d9 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/CharacterEncodingFilter.java @@ -0,0 +1,26 @@ + +package dev.alexzaw.fetchapi; + +import javax.servlet.*; +import java.io.IOException; + +public class CharacterEncodingFilter implements Filter { + private String encoding; + + public void init(FilterConfig config) throws ServletException { + encoding = config.getInitParameter("encoding"); + if (encoding == null) { + encoding = "UTF-8"; + } + } + + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + request.setCharacterEncoding(encoding); + response.setCharacterEncoding(encoding); + chain.doFilter(request, response); + } + + public void destroy() { + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/DatabaseManager.java b/src/main/java/dev/alexzaw/fetchapi/DatabaseManager.java new file mode 100644 index 0000000..0d885ff --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/DatabaseManager.java @@ -0,0 +1,164 @@ +package dev.alexzaw.fetchapi; + +import com.ibm.as400.access.*; +import java.beans.PropertyVetoException; +import java.sql.*; +import java.util.Properties; +import java.util.logging.Logger; +import static dev.alexzaw.fetchapi.Utils.*; + + +public class DatabaseManager { + private static final String JDBC_DRIVER = "com.ibm.as400.access.AS400JDBCDriver"; + private static final Logger logger = Logger.getLogger(DatabaseManager.class.getName()); + + private AS400JDBCConnectionPool pool; + private Properties configProps; + + public DatabaseManager(Properties configProps) throws Exception { + this.configProps = configProps; + Class.forName(JDBC_DRIVER); + initializeConnectionPool(); + } + + private void initializeConnectionPool() throws Exception { + try { + String username = configProps.getProperty("db.username"); + String password = decodeBase64(configProps.getProperty("db.password")); + String serverName = configProps.getProperty("db.server", "localhost"); + String jdbcString = configProps.getProperty("db.jdbcString"); + String libraries = configProps.getProperty("db.libraries", "QGPL,QTEMP"); + + validateConfig(username, password); + AS400JDBCConnectionPoolDataSource dataSource = createDataSource( + serverName, + username, + password, + jdbcString, + libraries + ); + pool = new AS400JDBCConnectionPool(dataSource); + configurePool(pool); + } catch (PropertyVetoException | ConnectionPoolException e) { + throw new Exception("Failed to initialize connection pool", e); + } + } + + private void validateConfig(String username, String password) throws Exception { + if (username == null || password == null) { + throw new Exception("Database username or password not provided in properties file"); + } + } + + private AS400JDBCConnectionPoolDataSource createDataSource( + String serverName, + String username, + String password, + String jdbcString, + String libraries + ) throws PropertyVetoException { + AS400JDBCConnectionPoolDataSource dataSource = new AS400JDBCConnectionPoolDataSource(); + dataSource.setServerName(serverName); + dataSource.setUser(username); + dataSource.setPassword(password); + dataSource.setProperties(jdbcString); + dataSource.setLibraries(libraries); + dataSource.setExtendedMetaData(true); + return dataSource; + } + + private void configurePool(AS400JDBCConnectionPool pool) throws ConnectionPoolException { + pool.setMaxConnections(40); + pool.fill(5); + } + + public QueryResult executeQuery(String sqlQuery) throws SQLException, ConnectionPoolException { + // Log connection pool status + logger.info("Connection pool before query - Active connections: " + pool.getActiveConnectionCount() + + ", Available connections: " + pool.getAvailableConnectionCount()); + + Connection connection = pool.getConnection(); + logger.info("Got new connection from pool: " + connection.hashCode()); + + PreparedStatement statement = null; + ResultSet resultSet = null; + + try { + statement = connection.prepareStatement( + sqlQuery, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + ); + + statement.setFetchSize(5000); + statement.setQueryTimeout(Integer.parseInt( + configProps.getProperty("query.timeout.seconds", "600") + )); + + // Execute the statement and check if it returns a ResultSet + boolean hasResultSet = statement.execute(); + + if (hasResultSet) { + // Handle SELECT queries + resultSet = statement.getResultSet(); + String[] headers = getQueryHeaders(resultSet); + ResultSetMetaData md = resultSet.getMetaData(); + StringBuilder sb = new StringBuilder("MD: "); + for (int i = 1; i <= md.getColumnCount(); i++) { + sb.append("[").append(i) + .append(" label=").append(md.getColumnLabel(i)) + .append(" name=").append(md.getColumnName(i)) + .append("]"); + } + logger.info(sb.toString()); + return new QueryResult( + connection, + statement, + resultSet, + headers, + resultSet.getMetaData() + ); + } else { + // Handle non-SELECT queries (CREATE, INSERT, etc.) + return new QueryResult( + connection, + statement, + null, + new String[0], + null + ); + } + } catch (SQLException e) { + // Clean up resources in case of error + if (resultSet != null) resultSet.close(); + if (statement != null) statement.close(); + if (connection != null) connection.close(); + throw e; + } + } + + private String[] getQueryHeaders(ResultSet rs) throws SQLException { + ResultSetMetaData rsmd = rs.getMetaData(); + int columnCount = rsmd.getColumnCount(); + String[] headers = new String[columnCount]; + + for (int i = 1; i <= columnCount; i++) { + // Prefer alias/label if defined + String label = rsmd.getColumnLabel(i); + if (label == null || label.trim().isEmpty()) { + label = rsmd.getColumnName(i); + } + // Title-case only for pretty outputs (HTML/Excel) + headers[i - 1] = toTitleCase(label.trim()); + } + return headers; + } + + + public void closePool() { + if (pool != null) { + pool.close(); + logger.info("Connection pool closed."); + } + } +} diff --git a/src/main/java/dev/alexzaw/fetchapi/FetchAPIServlet.java b/src/main/java/dev/alexzaw/fetchapi/FetchAPIServlet.java new file mode 100644 index 0000000..58c3ce7 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/FetchAPIServlet.java @@ -0,0 +1,163 @@ +package dev.alexzaw.fetchapi; + +import java.io.*; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.*; +import javax.servlet.http.*; + +public class FetchAPIServlet extends HttpServlet { + + private static final Logger logger = Logger.getLogger(FetchAPIServlet.class.getName()); + + private DatabaseManager dbManager; + private OutputFormatter outputFormatter; + private AuthenticationService authService; + private Properties configProps; + private Properties APITokens; + + @Override + public void init() throws ServletException { + System.setProperty( + "org.apache.logging.log4j.simplelog.StatusLogger.level", + "OFF" + ); + try { + configProps = loadProperties("/etc/misc/configs/FetchAPI.properties"); + APITokens = loadProperties("/etc/misc/configs/APITokens.properties"); + + dbManager = new DatabaseManager(configProps); + outputFormatter = new OutputFormatter(configProps); + authService = new AuthenticationService(configProps, APITokens); + + logger.info("FetchAPI initialized successfully"); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to initialize FetchAPI", e); + throw new ServletException(e); + } + } + + private Properties loadProperties(String propsPath) throws ServletException { + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(propsPath)) { + props.load(in); + return props; + } catch (IOException e) { + throw new ServletException("Failed to load properties file", e); + } + } + + @Override +protected void doGet( + HttpServletRequest request, + HttpServletResponse response +) throws ServletException, IOException { + processRequest(request, response, "GET"); +} + +@Override +protected void doPost( + HttpServletRequest request, + HttpServletResponse response +) throws ServletException, IOException { + processRequest(request, response, "POST"); +} + +private void processRequest( + HttpServletRequest request, + HttpServletResponse response, + String method +) throws ServletException, IOException { + QueryResult result = null; + request.setCharacterEncoding("UTF-8"); + Utils.addCorsHeaders(response); + + + + if (!authService.authenticate(request)) { + outputFormatter.sendErrorResponse( + response, + HttpServletResponse.SC_UNAUTHORIZED, + "Not Authorized!" + ); + return; + } + + // Works for both GET query params and POST form data + String format = request.getParameter("format"); + String sqlQuery = request.getParameter("sql"); + if (!validateQuery(sqlQuery)) { + outputFormatter.sendErrorResponse( + response, + HttpServletResponse.SC_BAD_REQUEST, + "Invalid or dangerous SQL query" + ); + return; + } + + try { + logger.info("Executing query: " + sqlQuery); + result = dbManager.executeQuery(sqlQuery); + + if (result.getResultSet() == null) { + // Non-query operation completed successfully + response.setContentType("application/json"); + response.getWriter().write("{\"success\":true,\"message\":\"SQL executed successfully\"}"); + logger.info("Non-query SQL executed successfully"); + } else { + // Query operation - format the results + if ("excel".equalsIgnoreCase(format)) { + outputFormatter.outputExcel(response, result); + } else if ("html".equalsIgnoreCase(format)) { + outputFormatter.outputHtml(response, result); + } else if ("csv".equalsIgnoreCase(format)) { + outputFormatter.outputCsv(response, result); + } else if ("xml".equalsIgnoreCase(format)) { + outputFormatter.outputXml(response, result); + } else { + // Default to JSON + outputFormatter.outputJson(response, result); + } + logger.info("Query results formatted successfully"); + } + + } catch (Exception e) { + logger.log(Level.SEVERE, "Error processing request", e); + outputFormatter.sendErrorResponse( + response, + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + e.getMessage() + ); + } finally { + if (result != null) { + try { + result.close(); + logger.info("Result closed"); + } catch (Exception e) { + logger.log(Level.WARNING, "Error closing database resources", e); + } + } + } +} + private boolean validateQuery(String sqlQuery) { + if (sqlQuery == null || sqlQuery.trim().isEmpty()) { + return false; + } + String lowercaseQuery = sqlQuery.toLowerCase(); + return !( + lowercaseQuery.contains("truncate") || + lowercaseQuery.contains("drop") || + lowercaseQuery.contains("delete from") + ); + } + + @Override + public void destroy() { + if (dbManager != null) { + dbManager.closePool(); + logger.info("Database connection pool closed."); + } + } +} + diff --git a/src/main/java/dev/alexzaw/fetchapi/OutputFormatter.java b/src/main/java/dev/alexzaw/fetchapi/OutputFormatter.java new file mode 100644 index 0000000..b8a96dc --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/OutputFormatter.java @@ -0,0 +1,47 @@ +package dev.alexzaw.fetchapi; + +import java.io.*; +import java.sql.SQLException; +import java.util.Properties; +import javax.servlet.http.HttpServletResponse; + +import dev.alexzaw.fetchapi.formatter.*; + +public class OutputFormatter { + private Properties configProps; + private ErrorFormatter errorFormatter; + + public OutputFormatter(Properties configProps) { + this.configProps = configProps; + this.errorFormatter = FormatterFactory.createErrorFormatter(); + } + + public void outputJson(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + ResponseFormatter formatter = FormatterFactory.getFormatter("json", configProps); + formatter.format(response, result); + } + + public void outputExcel(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + ResponseFormatter formatter = FormatterFactory.getFormatter("excel", configProps); + formatter.format(response, result); + } + + public void outputHtml(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + ResponseFormatter formatter = FormatterFactory.getFormatter("html", configProps); + formatter.format(response, result); + } + + public void outputCsv(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + ResponseFormatter formatter = FormatterFactory.getFormatter("csv", configProps); + formatter.format(response, result); + } + + public void outputXml(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + ResponseFormatter formatter = FormatterFactory.getFormatter("xml", configProps); + formatter.format(response, result); + } + + public void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + errorFormatter.sendErrorResponse(response, status, message); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/QueryResult.java b/src/main/java/dev/alexzaw/fetchapi/QueryResult.java new file mode 100644 index 0000000..7b1cbe8 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/QueryResult.java @@ -0,0 +1,62 @@ +package dev.alexzaw.fetchapi; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; + +public class QueryResult implements AutoCloseable { + + public final ResultSet resultSet; + public final String[] headers; + public final ResultSetMetaData metadata; + public final Connection connection; + public final PreparedStatement statement; + + public QueryResult( + Connection conn, + PreparedStatement stmt, + ResultSet rs, + String[] headers, + ResultSetMetaData metadata + ) { + this.connection = conn; + this.statement = stmt; + this.resultSet = rs; + this.headers = headers; + this.metadata = metadata; + } + + public ResultSet getResultSet() { + return resultSet; + } + + public String[] getHeaders() { + return headers; + } + + public ResultSetMetaData getMetadata() { + return metadata; + } + + public Connection getConnection() { + return connection; + } + + public PreparedStatement getStatement() { + return statement; + } + + @Override + public void close() throws Exception { + try { + if (resultSet != null) resultSet.close(); + } finally { + try { + if (statement != null) statement.close(); + } finally { + if (connection != null) connection.close(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/StyleManager.java b/src/main/java/dev/alexzaw/fetchapi/StyleManager.java new file mode 100644 index 0000000..8dbead6 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/StyleManager.java @@ -0,0 +1,127 @@ +package dev.alexzaw.fetchapi; + +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.poi.ss.usermodel.*; + +public class StyleManager { + private final Map styleCache = new ConcurrentHashMap<>(); + private Properties configProps; + + public StyleManager(Properties configProps) { + this.configProps = configProps; + } + + public CellStyle createHeaderStyle(Workbook workbook) { + CellStyle headerStyle = workbook.createCellStyle(); + headerStyle.setFillForegroundColor(IndexedColors.ROYAL_BLUE.getIndex()); + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + headerStyle.setWrapText(true); + + Font headerFont = workbook.createFont(); + headerFont.setColor(IndexedColors.WHITE.getIndex()); + headerFont.setBold(true); + headerStyle.setFont(headerFont); + + return headerStyle; + } + + public CellStyle getCellStyle(String styleType, Workbook workbook) { + return styleCache.computeIfAbsent(styleType, type -> createCellStyle(type, workbook)); + } + + private CellStyle createCellStyle(String styleType, Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + + // Set alignment + String alignmentProp = configProps.getProperty("style." + styleType + ".alignment", "LEFT"); + HorizontalAlignment alignment = HorizontalAlignment.valueOf(alignmentProp); + style.setAlignment(alignment); + + // Set format if exists + String format = configProps.getProperty("style." + styleType + ".format"); + if (format != null) { + DataFormat dataFormat = workbook.createDataFormat(); + style.setDataFormat(dataFormat.getFormat(format)); + } + + return style; + } + + public void setColumnStyles(Sheet sheet, ResultSetMetaData metadata) throws SQLException { + for (int i = 1; i <= metadata.getColumnCount(); i++) { + int sqlType = metadata.getColumnType(i); + int scale = metadata.getScale(i); + + String styleType = determineStyleType(sqlType, scale); + CellStyle style = getCellStyle(styleType, sheet.getWorkbook()); + sheet.setDefaultColumnStyle(i - 1, style); + } + } + + public String determineStyleType(int sqlType, int scale) throws SQLException { + switch (sqlType) { + case Types.CHAR: + case Types.VARCHAR: + case Types.LONGVARCHAR: + return "text"; + + case Types.DECIMAL: + case Types.NUMERIC: + return scale > 0 ? "number" : "integer"; + + case Types.DOUBLE: + case Types.FLOAT: + case Types.REAL: + return "number"; // Always decimal for floating point types + + case Types.BIGINT: + case Types.INTEGER: + case Types.SMALLINT: + case Types.TINYINT: + return "integer"; + + case Types.DATE: + return "date"; + + case Types.TIMESTAMP: + return "timestamp"; + + default: + return "text"; + } + } + + public void setOptimalColumnWidths(Sheet sheet, String[] headers, ResultSetMetaData metadata) throws SQLException { + for (int i = 0; i < headers.length; i++) { + int columnWidth = metadata.getColumnDisplaySize(i + 1); + int excelWidth = getOptimalColumnLength(headers[i], columnWidth); + sheet.setColumnWidth(i, excelWidth); + } + } + + private int getOptimalColumnLength(String header, int dataLength) { + final int DEFAULT_MAX = Integer.parseInt( + configProps.getProperty("excel.column.max.width", "40") + ); + final int DEFAULT_MIN = Integer.parseInt( + configProps.getProperty("excel.column.min.width", "10") + ); + int maxLength = 0; + + for (String word : header.split("\\s+")) { + maxLength = Math.max(maxLength, word.length()); + } + maxLength = Math.max(maxLength, dataLength); + maxLength = Math.max(DEFAULT_MIN, Math.min(maxLength, DEFAULT_MAX)); + return maxLength * 256; + } + + public void clearCache() { + styleCache.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/Utils.java b/src/main/java/dev/alexzaw/fetchapi/Utils.java new file mode 100644 index 0000000..f6f8677 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/Utils.java @@ -0,0 +1,83 @@ +package dev.alexzaw.fetchapi; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.servlet.http.HttpServletResponse; + +public class Utils { + + public static String toTitleCase(String input) { + if (input == null || input.isEmpty()) return input; + + // 1) lower-case everything + String normalized = input.toLowerCase(); + + // 2) replace whitespace, '+' , '_' , '-' with a single space + normalized = normalized.replaceAll("[\\s+_-]", " "); + + // 3) split on one-or-more spaces (collapsing runs) + String[] parts = normalized.trim().split("\\s+"); + + // 4) capitalize first char of each fragment; 5) join with single spaces + StringBuilder out = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + String w = parts[i]; + if (!w.isEmpty()) { + out.append(Character.toUpperCase(w.charAt(0))); + if (w.length() > 1) out.append(w.substring(1)); // already lower-case + if (i < parts.length - 1) out.append(' '); + } + } + // 6) trim (already trimmed by construction) + return out.toString(); + } + + + public static String rightTrim(String s) { + int i = s.length() - 1; + while (i >= 0 && Character.isWhitespace(s.charAt(i))) { + i--; + } + return s.substring(0, i + 1); + } + + public static String decodeBase64(String encodedString) { + return new String( + Base64.getDecoder().decode(encodedString), + StandardCharsets.UTF_8 + ); + } + + public static String escapeHtml(String input) { + if (input == null) return ""; + return input + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static String escapeJson(String str) { + if (str == null) return ""; + return str + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + + public static void addCorsHeaders(HttpServletResponse resp) { + resp.setHeader("Access-Control-Allow-Origin", "*"); + resp.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + resp.setHeader("Access-Control-Allow-Headers", "Content-Type"); + resp.setHeader("Access-Control-Max-Age", "3600"); + resp.setHeader("Connection", "close"); + resp.setHeader("Keep-Alive", "timeout=0, max=0"); + resp.setHeader("Pragma", "no-cache"); + resp.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + + // Add a unique timestamp to prevent caching + resp.setHeader("X-Timestamp", String.valueOf(System.currentTimeMillis())); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/CsvFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/CsvFormatter.java new file mode 100644 index 0000000..efb6e4a --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/CsvFormatter.java @@ -0,0 +1,88 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletResponse; + +import dev.alexzaw.fetchapi.QueryResult; + +public class CsvFormatter implements ResponseFormatter { + private static final Logger logger = Logger.getLogger(CsvFormatter.class.getName()); + private Properties configProps; + private static final char DEFAULT_DELIMITER = ','; + private static final char DEFAULT_QUOTE = '"'; + + public CsvFormatter(Properties configProps) { + this.configProps = configProps; + } + + @Override + public void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("text/csv"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=data.csv"); + response.setBufferSize(8192); + + char delimiter = getDelimiter(); + + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), 8192)) { + + // Write header row + writeRow(writer, result.headers, delimiter); + + // Write data rows + while (result.resultSet.next()) { + String[] rowData = new String[result.metadata.getColumnCount()]; + for (int i = 1; i <= result.metadata.getColumnCount(); i++) { + String value = result.resultSet.getString(i); + rowData[i-1] = value != null ? value.trim() : ""; + } + writeRow(writer, rowData, delimiter); + + // Flush periodically + if (result.resultSet.getRow() % 1000 == 0) { + writer.flush(); + } + } + } + } + + private char getDelimiter() { + String delimiterStr = configProps.getProperty("csv.delimiter", ","); + return delimiterStr.isEmpty() ? DEFAULT_DELIMITER : delimiterStr.charAt(0); + } + + private void writeRow(BufferedWriter writer, String[] values, char delimiter) throws IOException { + for (int i = 0; i < values.length; i++) { + if (i > 0) { + writer.write(delimiter); + } + writer.write(escapeField(values[i], delimiter)); + } + writer.write("\r\n"); + } + + private String escapeField(String field, char delimiter) { + if (field == null) { + return ""; + } + + // If the field contains delimiter, quotes, or newlines, it needs to be quoted + if (field.indexOf(delimiter) >= 0 || field.indexOf(DEFAULT_QUOTE) >= 0 || + field.indexOf('\n') >= 0 || field.indexOf('\r') >= 0) { + + // Double up any quotes in the field + field = field.replace(String.valueOf(DEFAULT_QUOTE), + String.valueOf(DEFAULT_QUOTE) + String.valueOf(DEFAULT_QUOTE)); + + // Wrap the field in quotes + return DEFAULT_QUOTE + field + DEFAULT_QUOTE; + } + + return field; + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/ErrorFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/ErrorFormatter.java new file mode 100644 index 0000000..13915c3 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/ErrorFormatter.java @@ -0,0 +1,27 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.http.HttpServletResponse; + +import static dev.alexzaw.fetchapi.Utils.*; + +public class ErrorFormatter { + + public void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + String json = String.format( + "{ \"error\": { \"status\": %d, \"message\": \"%s\" } }", + status, + escapeJson(message) + ); + + try (PrintWriter out = response.getWriter()) { + out.print(json); + } + } + +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/ExcelFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/ExcelFormatter.java new file mode 100644 index 0000000..4ffa22d --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/ExcelFormatter.java @@ -0,0 +1,136 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.*; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; + +import dev.alexzaw.fetchapi.QueryResult; +import dev.alexzaw.fetchapi.StyleManager; + +public class ExcelFormatter implements ResponseFormatter { + private static final Logger logger = Logger.getLogger(ExcelFormatter.class.getName()); + private Properties configProps; + private StyleManager styleManager; + + public ExcelFormatter(Properties configProps) { + this.configProps = configProps; + this.styleManager = new StyleManager(configProps); + } + + @Override + public void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + logger.info("Starting Excel generation..."); + + // Don't set content type and headers until we're ready to write + response.reset(); + response.setBufferSize(32768); // Increase buffer size + + try (SXSSFWorkbook workbook = generateExcelWorkbook(result)) { + // Now that we have all data, set response headers + response.setContentType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + response.setHeader( + "Content-Disposition", + "attachment; filename=ExcelExport.xlsx" + ); + + // Write to response with larger buffer + try ( + BufferedOutputStream out = new BufferedOutputStream( + response.getOutputStream(), + 32768 + ) + ) { + workbook.write(out); + out.flush(); + response.flushBuffer(); + } + + // Clear the style cache and dispose workbook + styleManager.clearCache(); + workbook.dispose(); + logger.info("Excel file generated successfully"); + + } catch (Exception e) { + logger.severe("Error generating Excel: " + e.getMessage()); + // If error occurs before headers are sent, we can still send error response + if (!response.isCommitted()) { + response.reset(); + response.setContentType("text/plain"); + response + .getWriter() + .println("Error generating Excel: " + e.getMessage()); + } + throw e; // Rethrow to be handled by caller + } + } + + private SXSSFWorkbook generateExcelWorkbook(QueryResult result) throws SQLException, IOException { + SXSSFWorkbook workbook = new SXSSFWorkbook(100); + workbook.setCompressTempFiles(true); + Sheet sheet = workbook.createSheet("Data"); + + CellStyle headerStyle = styleManager.createHeaderStyle(workbook); + createHeaderRow(sheet, result.headers, headerStyle); + + styleManager.setColumnStyles(sheet, result.metadata); + styleManager.setOptimalColumnWidths(sheet, result.headers, result.metadata); + + int rowNum = 1; + int batchSize = Integer.parseInt(configProps.getProperty("excel.batch.size", "1000")); + int currentBatch = 0; + + while (result.resultSet.next()) { + Row row = sheet.createRow(rowNum++); + populateDataRow(row, result.resultSet, result.metadata); + + currentBatch++; + if (currentBatch >= batchSize) { + ((SXSSFSheet) sheet).flushRows(batchSize); + currentBatch = 0; + } + } + + return workbook; + } + + private void createHeaderRow(Sheet sheet, String[] headers, CellStyle headerStyle) { + Row headerRow = sheet.createRow(0); + for (int i = 0; i < headers.length; i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(headers[i]); + cell.setCellStyle(headerStyle); + } + } + + private void populateDataRow(Row row, ResultSet rs, ResultSetMetaData metadata) throws SQLException { + for (int i = 1; i <= metadata.getColumnCount(); i++) { + Cell cell = row.createCell(i-1); + Object value = rs.getObject(i); + + if (value == null) { + continue; + } + + if (value instanceof Number) { + cell.setCellValue(((Number) value).doubleValue()); + } else if (value instanceof java.sql.Date) { + cell.setCellValue((java.sql.Date) value); + } else if (value instanceof java.sql.Timestamp) { + cell.setCellValue((java.sql.Timestamp) value); + } else if (value instanceof Boolean) { + cell.setCellValue((Boolean) value); + } else { + cell.setCellValue(value instanceof String ? ((String)value).trim() : value.toString()); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/FormatterFactory.java b/src/main/java/dev/alexzaw/fetchapi/formatter/FormatterFactory.java new file mode 100644 index 0000000..7eec832 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/FormatterFactory.java @@ -0,0 +1,25 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.util.Properties; + +public class FormatterFactory { + + public static ResponseFormatter getFormatter(String format, Properties configProps) { + if ("excel".equalsIgnoreCase(format)) { + return new ExcelFormatter(configProps); + } else if ("html".equalsIgnoreCase(format)) { + return new HtmlFormatter(configProps); + } else if ("csv".equalsIgnoreCase(format)) { + return new CsvFormatter(configProps); + } else if ("xml".equalsIgnoreCase(format)) { + return new XmlFormatter(configProps); + } else { + // Default to JSON + return new JsonFormatter(configProps); + } + } + + public static ErrorFormatter createErrorFormatter() { + return new ErrorFormatter(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/HtmlFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/HtmlFormatter.java new file mode 100644 index 0000000..d21ab61 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/HtmlFormatter.java @@ -0,0 +1,178 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.*; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletResponse; + +import dev.alexzaw.fetchapi.QueryResult; +import dev.alexzaw.fetchapi.Utils; + +public class HtmlFormatter implements ResponseFormatter { + private static final Logger logger = Logger.getLogger(HtmlFormatter.class.getName()); + private Properties configProps; + + public HtmlFormatter(Properties configProps) { + this.configProps = configProps; + } + + @Override + public void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("text/html"); + response.setCharacterEncoding("UTF-8"); + + try (PrintWriter out = response.getWriter()) { + // Start HTML document + out.println(""); + out.println(""); + out.println(""); + out.println(" "); + out.println(" "); + out.println(" Query Results"); + writeHtmlStyles(out); + out.println(""); + out.println(""); + + // Container that mimics the design in the screenshot + out.println("
"); + out.println("
"); + out.println(" "); + + // Write table headers and body + writeHtmlHeaders(out, result.headers); + writeHtmlBody(out, result); + + out.println("
"); + out.println("
"); + out.println("
"); + out.println(""); + out.println(""); + } + } + + private void writeHtmlStyles(PrintWriter out) { + out.println(" "); + } + + private void writeHtmlHeaders(PrintWriter out, String[] headers) { + out.println(" "); + out.println(" "); + for (String header : headers) { + out.println(" " + Utils.escapeHtml(header) + ""); + } + out.println(" "); + out.println(" "); + out.println(" "); + } + + private void writeHtmlBody(PrintWriter out, QueryResult result) throws SQLException { + int batchSize = Integer.parseInt( + configProps.getProperty("html.batch.size", "1000") + ); + int currentBatch = 0; + int rowCount = 0; + + while (result.resultSet.next()) { + // Alternate row styling for better readability with many columns + String rowClass = (rowCount % 2 == 0) ? "even-row" : "odd-row"; + out.println(" "); + + for (int i = 1; i <= result.metadata.getColumnCount(); i++) { + String value = result.resultSet.getString(i); + String escapedValue = Utils.escapeHtml(value != null ? value : ""); + out.println(" " + escapedValue + ""); + } + + out.println(" "); + rowCount++; + + if (++currentBatch >= batchSize) { + out.flush(); + currentBatch = 0; + } + } + + out.println(" "); + } +} diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/JsonFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/JsonFormatter.java new file mode 100644 index 0000000..ed90730 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/JsonFormatter.java @@ -0,0 +1,61 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletResponse; +import org.json.JSONObject; + +import dev.alexzaw.fetchapi.QueryResult; +import dev.alexzaw.fetchapi.Utils; + +public class JsonFormatter implements ResponseFormatter { + private static final Logger logger = Logger.getLogger(JsonFormatter.class.getName()); + private Properties configProps; + + public JsonFormatter(Properties configProps) { + this.configProps = configProps; + } + + @Override + public void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + // Increase buffer size for better performance + response.setBufferSize(8192); + + try (BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream(), 8192); + OutputStreamWriter osw = new OutputStreamWriter(bos, StandardCharsets.UTF_8)) { + + osw.write("["); + boolean first = true; + + while (result.resultSet.next()) { + if (!first) { + osw.write(","); + } + first = false; + + JSONObject obj = new JSONObject(); + for (int i = 1; i <= result.metadata.getColumnCount(); i++) { + String columnName = result.metadata.getColumnName(i); + String value = result.resultSet.getString(i); + obj.put(columnName, value != null ? Utils.rightTrim(value) : JSONObject.NULL); + } + + osw.write(obj.toString()); + + // Flush periodically to avoid holding too much in memory + if (result.resultSet.getRow() % 1000 == 0) { + osw.flush(); + } + } + + osw.write("]"); + osw.flush(); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/ResponseFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/ResponseFormatter.java new file mode 100644 index 0000000..f54b5b9 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/ResponseFormatter.java @@ -0,0 +1,11 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.IOException; +import java.sql.SQLException; +import javax.servlet.http.HttpServletResponse; + +import dev.alexzaw.fetchapi.QueryResult; + +public interface ResponseFormatter { + void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException; +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/fetchapi/formatter/XmlFormatter.java b/src/main/java/dev/alexzaw/fetchapi/formatter/XmlFormatter.java new file mode 100644 index 0000000..04ec091 --- /dev/null +++ b/src/main/java/dev/alexzaw/fetchapi/formatter/XmlFormatter.java @@ -0,0 +1,90 @@ +package dev.alexzaw.fetchapi.formatter; + +import java.io.*; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; +import javax.servlet.http.HttpServletResponse; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +import dev.alexzaw.fetchapi.QueryResult; + +public class XmlFormatter implements ResponseFormatter { + private static final Logger logger = Logger.getLogger(XmlFormatter.class.getName()); + private Properties configProps; + + public XmlFormatter(Properties configProps) { + this.configProps = configProps; + } + + @Override + public void format(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("application/xml"); + response.setCharacterEncoding("UTF-8"); + response.setBufferSize(8192); + + try { + // Create XML stream writer + XMLOutputFactory factory = XMLOutputFactory.newInstance(); + XMLStreamWriter writer = factory.createXMLStreamWriter( + response.getOutputStream(), "UTF-8"); + + // Start document + writer.writeStartDocument("UTF-8", "1.0"); + writer.writeStartElement("data"); + + // Write data rows + int rowCount = 0; + while (result.resultSet.next()) { + writer.writeStartElement("row"); + + for (int i = 1; i <= result.metadata.getColumnCount(); i++) { + String columnName = sanitizeXmlTag(result.metadata.getColumnName(i)); + String value = result.resultSet.getString(i); + + writer.writeStartElement(columnName); + if (value != null) { + writer.writeCharacters(value.trim()); + } + writer.writeEndElement(); // column + } + + writer.writeEndElement(); // row + + // Flush periodically + if (++rowCount % 1000 == 0) { + writer.flush(); + } + } + + // End document + writer.writeEndElement(); // data + writer.writeEndDocument(); + writer.flush(); + writer.close(); + + } catch (XMLStreamException e) { + logger.severe("Error generating XML: " + e.getMessage()); + throw new IOException("Error generating XML", e); + } + } + + private String sanitizeXmlTag(String tag) { + // XML element names must start with a letter or underscore and can contain + // letters, digits, hyphens, underscores, and periods + if (tag == null || tag.isEmpty()) { + return "_column"; + } + + // Ensure first character is valid + char firstChar = tag.charAt(0); + if (!Character.isLetter(firstChar) && firstChar != '_') { + tag = "_" + tag; + } + + // Replace invalid characters + return tag.replaceAll("[^a-zA-Z0-9_.-]", "_"); + } +} \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..df47d8b --- /dev/null +++ b/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,31 @@ + + + EncodingFilter + dev.alexzaw.fetchapi.CharacterEncodingFilter + + encoding + UTF-8 + + + forceEncoding + true + + + + EncodingFilter + /* + + + FetchAPI + dev.alexzaw.fetchapi.FetchAPIServlet + 1 + + + FetchAPI + / + +