From f46e155eb727f9549fa7d1f1163bfcf3715de946 Mon Sep 17 00:00:00 2001 From: Alex Zaw Date: Fri, 15 Aug 2025 22:17:59 -0700 Subject: [PATCH] upload current sources --- .gitignore | 3 + .vscode/settings.json | 3 + pom.xml | 223 + src/main/java/.DS_Store | Bin 0 -> 6148 bytes src/main/java/META-INF/MANIFEST.MF | 3 + .../rest4i/UnifiedAuthenticationService.java | 211 + .../java/dev/alexzaw/rest4i/api/APIImpl.java | 95 + .../dev/alexzaw/rest4i/api/AdminAPIImpl.java | 226 + .../alexzaw/rest4i/api/CLCommandAPIImpl.java | 280 + .../dev/alexzaw/rest4i/api/IFSAPIImpl.java | 495 ++ .../dev/alexzaw/rest4i/api/QSYSAPIImpl.java | 157 + .../dev/alexzaw/rest4i/api/SQLAPIImpl.java | 505 ++ .../alexzaw/rest4i/api/SecurityAPIImpl.java | 1050 ++++ .../alexzaw/rest4i/api/SessionAPIImpl.java | 465 ++ .../rest4i/api/jaxrs/AdminRESTService.java | 166 + .../api/jaxrs/CLCommandRESTService.java | 85 + .../rest4i/api/jaxrs/DatabaseRESTService.java | 193 + .../api/jaxrs/FileOperationsRESTService.java | 420 ++ .../rest4i/api/jaxrs/HealthRESTService.java | 38 + .../rest4i/api/jaxrs/IFSRESTService.java | 131 + .../api/jaxrs/PDFOperationsRESTService.java | 334 ++ .../rest4i/api/jaxrs/QSYSRESTService.java | 56 + .../alexzaw/rest4i/api/jaxrs/RESTService.java | 124 + .../api/jaxrs/RSEMessageBodyWriter.java | 68 + .../rest4i/api/jaxrs/RSEResourceConfig.java | 39 + .../rest4i/api/jaxrs/SQLRESTService.java | 57 + .../rest4i/api/jaxrs/SecurityRESTService.java | 323 ++ .../api/jaxrs/ServerInfoRESTService.java | 60 + .../rest4i/api/jaxrs/SessionRESTService.java | 125 + .../api/jaxrs/util/ResponseFormatter.java | 296 ++ .../config/EnhancedConfigurationManager.java | 540 ++ .../rest4i/database/DatabaseManager.java | 164 + .../alexzaw/rest4i/database/QueryResult.java | 205 + .../database/formatter/OutputFormatter.java | 350 ++ .../exception/RestWABadRequestException.java | 14 + .../exception/RestWAConflictException.java | 14 + .../rest4i/exception/RestWAException.java | 165 + .../exception/RestWAForbiddenException.java | 14 + .../RestWAInsufficientStorageException.java | 14 + .../RestWAInternalServerErrorException.java | 14 + .../exception/RestWANoContentException.java | 14 + .../exception/RestWANotFoundException.java | 14 + .../RestWAPreconditionFailedException.java | 14 + .../RestWAReqEntityTooLargeException.java | 14 + .../RestWARequestTimeoutException.java | 14 + .../RestWAServiceUnavailableException.java | 14 + .../RestWAUnauthorizedException.java | 14 + ...UnauthorizedMustAuthenticateException.java | 14 + .../rest4i/fileops/SMBFileManager.java | 290 ++ .../dev/alexzaw/rest4i/messages/Messages.java | 190 + .../dev/alexzaw/rest4i/model/ApiResponse.java | 305 ++ .../dev/alexzaw/rest4i/model/ErrorInfo.java | 401 ++ .../alexzaw/rest4i/model/PaginationInfo.java | 469 ++ .../dev/alexzaw/rest4i/pdf/PDFManager.java | 350 ++ .../pool/AS400ConnectionPoolService.java | 466 ++ .../dev/alexzaw/rest4i/session/Session.java | 527 ++ .../rest4i/session/SessionRepository.java | 127 + .../dev/alexzaw/rest4i/util/AsyncLogger.java | 134 + .../util/CertificateStoreProcessor.java | 1597 ++++++ .../dev/alexzaw/rest4i/util/ChecksumUtil.java | 63 + .../alexzaw/rest4i/util/CommonConstants.java | 142 + .../dev/alexzaw/rest4i/util/CommonUtil.java | 456 ++ .../alexzaw/rest4i/util/GlobalProperties.java | 403 ++ .../alexzaw/rest4i/util/JSONSerializer.java | 175 + .../dev/alexzaw/rest4i/util/JsonUtils.java | 443 ++ .../alexzaw/rest4i/util/ResponseHelper.java | 405 ++ .../dev/alexzaw/rest4i/util/SystemObject.java | 475 ++ .../rest4i/util/SystemObjectFactory.java | 13 + .../alexzaw/rest4i/util/SystemObjectQSYS.java | 403 ++ .../dev/alexzaw/rest4i/util/UNIXErrno.java | 78 + .../alexzaw/rest4i/util/ValidationUtils.java | 497 ++ src/main/java/src-main-java-rest4i.zip | Bin 0 -> 157997 bytes src/main/webapp/META-INF/MANIFEST.MF | 3 + src/main/webapp/WEB-INF/.gitignore | 1 + src/main/webapp/WEB-INF/web.xml | 7 + src/main/webapp/assets/.DS_Store | Bin 0 -> 6148 bytes src/main/webapp/assets/favicon/favicon.ico | Bin 0 -> 5558 bytes src/main/webapp/assets/icons/api.svg | 1 + .../webapp/assets/icons/pdf--reference.svg | 14 + .../assets/images/header_banner_1200.png | Bin 0 -> 13755 bytes .../assets/images/header_banner_2560.png | Bin 0 -> 18625 bytes .../assets/images/header_banner_576.png | Bin 0 -> 15913 bytes .../assets/images/header_banner_768.png | Bin 0 -> 15398 bytes .../assets/images/header_banner_922.png | Bin 0 -> 12300 bytes src/main/webapp/assets/images/ibm_logo.png | Bin 0 -> 31920 bytes src/main/webapp/index.html | 40 + src/main/webapp/scalar.json | 4574 +++++++++++++++++ src/main/webapp/styles.css | 224 + 88 files changed, 21110 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 pom.xml create mode 100644 src/main/java/.DS_Store create mode 100644 src/main/java/META-INF/MANIFEST.MF create mode 100644 src/main/java/dev/alexzaw/rest4i/UnifiedAuthenticationService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/APIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/AdminAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/CLCommandAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/IFSAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/QSYSAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/SQLAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/SecurityAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/SessionAPIImpl.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/AdminRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/CLCommandRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/DatabaseRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/FileOperationsRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/HealthRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/IFSRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/PDFOperationsRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/QSYSRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/RESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEMessageBodyWriter.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEResourceConfig.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/SQLRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/SecurityRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/ServerInfoRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/SessionRESTService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/api/jaxrs/util/ResponseFormatter.java create mode 100644 src/main/java/dev/alexzaw/rest4i/config/EnhancedConfigurationManager.java create mode 100644 src/main/java/dev/alexzaw/rest4i/database/DatabaseManager.java create mode 100644 src/main/java/dev/alexzaw/rest4i/database/QueryResult.java create mode 100644 src/main/java/dev/alexzaw/rest4i/database/formatter/OutputFormatter.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWABadRequestException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAConflictException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAForbiddenException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAInsufficientStorageException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAInternalServerErrorException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWANoContentException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWANotFoundException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAPreconditionFailedException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAReqEntityTooLargeException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWARequestTimeoutException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAServiceUnavailableException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedMustAuthenticateException.java create mode 100644 src/main/java/dev/alexzaw/rest4i/fileops/SMBFileManager.java create mode 100644 src/main/java/dev/alexzaw/rest4i/messages/Messages.java create mode 100644 src/main/java/dev/alexzaw/rest4i/model/ApiResponse.java create mode 100644 src/main/java/dev/alexzaw/rest4i/model/ErrorInfo.java create mode 100644 src/main/java/dev/alexzaw/rest4i/model/PaginationInfo.java create mode 100644 src/main/java/dev/alexzaw/rest4i/pdf/PDFManager.java create mode 100644 src/main/java/dev/alexzaw/rest4i/pool/AS400ConnectionPoolService.java create mode 100644 src/main/java/dev/alexzaw/rest4i/session/Session.java create mode 100644 src/main/java/dev/alexzaw/rest4i/session/SessionRepository.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/AsyncLogger.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/CertificateStoreProcessor.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/ChecksumUtil.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/CommonConstants.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/CommonUtil.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/GlobalProperties.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/JSONSerializer.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/JsonUtils.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/ResponseHelper.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/SystemObject.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/SystemObjectFactory.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/SystemObjectQSYS.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/UNIXErrno.java create mode 100644 src/main/java/dev/alexzaw/rest4i/util/ValidationUtils.java create mode 100644 src/main/java/src-main-java-rest4i.zip create mode 100644 src/main/webapp/META-INF/MANIFEST.MF create mode 100644 src/main/webapp/WEB-INF/.gitignore create mode 100644 src/main/webapp/WEB-INF/web.xml create mode 100644 src/main/webapp/assets/.DS_Store create mode 100644 src/main/webapp/assets/favicon/favicon.ico create mode 100644 src/main/webapp/assets/icons/api.svg create mode 100644 src/main/webapp/assets/icons/pdf--reference.svg create mode 100644 src/main/webapp/assets/images/header_banner_1200.png create mode 100644 src/main/webapp/assets/images/header_banner_2560.png create mode 100644 src/main/webapp/assets/images/header_banner_576.png create mode 100644 src/main/webapp/assets/images/header_banner_768.png create mode 100644 src/main/webapp/assets/images/header_banner_922.png create mode 100644 src/main/webapp/assets/images/ibm_logo.png create mode 100644 src/main/webapp/index.html create mode 100644 src/main/webapp/scalar.json create mode 100644 src/main/webapp/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7922172 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/src/main/webapp/WEB-INF/classes +/lib +/target \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e0f15db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a9cbb16 --- /dev/null +++ b/pom.xml @@ -0,0 +1,223 @@ + + + 4.0.0 + + dev.alexzaw.rest4i + rest4i + 1.0.0 + war + + + 1.8 + 1.8 + UTF-8 + + + 4.0.1 + 2.1.1 + 1.3.2 + 2.0.1.Final + 1.1.4 + 2.3.1 + 2.3.1 + 1.1.2 + + + 2.13.3 + 11.2 + 2.1.7 + 2.0.24 + 1.0.10 + 5.2.4 + 1.16.1 + + + + + + javax.servlet + javax.servlet-api + ${javax.servlet.version} + provided + + + javax.ws.rs + javax.ws.rs-api + ${javax.ws.rs.version} + provided + + + org.eclipse.microprofile.openapi + microprofile-openapi-api + ${mp.openapi.api.version} + provided + + + javax.annotation + javax.annotation-api + ${javax.annotation.version} + provided + + + javax.validation + validation-api + ${javax.validation.version} + provided + + + javax.json + javax.json-api + ${javax.json.version} + provided + + + javax.xml.bind + jaxb-api + ${javax.jaxb.version} + provided + + + javax.xml.ws + jaxws-api + ${javax.jaxws.version} + provided + + + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + + net.sf.jt400 + jt400 + ${jt400.version} + + + com.zaxxer + HikariCP + 4.0.3 + + + eu.agno3.jcifs + jcifs-ng + ${jcifs-ng.version} + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} + + + org.apache.pdfbox + pdfbox-tools + ${pdfbox.version} + + + com.openhtmltopdf + openhtmltopdf-pdfbox + ${openhtmltopdf.version} + + + org.apache.poi + poi + ${poi.version} + + + org.apache.poi + poi-ooxml + ${poi.version} + + + org.jsoup + jsoup + ${jsoup.version} + + + + + junit + junit + 4.13.2 + test + + + + + rest4i + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.source} + ${maven.compiler.target} + ${project.build.sourceEncoding} + + -Xlint:unchecked + -Xlint:deprecation + + + + + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + false + src/main/webapp + + + src/main/webapp + false + + **/* + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + ${project.build.sourceEncoding} + + json + html + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + true + + + + + + diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..35a54bf0595027f28217af82c3d32981e121b5e7 GIT binary patch literal 6148 zcmeH~Jr2S!425mVP>H1@V-^m;4I%_5-~tF3K^+i#j?VMXLSaS~dY0@jc51bKLsN^0 z?w;4J$RHv;+$b9h6I0}!Tx68{^>MkK$MI$*w?)zl@IfZ~xlK?3DnJFO02QDDGg2TA z@_sd=XX2w!0V*&L1?>A!;KrJ4LH~3h_y_tMAK>5S|49o|DnJGPOaWc) zkNX{7D$mxB*R%R5tF~@%&@V@L`w2i|NAVi&hW%m-uqIm&6&Qa6Tm}Xz@KXg|*oF~o literal 0 HcmV?d00001 diff --git a/src/main/java/META-INF/MANIFEST.MF b/src/main/java/META-INF/MANIFEST.MF new file mode 100644 index 0000000..8309907 --- /dev/null +++ b/src/main/java/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Created-By: 17.0.15 (Ubuntu) + diff --git a/src/main/java/dev/alexzaw/rest4i/UnifiedAuthenticationService.java b/src/main/java/dev/alexzaw/rest4i/UnifiedAuthenticationService.java new file mode 100644 index 0000000..42e64f1 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/UnifiedAuthenticationService.java @@ -0,0 +1,211 @@ +package dev.alexzaw.rest4i; + +import dev.alexzaw.rest4i.api.SessionAPIImpl; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.GlobalProperties; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Base64; +import java.util.Properties; + +/** + * Unified Authentication Service that integrates the existing RSE session-based + * authentication with API token authentication for the new integration APIs. + * + * This service provides backward compatibility while supporting additional + * authentication methods for database, file operations, and PDF services. + * + * @author alexzaw + */ +public class UnifiedAuthenticationService { + + // This code is not IBM's intellectual property + + private static final String API_TOKEN_HEADER = "X-API-Token"; + private static final String API_TOKEN_PARAM = "apiToken"; + + private Properties apiTokens; + + /** + * Constructor that initializes the authentication service. + * In a production environment, API tokens would be loaded from a secure configuration. + */ + public UnifiedAuthenticationService() { + this.apiTokens = new Properties(); + // In production, load from secure configuration + // For now, this would be configured through system properties or external config + } + + /** + * Constructor that allows injection of API tokens configuration. + * + * @param apiTokens Properties containing valid API tokens + */ + public UnifiedAuthenticationService(Properties apiTokens) { + this.apiTokens = apiTokens != null ? apiTokens : new Properties(); + } + + /** + * Authenticates a request using the existing RSE session authentication + * or API token authentication. + * + * @param req The HTTP request + * @param authorization The authorization header value + * @param allowBasicAuth Whether to allow basic authentication + * @return Authenticated session or null if API token authentication is used + * @throws RestWAUnauthorizedException if authentication fails + */ + public Session authenticateRequest(HttpServletRequest req, String authorization, boolean allowBasicAuth) { + // Check for secure connection if required + checkSecureConnection(req); + + // First try API token authentication + if (authenticateWithApiToken(req)) { + // API token authentication successful, return null to indicate + // that session-based authentication is not needed + return null; + } + + // Fall back to existing RSE session authentication + return authenticateWithSession(req, authorization, allowBasicAuth); + } + + /** + * Authenticates using API token from header or parameter. + * + * @param req The HTTP request + * @return true if API token authentication is successful, false otherwise + */ + private boolean authenticateWithApiToken(HttpServletRequest req) { + // Check header first + String apiToken = req.getHeader(API_TOKEN_HEADER); + + // If not in header, check parameter + if (apiToken == null) { + apiToken = req.getParameter(API_TOKEN_PARAM); + } + + if (apiToken == null || apiToken.trim().isEmpty()) { + return false; + } + + // Validate API token + return validateApiToken(apiToken.trim()); + } + + /** + * Validates an API token against the configured tokens. + * + * @param token The API token to validate + * @return true if the token is valid, false otherwise + */ + private boolean validateApiToken(String token) { + if (apiTokens == null || apiTokens.isEmpty()) { + return false; + } + + // Check if the token exists in the configuration + // In a production environment, tokens should be hashed + return apiTokens.containsValue(token) || apiTokens.containsKey(token); + } + + /** + * Authenticates using the existing RSE session mechanism. + * This is the same logic as in RESTService.getSession(). + * + * @param req The HTTP request + * @param authorization The authorization header value + * @param allowBasicAuth Whether to allow basic authentication + * @return Authenticated session + * @throws RestWAUnauthorizedException if authentication fails + */ + private Session authenticateWithSession(HttpServletRequest req, String authorization, boolean allowBasicAuth) { + String token = null; + SessionAPIImpl.RSEAPI_LoginCredentials creds = null; + Session session = null; + + if (authorization != null) { + if (authorization.startsWith("Bearer")) { + token = authorization.replaceFirst("Bearer ", "").trim(); + session = SessionAPIImpl.getSession(token); + } else if (allowBasicAuth && authorization.startsWith("Basic")) { + String encodedUserPassword = authorization.replaceFirst("Basic ", "").trim(); + String usernameAndPassword = null; + + try { + byte[] decodedBytes = Base64.getDecoder().decode(encodedUserPassword); + usernameAndPassword = new String(decodedBytes, "UTF-8"); + } catch (Exception e) { + AsyncLogger.traceDebug("authenticateWithSession", "Base64 decoder error: " + e.getMessage(), new Object[0]); + } + + int separatorIndex; + if (usernameAndPassword != null && (separatorIndex = usernameAndPassword.indexOf(':')) != -1) { + creds = new SessionAPIImpl.RSEAPI_LoginCredentials(); + creds.userid = usernameAndPassword.substring(0, separatorIndex); + creds.password = usernameAndPassword.substring(separatorIndex + 1); + session = SessionAPIImpl.sessionLogin(creds); + } + } + } + + if (session == null) { + throw new RestWAUnauthorizedException("User ID or password is not set or not valid.", new Object[0]); + } + + session.lock(); + try { + session.setLastUsed(); + if (creds != null) { + session.setSingleUse(true); + } + } finally { + session.unlock(); + } + + return session; + } + + /** + * Checks if a secure connection is required and enforces it. + * + * @param req The HTTP request + * @throws dev.alexzaw.rest4i.exception.RestWAForbiddenException if secure connection is required but not present + */ + private void checkSecureConnection(HttpServletRequest req) { + if (GlobalProperties.useSecureConnections() && !req.isSecure()) { + throw new dev.alexzaw.rest4i.exception.RestWAForbiddenException("Resource must be accessed with a secure connection.", new Object[0]); + } + } + + /** + * Releases a session if it was created during authentication. + * + * @param session The session to release, can be null + */ + public static void releaseSession(Session session) { + if (session != null) { + if (session.getSingleUse()) { + SessionAPIImpl.sessionLogout(session); + } + session.unlock(); + } + } + + /** + * Determines if the current request is authenticated via API token. + * + * @param req The HTTP request + * @return true if authenticated via API token, false if via session + */ + public boolean isApiTokenAuthenticated(HttpServletRequest req) { + String apiToken = req.getHeader(API_TOKEN_HEADER); + if (apiToken == null) { + apiToken = req.getParameter(API_TOKEN_PARAM); + } + return apiToken != null && validateApiToken(apiToken.trim()); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/api/APIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/APIImpl.java new file mode 100644 index 0000000..6607bc0 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/APIImpl.java @@ -0,0 +1,95 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.ConnectionDroppedException; +import com.ibm.as400.access.ExtendedIOException; +import com.ibm.as400.access.ExtendedIllegalArgumentException; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAConflictException; +import dev.alexzaw.rest4i.exception.RestWAForbiddenException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedMustAuthenticateException; +import dev.alexzaw.rest4i.util.AsyncLogger; +import javax.ws.rs.WebApplicationException; + +public abstract class APIImpl { + + public static void handleException(String classSignature, Exception e, boolean isAuthenticated) { + if (e instanceof WebApplicationException) + throw (WebApplicationException)e; + if (e instanceof com.ibm.as400.access.ObjectDoesNotExistException) + throw new RestWANotFoundException(e.getMessage(), new Object[0]); + if (e instanceof com.ibm.as400.access.AS400SecurityException) { + if (isAuthenticated) + throw new RestWAForbiddenException(e.getMessage(), new Object[0]); + throw new RestWAUnauthorizedMustAuthenticateException("User ID or password is not set or not valid.", new Object[0]); + } + if (e instanceof com.ibm.as400.access.ObjectAlreadyExistsException) + throw new RestWAConflictException(e.getMessage(), new Object[0]); + if (e instanceof javax.net.ssl.SSLException) + throw new RestWAForbiddenException("Unable to connect to server over a secure communication channel. %s", new Object[] { e.getMessage() }); + if (e instanceof java.net.ConnectException) + throw new RestWABadRequestException("Unable to connect to server. %s", new Object[] { e.getMessage() }); + if (e instanceof java.net.UnknownHostException) + throw new RestWABadRequestException("The host '%s' is not known.", new Object[] { e.getMessage() }); + if (e instanceof ConnectionDroppedException) { + int rc = ((ConnectionDroppedException)e).getReturnCode(); + switch (rc) { + case 2: + throw new RestWAInternalServerErrorException("The connection to server dropped unexpectedly. Retry operation.", new Object[0]); + } + } else { + if (e instanceof ExtendedIllegalArgumentException) { + int rc = ((ExtendedIllegalArgumentException)e).getReturnCode(); + switch (rc) { + case 1: + throw new RestWABadRequestException("The length of a parameter value is not valid.", new Object[0]); + } + throw new RestWABadRequestException("A parameter value is not valid.", new Object[0]); + } + if (e instanceof ExtendedIOException) { + int rc = ((ExtendedIOException)e).getReturnCode(); + switch (rc) { + case 24: + throw new RestWAUnauthorizedException(e.getMessage(), new Object[0]); + case 5: + case 13: + throw new RestWAForbiddenException(e.getMessage(), new Object[0]); + case 4: + throw new RestWAConflictException(e.getMessage(), new Object[0]); + case 2: + case 3: + case 7: + throw new RestWANotFoundException(e.getMessage(), new Object[0]); + case 23: + throw new RestWABadRequestException(e.getMessage(), new Object[0]); + } + } else if (e instanceof com.ibm.as400.access.AS400Exception) { + String errorMsg = e.getMessage(); + if (errorMsg != null) { + if (errorMsg.indexOf("CPF5812") != -1) + throw new RestWAConflictException(e.getMessage(), new Object[0]); + if (errorMsg.indexOf("CPF9810") != -1 || + errorMsg.indexOf("CPF9801") != -1) + throw new RestWANotFoundException(e.getMessage(), new Object[0]); + if (errorMsg.indexOf("CPF9802") != -1) + throw new RestWAUnauthorizedException(e.getMessage(), new Object[0]); + if (errorMsg.indexOf("CPF3C31") != -1 || + errorMsg.indexOf("CPE3014") != -1) + throw new RestWABadRequestException(e.getMessage(), new Object[0]); + if (errorMsg.indexOf("CPF9820") != -1) + throw new RestWAForbiddenException(e.getMessage(), new Object[0]); + } + } + } + AsyncLogger.traceException(classSignature, e, null, new Object[0]); + throw new RestWAInternalServerErrorException(e.getMessage(), new Object[0]); + } + + public static void handleException(String classSignature, Exception e) { + handleException(classSignature, e, true); + } + + public static abstract class RSEResult {} +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/AdminAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/AdminAPIImpl.java new file mode 100644 index 0000000..eff62ba --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/AdminAPIImpl.java @@ -0,0 +1,226 @@ +package dev.alexzaw.rest4i.api; + +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAForbiddenException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.session.SessionRepository; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class AdminAPIImpl extends APIImpl { + + public static String adminGetSettings(Session session) { + String output = null; + try { + checkIsAdminUser(session); + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("adminUsers", GlobalProperties.getAdminUsers()); + json.add("includeUsers", GlobalProperties.getIncludeUsers()); + json.add("excludeUsers", GlobalProperties.getExcludeUsers()); + json.add("maxFileSize", Long.valueOf(GlobalProperties.getMaxFileSize())); + json.add("maxSessionInactivity", Long.valueOf(GlobalProperties.getSessionMaxInactivity())); + json.add("maxSessionLifetime", Long.valueOf(GlobalProperties.getSessionMaxLifetime())); + json.add("maxSessionUseCount", Long.valueOf(GlobalProperties.getSessionMaxUseCount())); + json.add("maxSessionWaitTime", Long.valueOf(GlobalProperties.getSessionMaxWaitTime())); + json.add("maxSessions", Long.valueOf(GlobalProperties.getMaxSessions())); + json.add("maxSessionsPerUser", Long.valueOf(GlobalProperties.getMaxSessionsPerUser())); + json.add("sessionCleanupInterval", Long.valueOf(GlobalProperties.getSessionCleanupInterval())); + json.endObject(); + output = json.toString(); + } catch (Exception e) { + handleException("getRSEAPISettings", e); + } + return output; + } + + public static void adminSetSettings(Session session, RSEAPI_Settings rseapiSettings) { + try { + checkIsAdminUser(session); + if (rseapiSettings == null) + return; + if (rseapiSettings.persist && (!GlobalProperties.isIBMi() || !session.isLocalhost())) + throw new RestWABadRequestException("The combination of values specified is not supported.", new Object[0]); + if (rseapiSettings.maxFileSize != null) + CommonUtil.isValid("maxFileSize", rseapiSettings.maxFileSize.longValue(), + 0L, 15360000L, true); + if (rseapiSettings.maxSessions != null) + CommonUtil.isValid("maxSessions", rseapiSettings.maxSessions.longValue(), + 0L, -1L, true); + if (rseapiSettings.maxSessionsPerUser != null) + CommonUtil.isValid("maxSessionsPerUser", rseapiSettings.maxSessionsPerUser.longValue(), + 1L, -1L, true); + if (rseapiSettings.maxSessionWaitTime != null) + CommonUtil.isValid("maxWaitTime", rseapiSettings.maxSessionWaitTime.longValue(), + 0L, -1L, true); + if (rseapiSettings.maxSessionInactivity != null) + CommonUtil.isValid("maxSessionIdleTime", rseapiSettings.maxSessionInactivity.longValue(), + 30L, 7200L, true); + if (rseapiSettings.maxSessionLifetime != null) + CommonUtil.isValid("maxSessionLifetime", rseapiSettings.maxSessionLifetime.longValue(), + 30L, -1L, true); + if (rseapiSettings.maxSessionUseCount != null) + CommonUtil.isValid("maxSessionUseCount", rseapiSettings.maxSessionUseCount.longValue(), + 1L, -1L, true); + if (rseapiSettings.sessionCleanupInterval != null) + CommonUtil.isValid("sessionCleanupInterval", rseapiSettings.sessionCleanupInterval.longValue(), + 30L, 900L, true); + if (rseapiSettings.adminUsers != null) + GlobalProperties.setAdminUserids(rseapiSettings.adminUsers); + if (rseapiSettings.includeUsers != null) + GlobalProperties.setIncludeUserids(rseapiSettings.includeUsers); + if (rseapiSettings.excludeUsers != null) + GlobalProperties.setExcludeUserids(rseapiSettings.excludeUsers); + if (rseapiSettings.maxFileSize != null) + GlobalProperties.setMaxFileSize(rseapiSettings.maxFileSize.longValue()); + if (rseapiSettings.maxSessions != null) + GlobalProperties.setMaxSessions(rseapiSettings.maxSessions.longValue()); + if (rseapiSettings.maxSessionsPerUser != null) + GlobalProperties.setMaxSessionsPerUser(rseapiSettings.maxSessionsPerUser.longValue()); + if (rseapiSettings.maxSessionWaitTime != null) + GlobalProperties.setSessionMaxWaitTime(rseapiSettings.maxSessionWaitTime.longValue()); + if (rseapiSettings.maxSessionInactivity != null) + GlobalProperties.setSessionMaxInactivity(rseapiSettings.maxSessionInactivity.longValue()); + if (rseapiSettings.maxSessionLifetime != null) + GlobalProperties.setSessionMaxLifetime(rseapiSettings.maxSessionLifetime.longValue()); + if (rseapiSettings.maxSessionUseCount != null) + GlobalProperties.setSessionMaxUseCount(rseapiSettings.maxSessionUseCount.longValue()); + if (rseapiSettings.sessionCleanupInterval != null) + GlobalProperties.setSessionCleanupInterval(rseapiSettings.sessionCleanupInterval.longValue()); + if (rseapiSettings.persist) + GlobalProperties.save(); + } catch (Exception e) { + handleException("setRSEAPISettings", e); + } + } + + public static String adminGetSessions(Session session) { + String output = null; + try { + checkIsAdminUser(session); + Map sessionStatistics = SessionRepository.getSessionsStatisticsData(); + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("totalSessions", Long.valueOf(SessionRepository.getTotalSessions())); + json.startArray("sessions"); + for (String u : sessionStatistics.keySet()) { + json.startObject(); + json.add("userid", u); + json.add("sessionCount", Long.valueOf(((AtomicLong)sessionStatistics.get(u)).longValue())); + json.endObject(); + } + json.endArray(); + json.endObject(); + output = json.toString(); + } catch (Exception e) { + handleException("getSessions", e); + } + return output; + } + + public static void adminClearSessions(Session session, String user) { + try { + checkIsAdminUser(session); + if (user != null && user.length() > 10) + throw new RestWABadRequestException("The length of a parameter value is not valid.", new Object[0]); + SessionRepository.clearSessions(user); + } catch (Exception e) { + handleException("clearSessions", e); + } + } + + public static String adminGetMemoryUsage(Session session) { + String output = null; + try { + checkIsAdminUser(session); + Runtime rt = Runtime.getRuntime(); + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("jvmFreeMemory", Long.valueOf(rt.freeMemory())); + json.add("jvmMaxMemory", Long.valueOf(rt.maxMemory())); + json.add("jvmTotalMemory", Long.valueOf(rt.totalMemory())); + json.endObject(); + output = json.toString(); + } catch (Exception e) { + handleException("getMemoryUsage", e); + } + return output; + } + + public static String adminGetEnvironment(Session session) { + String output = null; + try { + checkIsAdminUser(session); + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("rseapiBasepath", GlobalProperties.getRSEAPIBasePath()); + json.add("rseapiHostname", GlobalProperties.getHostName()); + json.add("rseapiPort", Integer.valueOf(CommonUtil.getServerPort())); + json.add("rseapiVersion", GlobalProperties.getRSEAPIVersion()); + json.add("osName", GlobalProperties.getOSName()); + json.add("osVersion", GlobalProperties.getOSVersion()); + json.add("javaVersion", GlobalProperties.getJavaVersion()); + json.endObject(); + output = json.toString(); + } catch (Exception e) { + handleException("getEnvironment", e); + } + return output; + } + + private static void checkIsAdminUser(Session session) throws Exception { + if (GlobalProperties.isIBMi() && !session.getHost().equalsIgnoreCase("localhost")) + throw new RestWAForbiddenException("Host connection is not set to localhost.", new Object[0]); + if (!GlobalProperties.isAdminUserid(session.getUserid()) && !CommonUtil.hasAllObjectAuthority(session.getAS400())) + throw new RestWAForbiddenException("User not an administrator.", new Object[0]); + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"persist", "adminUsers", "includeUsers", "excludeUsers", "maxFileSize", "maxSessions", "maxSessionsPerUser", "maxSessionInactivity", "maxSessionLifetime", "maxSessionUseCount", "maxSessionWaitTime", "sessionCleanupInterval"}) + @Schema(required = true, name = "RSEAPI_Settings", description = "Global settings for RSE API.") + public static class RSEAPI_Settings { + @Schema(required = false, defaultValue = "false", description = "Save settings to property file in persistent storage (hard disk).") + public boolean persist; + + @Schema(required = false, description = "User IDs that will be designated as an RSE API administrator.") + public List adminUsers; + + @Schema(required = false, description = "User IDs allowed to use RSE API.") + public List includeUsers; + + @Schema(required = false, description = "User IDs not allowed to use RSE API.") + public List excludeUsers; + + @Schema(required = false, defaultValue = "3072000", minimum = "0", maximum = "15360000", description = "Maximum size of IFS file data that can be processed (reading or writing) by RSE API.") + public Long maxFileSize; + + @Schema(required = false, defaultValue = "100", minimum = "-1", description = "Maximum number of total sessions. The value of -1 indicates there is no limit.") + public Long maxSessions; + + @Schema(required = false, defaultValue = "20", description = "Maximum number of sessions allowed on a per-user basis. The value of -1 indicates there is no limit. A value other than -1 must be greater than zero.") + public Long maxSessionsPerUser; + + @Schema(required = false, defaultValue = "7200", minimum = "30", maximum = "7200", description = "Maximum amount of inactive time, in seconds, before an available sesson is invalidated.") + public Long maxSessionInactivity; + + @Schema(required = false, defaultValue = "-1", description = "Maximum life, in seconds, for a session. The value of -1 indicates there is no limit. A value other than -1 must be greater or equal to 30.") + public Long maxSessionLifetime; + + @Schema(required = false, defaultValue = "-1", description = "Maximum number of times a session can be used before it is invalidated. The value of -1 indicates there is no limit. A value other than -1 must be greater than zero.") + public Long maxSessionUseCount; + + @Schema(required = false, defaultValue = "300", minimum = "-1", description = "Maximum time, in seconds, to wait on a session to become available. The value of -1 indicates there is no limit.") + public Long maxSessionWaitTime; + + @Schema(required = false, defaultValue = "300", minimum = "30", maximum = "900", description = "The time interval, in seconds, for how often the session maintenance daemon is run.") + public Long sessionCleanupInterval; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/CLCommandAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/CLCommandAPIImpl.java new file mode 100644 index 0000000..6380636 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/CLCommandAPIImpl.java @@ -0,0 +1,280 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Bin4; +import com.ibm.as400.access.AS400Exception; +import com.ibm.as400.access.AS400Message; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.CommandCall; +import com.ibm.as400.access.ProgramCall; +import com.ibm.as400.access.ProgramParameter; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.util.ArrayList; +import java.util.List; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class CLCommandAPIImpl extends APIImpl { + + private static final AS400Bin4 intConverter = new AS400Bin4(); + + public static CLCommandOperationResult clRunCLCommand(Session session, RSEAPI_CLCommands clCommands) { + if (clCommands == null || !CommonUtil.hasContent(clCommands.clCommands)) + throw new RestWABadRequestException("Invalid command. At least one CL command must be specified.", new Object[0]); + CLCommandOperationResult output = new CLCommandOperationResult(clCommands.includeMessageOnSuccess, clCommands.includeMessageHelpText); + for (String cmd : clCommands.clCommands) { + if (CommonUtil.detectReflectedXSS(cmd)) + throw new RestWABadRequestException("Reflected cross site scripting vulnerabilities detected in input data.", new Object[0]); + } + AS400 sys = null; + try { + sys = session.getAS400(); + CommandCall cmdCall = new CommandCall(sys); + cmdCall.setThreadSafe(false); + for (String cmd : clCommands.clCommands) { + if (!CommonUtil.hasContent(cmd)) + continue; + output.totalIssued_++; + if (!cmdCall.run(cmd)) { + output.totalFailures_++; + output.add(cmd, false, cmdCall.getMessageList()); + if (!clCommands.continueOnError) + break; + continue; + } + output.totalSuccesses_++; + if (clCommands.includeMessageOnSuccess) + output.add(cmd, true, cmdCall.getMessageList()); + } + } catch (Exception e) { + handleException("runCLCommand", e); + } + return output; + } + + public static String clGetCommandDefinition(Session session, String commandName, String library, boolean ignoreCase) { + AsyncLogger.traceDebug("getCommandDefinition", "in-parms: commandName='%s', library='%s'", new Object[] { commandName, library }); + commandName = (commandName == null) ? "" : commandName.trim(); + if (!CommonUtil.hasContent(commandName) || commandName.length() > 10) + throw new RestWABadRequestException("CL command not specified or not valid.", new Object[0]); + library = (library == null) ? "" : library.trim(); + if (!CommonUtil.hasContent(library)) + library = "*LIBL"; + if (library.length() > 10) + throw new RestWABadRequestException("Library not specified or not valid.", new Object[0]); + if (ignoreCase) { + commandName = commandName.toUpperCase(); + library = library.toUpperCase(); + } + AS400 sys = null; + String output = null; + try { + sys = session.getAS400(); + output = doQCDRCMDD(sys, library, commandName); + } catch (Exception e) { + handleException("getCommandDefinition", e); + } + JSONSerializer ser = new JSONSerializer(); + ser.startObject().add("definition", output).endObject(); + return ser.toString(); + } + + private static String doQCDRCMDD(AS400 sys, String library, String clCommand) throws Exception { + String clDefinition = null; + int initialReceiverVariableSize = 32768; + ProgramCall pc = CommonUtil.getProgramCall(sys, "/QSYS.LIB/QCDRCMDD.PGM", 6, true); + CharConverter charConverter = new CharConverter(sys.getCcsid(), sys); + CharConverter charConverterUTF8 = new CharConverter(1208, sys); + ProgramParameter[] pgmParmList = pc.getParameterList(); + byte[] bytes = null; + String qualifiedCLCommand = String.format("%-10s%-10s", new Object[] { clCommand, library }); + bytes = charConverter.stringToByteArray(qualifiedCLCommand); + pgmParmList[0].setInputData(bytes); + bytes = intConverter.toBytes(initialReceiverVariableSize); + pgmParmList[1].setInputData(bytes); + bytes = charConverter.stringToByteArray("DEST0100"); + pgmParmList[2].setInputData(bytes); + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + bytes = charConverter.stringToByteArray("CMDD0100"); + pgmParmList[4].setInputData(bytes); + int bytesReturned = 0; + int bytesAvailable = 0; + int count = 0; + while (true) { + count++; + if (!pc.run()) { + AS400Message[] msgs = pc.getMessageList(); + StringBuilder sb = new StringBuilder(); + if (msgs != null) + for (int i = 0; i < msgs.length; i++) + sb.append(msgs[i].getID()).append(":").append(msgs[i].getText()).append(" "); + throw new AS400Exception(msgs); + } + byte[] outBuf = pgmParmList[3].getOutputData(); + bytesReturned = intConverter.toInt(outBuf, 0); + bytesAvailable = intConverter.toInt(outBuf, 4); + if (bytesReturned > 0) { + clDefinition = charConverterUTF8.byteArrayToString(outBuf, 8, bytesReturned); + break; + } + if (bytesAvailable > 0) + if (count < 2) { + bytes = intConverter.toBytes(bytesAvailable); + pgmParmList[1].setInputData(bytes); + pgmParmList[3].setOutputDataLength(bytesAvailable); + continue; + } + break; + } + if (clDefinition == null || clDefinition.trim().isEmpty()) + throw new RestWAInternalServerErrorException("Unable to process the request due to an internal error. %s", new Object[] { "Call to QCDRCMDD() did not return command definition." }); + return clDefinition; + } + + public static void runClCommand(AS400 sys, String cmd) throws Exception { + if (!CommonUtil.hasContent(cmd)) + return; + CommandCall cmdCall = new CommandCall(sys); + cmdCall.setThreadSafe(false); + if (!cmdCall.run(cmd)) { + AS400Message[] msgs = cmdCall.getMessageList(); + StringBuilder sb = new StringBuilder(); + if (msgs != null) + for (int i = 0; i < msgs.length; i++) + sb.append(msgs[i].getID()).append(":").append(msgs[i].getText()).append(" "); + throw new RestWABadRequestException("CL command failed. Command is [%s]. Error message(s): %s", new Object[] { cmd, sb.toString() }); + } + } + + public static List runClCommands(AS400 sys, List clCommands, boolean continueOnError) throws Exception { + List es = null; + StringBuilder errorMessages = null; + if (clCommands == null || clCommands.isEmpty()) + return es; + CommandCall cmdCall = new CommandCall(sys); + cmdCall.setThreadSafe(false); + for (String cmd : clCommands) { + if (!CommonUtil.hasContent(cmd)) + continue; + if (!cmdCall.run(cmd)) { + AS400Message[] msgs = cmdCall.getMessageList(); + if (errorMessages == null) + errorMessages = new StringBuilder(); + if (msgs != null) + for (int i = 0; i < msgs.length; i++) + errorMessages.append(msgs[i].getID()).append(":").append(msgs[i].getText()).append(" "); + if (errorMessages != null && errorMessages.length() > 0) { + if (continueOnError) { + if (es == null) + es = new ArrayList<>(); + es.add(new SessionAPIImpl.SetSessionSettingsResults.ErrorSource(cmd, errorMessages.toString())); + errorMessages.setLength(0); + continue; + } + throw new RestWABadRequestException("CL command failed. Command is [%s]. Error message(s): %s", new Object[] { cmd, errorMessages.toString() }); + } + } + } + return es; + } + + public static class CLCommandOperationResult { + ArrayList clCommandStatus = new ArrayList<>(); + + int totalIssued_ = 0; + + int totalSuccesses_ = 0; + + int totalFailures_ = 0; + + boolean includeHelpText_ = false; + + boolean includeMessageOnSuccess_ = false; + + public CLCommandOperationResult(boolean includeMessageOnSuccess, boolean includeHelpText) { + this.includeHelpText_ = includeHelpText; + this.includeMessageOnSuccess_ = includeMessageOnSuccess; + } + + public void add(String cmd, boolean success, AS400Message[] msgs) { + CLCommandAPIImpl.CLCommandStatus cs = new CLCommandAPIImpl.CLCommandStatus(); + this.clCommandStatus.add(cs); + cs.success_ = success; + cs.command_ = cmd; + if (msgs != null) { + cs.commandOutput_ = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < msgs.length; i++) { + sb.append(msgs[i].getID()).append(": ").append(msgs[i].getText()).append(" "); + if (this.includeHelpText_ && msgs[i].getHelp() != null) + sb.append(msgs[i].getHelp()); + cs.commandOutput_.add(sb.toString()); + sb.setLength(0); + } + } + } + + public String toJSON() { + if (this.totalFailures_ > 0 || this.includeMessageOnSuccess_) { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("totalIssued", Integer.valueOf(this.totalIssued_)); + json.add("totalSuccesses", Integer.valueOf(this.totalSuccesses_)); + json.add("totalFailures", Integer.valueOf(this.totalFailures_)); + json.startArray("commandOutputList"); + if (!this.clCommandStatus.isEmpty()) + for (CLCommandAPIImpl.CLCommandStatus cs : this.clCommandStatus) { + if (cs.success_ && !this.includeMessageOnSuccess_) + continue; + json.startObject(); + json.add("success", Boolean.valueOf(cs.success_)); + json.add("command", cs.command_); + json.add("output", cs.commandOutput_); + json.endObject(); + } + json.endArray(); + json.endObject(); + return json.toString(); + } + return null; + } + } + + private static class CLCommandStatus { + String command_; + + ArrayList commandOutput_; + + boolean success_; + + private CLCommandStatus() { + this.command_ = null; + this.commandOutput_ = null; + this.success_ = false; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"continueOnError", "includeMessageOnSuccess", "includeMessageHelpText", "clCommands"}) + @Schema(required = true, name = "RSEAPI_CLCommands", description = "List of CL commands to run.") + public static class RSEAPI_CLCommands { + @Schema(required = false, defaultValue = "false", description = "Continue processing CL commands if an error is encountered.") + public boolean continueOnError; + + @Schema(required = false, defaultValue = "false", description = "Return CL command messages on success.") + public boolean includeMessageOnSuccess; + + @Schema(required = false, defaultValue = "false", description = "Return message help text for CL command messages.") + public boolean includeMessageHelpText; + + @Schema(required = true, description = "CL command to run.") + public ArrayList clCommands; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/IFSAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/IFSAPIImpl.java new file mode 100644 index 0000000..3fdc7ef --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/IFSAPIImpl.java @@ -0,0 +1,495 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400File; +import com.ibm.as400.access.AS400FileRecordDescription; +import com.ibm.as400.access.ExtendedIOException; +import com.ibm.as400.access.IFSFile; +import com.ibm.as400.access.IFSFileInputStream; +import com.ibm.as400.access.IFSFileOutputStream; +import com.ibm.as400.access.IFSTextFileInputStream; +import com.ibm.as400.access.IFSTextFileOutputStream; +import com.ibm.as400.access.Record; +import com.ibm.as400.access.RecordFormat; +import com.ibm.as400.access.SequentialFile; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAConflictException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import dev.alexzaw.rest4i.exception.RestWAPreconditionFailedException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.ChecksumUtil; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JSONSerializer; +import dev.alexzaw.rest4i.util.SystemObject; +import dev.alexzaw.rest4i.util.SystemObjectFactory; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class IFSAPIImpl extends APIImpl { + + public static String ifsListDir(Session session, String path, String subtypeFilter, boolean includehidden) { + path = CommonUtil.normalizePath(path); + AS400 sys = null; + List list = null; + List objInfoList = null; + if (!CommonUtil.hasContent(subtypeFilter)) + subtypeFilter = null; + AsyncLogger.traceDebug("listDir", "in-parms: path='%s', subtype='%s', includehidden=%s", new Object[] { path, subtypeFilter, Boolean.toString(includehidden) }); + try { + String root = "/"; + String pattern = null; + String[] pathArray = null; + if (path.indexOf("*") != -1) { + long splatCount = path.chars().filter(num -> (num == 42)).count(); + boolean walkthrough = true; + if (splatCount == 1L) { + int index = path.lastIndexOf("/"); + if (path.indexOf("*", index) != -1) { + pattern = path.substring(index + 1); + root = (index == 0) ? "/" : path.substring(0, index); + walkthrough = false; + } + } + if (walkthrough) + pathArray = path.split("/"); + } else { + root = path; + } + sys = session.getAS400(); + SystemObject rootDirObject = SystemObjectFactory.getSystemObject(sys, root); + if (pathArray == null) { + list = rootDirObject.listDir(includehidden, pattern, subtypeFilter); + } else { + list = new ArrayList<>(); + walkthrough(rootDirObject, 0, pathArray, list, includehidden, subtypeFilter); + } + objInfoList = new ArrayList<>(); + for (SystemObject f : list) { + SystemObject.SystemObjectInfo fi = f.getInfo(false); + if (fi != null) + objInfoList.add(fi); + } + } catch (Exception e) { + handleException("listDir", e); + } + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.startArray("objects"); + if (!objInfoList.isEmpty()) + for (SystemObject.SystemObjectInfo f : objInfoList) + f.toJSON(json); + json.endArray(); + json.endObject(); + return json.toString(); + } + + public static void ifsDeleteFile(Session session, String path) { + AsyncLogger.traceDebug("ifsDeleteFile", "in-parms: path='%s'", new Object[] { path }); + AS400 sys = null; + boolean deletedFile = false; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + deletedFile = sysObj.delete(); + if (!deletedFile) { + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + throw new RestWABadRequestException("Unable to delete object specified by path '%s'.", new Object[] { sysObj.getPath() }); + } + } catch (Exception e) { + handleException("deleteFile", e); + } + } + + public static IFSFileOperationResult ifsGetFileContent(Session session, String path, boolean setEtag) { + SequentialFile sequentialFile = null; + AsyncLogger.traceDebug("getContents", "in-parms: path='%s', setEtag='%s'", new Object[] { path, Boolean.valueOf(setEtag) }); + AS400 sys = null; + IFSTextFileInputStream istream = null; + AS400File as400file = null; + StringBuilder sb = null; + IFSFileOperationResult fileOpResult = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + if (!sysObj.isFile()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + if (sysObj.getSize() > GlobalProperties.getMaxFileSize()) + throw new RestWABadRequestException("File '%s' has size %d which exceeds maximum allowed file size of %d.", new Object[] { sysObj.getPath(), Long.valueOf(sysObj.getSize()), Long.valueOf(GlobalProperties.getMaxFileSize()) }); + if (sysObj.isQSYSObject()) { + if (!sysObj.isQSYSPFMBR()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + sequentialFile = new SequentialFile(sys, path); + AS400FileRecordDescription fileRecDesc = new AS400FileRecordDescription(sys, sysObj.getPath()); + RecordFormat rf = fileRecDesc.retrieveRecordFormat()[0]; + sequentialFile.setRecordFormat(rf); + sequentialFile.open(0, 500, 3); + sb = new StringBuilder(); + Record record = null; + int dataField = rf.getNumberOfFields() - 1; + while ((record = sequentialFile.readNext()) != null) { + String data = (String)record.getField(dataField); + sb.append(CommonUtil.trimRight(data)).append("\n"); + } + } else { + int sizeToRead = 131072; + istream = new IFSTextFileInputStream(sys, sysObj.getIFSFile(), -3); + sb = new StringBuilder(); + String fileData; + while (!(fileData = istream.read(sizeToRead)).isEmpty()) + sb.append(fileData); + } + fileOpResult = new IFSFileOperationResult(); + fileOpResult.fileContentInternal_ = new FileContentInternal(); + fileOpResult.fileContentInternal_.ccsid = sysObj.getCCSID(); + fileOpResult.fileContentInternal_.content = sb.toString(); + if (setEtag) + fileOpResult.etagHeader_ = ChecksumUtil.getMessageDigest(fileOpResult.fileContentInternal_.content); + } catch (Exception e) { + handleException("getContents", e); + } finally { + try { + if (istream != null) + istream.close(); + } catch (Exception exception) {} + try { + if (sequentialFile != null) + sequentialFile.close(); + } catch (Exception exception) {} + } + return fileOpResult; + } + + public static void ifsCreateFile(Session session, String path, RSEAPI_NewIFSFileAttributes fileInfo) { + AsyncLogger.traceDebug("createFile", "in-parms: path='%s', NewIFSFileAttributes=[ccsid='%s', description='%s', permissions='%s', type='%s']", new Object[] { path, Integer.valueOf(fileInfo.ccsid), fileInfo.description, fileInfo.permissions, fileInfo.type }); + if (fileInfo == null || !CommonUtil.hasContent(fileInfo.type)) + throw new RestWABadRequestException("Path name and type are required.", new Object[0]); + String objectType = fileInfo.type.toLowerCase(); + if (!objectType.equals("file") && !objectType.equals("directory")) + throw new RestWABadRequestException("The value for parameter '%s' is not valid.", new Object[] { "type" }); + AS400 sys = null; + boolean createdFile = false; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + IFSFile file = sysObj.getIFSFile(); + if (objectType.equals("file")) { + if (sysObj.isQSYSPFMBR()) { + SequentialFile sf = new SequentialFile(sys, sysObj.getPath()); + sf.addPhysicalFileMember(sf.getMemberName(), fileInfo.description); + createdFile = true; + } else { + createdFile = file.createNewFile(); + file.setCCSID(fileInfo.ccsid); + } + } else { + createdFile = file.mkdirs(); + } + if (!createdFile) + throw new RestWAConflictException("Target path '%s' already exists or path is not valid.", new Object[] { sysObj.getPath() }); + } catch (Exception e) { + handleException("createFile", e); + } + } + + public static String ifsPutFileContent(Session session, String path, String matchEtag, String content) { + SequentialFile sequentialFile = null; + AsyncLogger.traceDebug("updateFile", "in-parms: path='%s', matchEtag='%s'", new Object[] { path, matchEtag }); + AS400 sys = null; + IFSFile file = null; + IFSTextFileOutputStream ostream = null; + IFSFileInputStream fileis = null; + int ccsid = 1208; + String newChecksum = null; + AS400File as400file = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + file = sysObj.getIFSFile(); + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + if (!sysObj.isFile()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + ccsid = sysObj.getCCSID(); + if (matchEtag != null) { + fileis = new IFSFileInputStream(file, -4); + String checksum = ChecksumUtil.getMessageDigest((InputStream)fileis); + fileis.close(); + if (!checksum.equals(matchEtag)) + throw new RestWAPreconditionFailedException("Update failed. File referenced by path '%s' does not match client version.", new Object[] { sysObj.getPath() }); + } + if (content == null) + content = ""; + if (sysObj.isQSYSObject()) { + if (!sysObj.isQSYSPFMBR()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + sequentialFile = new SequentialFile(sys, sysObj.getPath()); + AS400FileRecordDescription fileRecDesc = new AS400FileRecordDescription(sys, sysObj.getPath()); + RecordFormat rf = fileRecDesc.retrieveRecordFormat()[0]; + sequentialFile.setRecordFormat(rf); + String[] lines = content.split("\n"); + Record dataRecord = rf.getNewRecord(); + ArrayList clCommands = new ArrayList<>(); + clCommands.add(String.format("QSYS/CLRPFM FILE(%s/%s) MBR(%s)", new Object[] { sysObj.getQSYSPath().getLibraryName(), + sysObj.getQSYSPath().getObjectName(), + sysObj.getQSYSPath().getMemberName() })); + CLCommandAPIImpl.runClCommands(sys, clCommands, false); + int blockingLevel = Integer.min(lines.length, 500); + sequentialFile.open(1, blockingLevel, 3); + byte b; + int i; + String[] arrayOfString1; + for (i = (arrayOfString1 = lines).length, b = 0; b < i; ) { + String data = arrayOfString1[b]; + dataRecord.setField(2, data); + sequentialFile.write(dataRecord); + b++; + } + } else { + ostream = new IFSTextFileOutputStream(sys, file, -4, false, ccsid); + ostream.write(content); + } + newChecksum = (matchEtag == null) ? null : ChecksumUtil.getMessageDigest(content); + } catch (Exception e) { + handleException("updateFile", e); + } finally { + try { + if (fileis != null) + fileis.close(); + } catch (Exception exception) {} + try { + if (ostream != null) + ostream.close(); + } catch (Exception exception) {} + try { + if (sequentialFile != null) + sequentialFile.close(); + } catch (Exception exception) {} + } + return newChecksum; + } + + public static IFSFileOperationResult ifsGetFileRawContent(Session session, String path, boolean convert, boolean setEtag) { + if (convert) + return ifsGetFileContent(session, path, setEtag); + AsyncLogger.traceDebug("ifsGetFileRawContent", "in-parms: path='%s', convert='%s', setEtag='%s'", new Object[] { path, Boolean.valueOf(convert), Boolean.valueOf(setEtag) }); + AS400 sys = null; + IFSFile file = null; + IFSFileInputStream istream = null; + IFSFileOperationResult fileOpResult = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + file = sysObj.getIFSFile(); + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + if (!sysObj.isFile()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + if (convert) { + IFSTextFileInputStream iFSTextFileInputStream = new IFSTextFileInputStream(sys, file.getAbsolutePath(), -3); + } else { + istream = new IFSFileInputStream(sys, file.getAbsolutePath(), -3); + } + fileOpResult = new IFSFileOperationResult(); + fileOpResult.iStream_ = istream; + if (setEtag) + fileOpResult.etagHeader_ = ChecksumUtil.getMessageDigest((InputStream)istream); + } catch (Exception e) { + try { + if (istream != null) + istream.close(); + } catch (Exception exception) {} + handleException("getContentsBinary", e); + } + return fileOpResult; + } + + public static String ifsPutFileRawContent(Session session, String path, boolean convert, String matchEtag, byte[] fileData) { + AsyncLogger.traceDebug("updateFileRaw", "in-parms: path='%s', convert='%s', matchEtag='%s'", new Object[] { path, Boolean.valueOf(convert), matchEtag }); + AS400 sys = null; + IFSFile file = null; + IFSFileOutputStream ostream = null; + IFSFileInputStream fileis = null; + InputStream byteStream = null; + int ccsid = 1208; + String newChecksum = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + file = sysObj.getIFSFile(); + if (sysObj.exists()) { + if (!sysObj.isFile()) + throw new RestWABadRequestException("Operation on object referenced by the path '%s' is not supported.", new Object[] { sysObj.getPath() }); + ccsid = file.getCCSID(); + if (matchEtag != null) { + fileis = new IFSFileInputStream(file, -4); + String checksum = ChecksumUtil.getMessageDigest((InputStream)fileis); + fileis.close(); + if (!checksum.equals(matchEtag)) + throw new RestWAConflictException("Update failed. File referenced by path '%s' does not match client version.", new Object[] { sysObj.getPath() }); + } + } + ostream = new IFSFileOutputStream(sys, file.getAbsolutePath(), -4, false, ccsid); + ostream.write(fileData); + ostream.close(); + byteStream = new ByteArrayInputStream(fileData); + newChecksum = (matchEtag == null) ? null : ChecksumUtil.getMessageDigest(byteStream); + } catch (Exception e) { + handleException("updateFile", e); + } finally { + try { + if (fileis != null) + fileis.close(); + } catch (Exception exception) {} + try { + if (ostream != null) + ostream.close(); + } catch (Exception exception) {} + try { + if (byteStream != null) + byteStream.close(); + } catch (Exception exception) {} + } + return newChecksum; + } + + public static void ifsRenameFile(Session session, String path, String newName) { + AsyncLogger.traceDebug("renameFile", "in-parms: path='%s', newName='%s'", new Object[] { path, newName }); + AS400 sys = null; + IFSFile file = null; + IFSFile fileNew = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + file = sysObj.getIFSFile(); + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + SystemObject sysObjNew = SystemObjectFactory.getSystemObject(sys, newName); + fileNew = sysObjNew.getIFSFile(); + if (sysObjNew.exists()) + throw new RestWAConflictException("Target path '%s' already exists or path is not valid.", new Object[] { sysObjNew.getPath() }); + if (!file.renameTo(fileNew)) + throw new RestWAConflictException("Target path '%s' already exists or path is not valid.", new Object[] { sysObjNew.getPath() }); + } catch (Exception e) { + handleException("renameFile", e); + } + } + + public static void ifsCopyFile(Session session, String path, String destination) { + AsyncLogger.traceDebug("copyFile", "in-parms: path='%s', destination='%s'", new Object[] { path, destination }); + AS400 sys = null; + IFSFile file = null; + try { + sys = session.getAS400(); + SystemObject sysObj = SystemObjectFactory.getSystemObject(sys, path); + file = sysObj.getIFSFile(); + if (!sysObj.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { sysObj.getPath() }); + SystemObject sysObjNew = new SystemObject(sys, destination); + if (sysObjNew.exists()) + throw new RestWAConflictException("Target path '%s' already exists or path is not valid.", new Object[] { sysObjNew.getPath() }); + if (!file.copyTo(destination)) + throw new RestWAConflictException("Target path '%s' already exists or path is not valid.", new Object[] { sysObjNew.getPath() }); + } catch (Exception e) { + handleException("copyFile", e); + } + } + + public static String ifsGetFileInfo(Session session, String path) { + AsyncLogger.traceDebug("getFileInfo", "in-parms: path='%s'", new Object[] { path }); + AS400 sys = null; + SystemObject.SystemObjectInfo objInfo = null; + try { + sys = session.getAS400(); + SystemObject rootDirObject = SystemObjectFactory.getSystemObject(sys, path); + if (!rootDirObject.exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { rootDirObject.getPath() }); + objInfo = rootDirObject.getInfo(true); + } catch (Exception e) { + handleException("getFileInfo", e); + } + JSONSerializer json = new JSONSerializer(); + objInfo.toJSON(json); + return json.toString(); + } + + private static void walkthrough(SystemObject root, int paternIdx, String[] wildcards, List matchingPaths, boolean includeHidden, String subtypeFilter) throws Exception { + if (paternIdx == wildcards.length) + return; + String pattern = wildcards[paternIdx++]; + List objList = null; + try { + objList = root.listDir(includeHidden, pattern, subtypeFilter); + } catch (ExtendedIOException e) { + int rc = e.getReturnCode(); + if (rc == 5 || rc == 13) + return; + throw e; + } + if (objList.isEmpty()) + return; + if (paternIdx < wildcards.length) + for (SystemObject f : objList) { + if (f.isDirectory()) + walkthrough(f, paternIdx, wildcards, matchingPaths, includeHidden, subtypeFilter); + } + if (paternIdx == wildcards.length) + matchingPaths.addAll(objList); + } + + public static class IFSFileOperationResult extends APIImpl.RSEResult { + public boolean setEtag_ = false; + + public String etagHeader_ = null; + + public IFSFileInputStream iStream_ = null; + + public IFSAPIImpl.FileContentInternal fileContentInternal_ = null; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"content"}) + @Schema(required = true, name = "RSEAPI_FileContent", description = "The file content.") + public static class RSEAPI_FileContent { + @Schema(required = true) + public String content; + } + + public static class FileContentInternal { + public int ccsid; + + public String content; + + public String toJSON() { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("ccsid", Integer.valueOf(this.ccsid)); + json.add("content", this.content); + json.endObject(); + return json.toString(); + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"type", "permissions", "ccsid", "description"}) + @Schema(required = true, name = "RSEAPI_NewIFSFileAttributes", description = "File attributes for newly created file.") + public static class RSEAPI_NewIFSFileAttributes { + public String type; + + public String permissions; + + public int ccsid; + + public String description; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/QSYSAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/QSYSAPIImpl.java new file mode 100644 index 0000000..1a0539c --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/QSYSAPIImpl.java @@ -0,0 +1,157 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Exception; +import com.ibm.as400.access.ExtendedIOException; +import com.ibm.as400.access.ObjectDescription; +import com.ibm.as400.access.ObjectList; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.JSONSerializer; +import dev.alexzaw.rest4i.util.SystemObject; +import dev.alexzaw.rest4i.util.SystemObjectQSYS; +import java.util.ArrayList; +import java.util.List; + +public class QSYSAPIImpl extends APIImpl { + + public static QSYSOperationResult qsysGetQsysObjects(Session session, String objectName, String objectLibrary, String objectType, String objectSubtype, String memberName, String memberType) { + AS400 sys = null; + ObjectList objList = null; + List qsysObjList = new ArrayList<>(); + boolean subtypeSpecified = false; + try { + if (CommonUtil.hasContent(objectName)) { + objectName = objectName.trim(); + if (objectName.length() == 11 && objectName.endsWith("*")) + objectName = objectName.substring(0, objectName.length() - 1); + } + objectName = checkParam("objectName", objectName, 10, null); + objectLibrary = checkParam("objectLibrary", objectLibrary, 10, "*USRLIBL"); + objectType = checkParam("objectType", objectType, 10, "*ALL"); + objectSubtype = checkParam("objectSubtype", objectSubtype, 10, "*ALL"); + memberName = checkParam("memberName", memberName, 10, ""); + memberType = checkParam("memberType", memberType, 10, "*ALL"); + if (memberName.equalsIgnoreCase("*ALL")) + memberName = "*"; + subtypeSpecified = !objectSubtype.equalsIgnoreCase("*ALL"); + AsyncLogger.traceDebug("search", + "in-parms: objectName='%s', objectLibrary='%s', objectType='%s', objectSubtype='%s', memberName='%s', memberType='%s', ", new Object[] { objectName, objectLibrary, objectType, objectSubtype, memberName, memberType }); + if (objectLibrary.equalsIgnoreCase("*ALL") && objectType.equalsIgnoreCase("*ALL") && objectName.equalsIgnoreCase("*ALL") && !subtypeSpecified) + throw new RestWABadRequestException("The combination of values specified is not supported.", new Object[0]); + sys = session.getAS400(); + objList = new ObjectList(sys, objectLibrary, objectName, objectType); + ObjectDescription[] objects = objList.getObjects(-1, 0); + if (objects != null) { + SystemObjectQSYS qsysObject = null; + byte b; + int i; + ObjectDescription[] arrayOfObjectDescription; + for (i = (arrayOfObjectDescription = objects).length, b = 0; b < i; ) { + ObjectDescription objdesc = arrayOfObjectDescription[b]; + String path = objdesc.getPath(); + if (path.equalsIgnoreCase("/qsys.lib/qsys.lib")) + continue; + if (subtypeSpecified) { + try { + String subtype = objdesc.getValueAsString(202); + String requestedSubtype = objectSubtype.toUpperCase(); + if (subtype.equals("PF") && ( + requestedSubtype.startsWith("PF-") || + requestedSubtype.startsWith("PF38-") || + requestedSubtype.startsWith("PF"))) { + boolean reqsrcpf = !(!objectSubtype.equalsIgnoreCase("PF-SRC") && !objectSubtype.equalsIgnoreCase("PF38-SRC")); + boolean reqpf = requestedSubtype.equalsIgnoreCase("PF"); + qsysObject = new SystemObjectQSYS(sys, objdesc); + boolean isSrcPF = qsysObject.getQSYSPFSubtype().equalsIgnoreCase("PF-SRC"); + if (!reqpf && ((!isSrcPF && reqsrcpf) || (isSrcPF && !reqsrcpf))) + continue; + if (CommonUtil.hasContent(memberName)) { + List mbrs = qsysObject.listDir(true, memberName, memberType); + for (SystemObject f : mbrs) { + SystemObject.SystemObjectInfo systemObjectInfo = f.getInfo(false); + if (systemObjectInfo != null) + qsysObjList.add(systemObjectInfo); + } + continue; + } + } else { + if (!subtype.equalsIgnoreCase(objectSubtype)) + continue; + qsysObject = new SystemObjectQSYS(sys, objdesc); + } + } catch (ExtendedIOException e) { + int rc = e.getReturnCode(); + if (rc != 5 && rc != 13) + throw e; + continue; + } catch (AS400Exception e) { + String errorMsg = e.getMessage(); + if (errorMsg != null) + if (errorMsg.indexOf("CPF9802") != -1 || errorMsg.indexOf("CPF9822") != -1) + continue; + throw e; + } + } else { + qsysObject = new SystemObjectQSYS(sys, objdesc); + } + SystemObject.SystemObjectInfo fi = qsysObject.getInfo(false); + if (fi != null) + qsysObjList.add(fi); + continue; + } + } + } catch (Exception e) { + handleException("search", e); + } finally { + if (objList != null) + try { + objList.close(); + } catch (Exception exception) {} + } + QSYSOperationResult qsysOpResult = new QSYSOperationResult(); + qsysOpResult.objectList_ = new QSYSObjectList(); + qsysOpResult.objectList_.qsysObjects_ = qsysObjList; + return qsysOpResult; + } + + private static String checkParam(String nm, String objectValue, int maxLen, String defaultValue) { + if (!CommonUtil.hasContent(objectValue)) { + if (defaultValue == null) + throw new RestWABadRequestException("Object name not set.", new Object[0]); + objectValue = defaultValue; + } + objectValue = objectValue.trim(); + if (maxLen != -1 && !objectValue.startsWith("*") && objectValue.length() > maxLen) + throw new RestWABadRequestException("The length of the value for parameter '%s' is not valid.", new Object[] { nm }); + if (objectValue.equals("*")) + objectValue = "*ALL"; + return objectValue; + } + + public static class QSYSOperationResult { + public QSYSAPIImpl.QSYSObjectList objectList_ = null; + + public boolean setEtag_ = false; + + public String etagHeader_ = null; + } + + public static class QSYSObjectList { + public List qsysObjects_ = null; + + public String toJSON() { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.startArray("objects"); + if (!this.qsysObjects_.isEmpty()) + for (SystemObject.SystemObjectInfo f : this.qsysObjects_) + f.toJSON(json); + json.endArray(); + json.endObject(); + return json.toString(); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/SQLAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/SQLAPIImpl.java new file mode 100644 index 0000000..57d9f7a --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/SQLAPIImpl.java @@ -0,0 +1,505 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400JDBCConnection; +import com.ibm.as400.access.AS400JDBCResultSet; +import com.ibm.as400.access.AS400JDBCResultSetMetaData; +import com.ibm.as400.access.AS400JDBCStatement; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.sql.Date; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.Time; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class SQLAPIImpl extends APIImpl { + + public static SQLOperationResult runSQLStatement(Session session, RSEAPI_SQLRequest sqlRequest) { + if (sqlRequest == null || !CommonUtil.hasContent(sqlRequest.sqlStatement)) + throw new RestWABadRequestException("Invalid SQL statement. At least one SQL statement must be specified.", new Object[0]); + SQLOperationResult output = new SQLOperationResult(); + try { + boolean alwaysReturnSQLStateInformation = (sqlRequest.alwaysReturnSQLStateInformation != null) ? + sqlRequest.alwaysReturnSQLStateInformation.booleanValue() : false; + boolean treatWarningsAsErrors = (sqlRequest.treatWarningsAsErrors != null) ? + sqlRequest.treatWarningsAsErrors.booleanValue() : session.getSQLTreatWarningsAsErrors(); + RSESQLPayload payload = runSQLStatement(session, sqlRequest.sqlStatement, alwaysReturnSQLStateInformation, treatWarningsAsErrors); + output.add(sqlRequest.sqlStatement, payload); + } catch (Exception e) { + handleException("runSQLStatement", e); + } + return output; + } + + private static RSESQLPayload runSQLStatement(Session session, String stmt, boolean alwaysReturnSQLStateInformation, boolean treatWarningsAsErrors) throws Exception { + AS400JDBCConnection dbconn = null; + AS400JDBCStatement dbstmt = null; + RSESQLPayload sp = new RSESQLPayload(alwaysReturnSQLStateInformation, true, treatWarningsAsErrors); + try { + dbconn = session.getJDBC(); + SQLWarning sw = dbconn.getWarnings(); + sp.setWarning(sw); + if (sw == null || !treatWarningsAsErrors) { + dbstmt = (AS400JDBCStatement)dbconn.createStatement(); + sw = dbstmt.getWarnings(); + sp.setWarning(sw); + if (sw == null || !treatWarningsAsErrors) { + boolean resultSet = dbstmt.execute(stmt); + sw = dbstmt.getWarnings(); + sp.setWarning(sw); + if (sw == null || !treatWarningsAsErrors) + sp.generatePayload(dbstmt, resultSet); + } + } + } catch (SQLException _e) { + sp.setException(_e); + } finally { + try { + if (dbstmt != null) + dbstmt.close(); + } catch (Exception exception) {} + } + return sp; + } + + public static void sqlExceptionCheck(SQLException se, String stmt, boolean treatWarningsAsErrors) throws Exception { + if (se == null || (se instanceof SQLWarning && !treatWarningsAsErrors)) + return; + StringBuilder sb = new StringBuilder(); + sb.append(se.getMessage()); + throw new RestWAInternalServerErrorException("Session initialization failed. The failed SQL statement is [%s]. Error message(s): %s", new Object[] { stmt, sb.toString() }); + } + + public static List runSQLStatements(AS400JDBCConnection dbconn, List sqlStatements, boolean treatWarningsAsErrors, boolean continueOnError) throws Exception { + List es = null; + AS400JDBCStatement dbstmt = null; + String currentStmt = "*N"; + try { + sqlExceptionCheck(dbconn.getWarnings(), "*N", treatWarningsAsErrors); + dbconn.clearWarnings(); + if (sqlStatements == null || sqlStatements.isEmpty()) + return es; + dbstmt = (AS400JDBCStatement)dbconn.createStatement(); + sqlExceptionCheck(dbstmt.getWarnings(), "*N", treatWarningsAsErrors); + } catch (SQLException sqlex) { + sqlExceptionCheck(sqlex, currentStmt, treatWarningsAsErrors); + } finally { + try { + if (dbstmt != null) + dbstmt.close(); + } catch (Exception exception) {} + } + try { + if (dbstmt != null) + dbstmt.close(); + } catch (Exception exception) {} + return es; + } + + public static class SQLOperationResult extends APIImpl.RSEResult { + ArrayList sqlStatementResults_ = new ArrayList<>(); + + int totalIssued_ = 0; + + int totalSuccesses_ = 0; + + int totalFailures_ = 0; + + public boolean processResults() { + return ((SQLAPIImpl.RSESQLPayload)this.sqlStatementResults_.get(0)).processResults(); + } + + public void add(String cmd, SQLAPIImpl.RSESQLPayload payload) { + this.sqlStatementResults_.add(payload); + this.totalIssued_++; + if (payload.sqlOperationFailed()) { + this.totalFailures_++; + } else { + this.totalSuccesses_++; + } + } + + public String toJSON() { + Iterator iterator = this.sqlStatementResults_.iterator(); + if (iterator.hasNext()) { + SQLAPIImpl.RSESQLPayload payload = iterator.next(); + return payload.toJSON(); + } + return null; + } + } + + public static class RSESQLPayload { + public abstract class RSESQLResult { + protected SQLWarning _sqlWarnings = null; + + protected int[] _fieldTypes = null; + + protected String[] _fieldTypeNames = null; + + public abstract StringBuilder toJSON(StringBuilder param2StringBuilder); + + public abstract boolean isEmpty(); + } + + public class RSESQLResultSet extends RSESQLResult { + protected String[] _columnHeaders = null; + + protected ArrayList _rows = new ArrayList(); + + public StringBuilder toJSON(StringBuilder payload) { + String[] sanitizedColHdrs = SQLAPIImpl.RSESQLPayload.sanitizeColHdrsJSON(this._columnHeaders); + payload.append("[\n"); + int rowsProcessed = 0; + for (Object[] array : this._rows) { + if (rowsProcessed++ > 0) + payload.append(",\n"); + if (array == null || array.length == 0) { + payload.append("null"); + continue; + } + payload.append("{ "); + int fieldsProcessed = 0; + for (int i = 0; i < array.length; i++) { + if (fieldsProcessed++ > 0) + payload.append(", "); + payload.append("\"").append(sanitizedColHdrs[i]).append("\" : "); + if (array[i] == null) { + payload.append("null"); + } else if (SQLAPIImpl.RSESQLPayload.isNumeric(this._fieldTypes[i], this._fieldTypeNames[i])) { + payload.append(array[i]); + } else if (SQLAPIImpl.RSESQLPayload.isBinary(this._fieldTypes[i], this._fieldTypeNames[i])) { + payload.append("\"").append(CommonUtil.toBase64((byte[])array[i])).append("\""); + } else if (SQLAPIImpl.RSESQLPayload.isString(this._fieldTypes[i], this._fieldTypeNames[i])) { + payload.append("\"").append(JSONSerializer.escapeJSON((String)array[i])).append("\""); + } else if (this._fieldTypes[i] == 91) { + payload.append("\"").append(CommonUtil.toDate((Date)array[i])).append("\""); + } else if (this._fieldTypes[i] == 92) { + payload.append("\"").append(CommonUtil.toTime((Time)array[i])).append("\""); + } else if (this._fieldTypes[i] == 93) { + payload.append("\"").append(CommonUtil.toTimestamp((Timestamp)array[i])).append("\""); + } else { + payload.append("null"); + } + } + payload.append("}"); + } + payload.append("\n]"); + return payload; + } + + public boolean isEmpty() { + return !(this._rows != null && !this._rows.isEmpty()); + } + } + + private ArrayList _sqlWarnings = null; + + private SQLException _sqlException = null; + + private ArrayList _results = null; + + private int _resultSetCount = 0; + + private boolean _returnSQLStateAlways = false; + + private boolean _returnSQLStateErrors = false; + + private boolean _treatWarningAsError = false; + + private ArrayList _updateCount = new ArrayList<>(); + + public RSESQLPayload(boolean returnSQLStateAlways, boolean returnSQLStateErrors, boolean treatWarningAsError) { + this._returnSQLStateAlways = returnSQLStateAlways; + this._returnSQLStateErrors = returnSQLStateErrors; + this._treatWarningAsError = treatWarningAsError; + } + + public void setWarning(SQLWarning w) { + if (w == null) + return; + if (this._sqlWarnings == null) + this._sqlWarnings = new ArrayList<>(); + this._sqlWarnings.add(w); + } + + public void setException(SQLException e) { + this._sqlException = e; + } + + public boolean warningsExist(boolean checkResults) { + if (this._sqlWarnings != null && this._sqlWarnings.size() > 0) + return true; + if (checkResults && this._results != null && !this._results.isEmpty()) + for (RSESQLResult rs : this._results) { + if (rs._sqlWarnings != null) + return true; + } + return false; + } + + public void generatePayload(AS400JDBCStatement pstmt, boolean moreResults) throws SQLException { + if (sqlOperationFailed()) + return; + if (this._results == null) { + this._results = new ArrayList<>(); + } else { + this._results.clear(); + } + int count = 0; + do { + if (moreResults) { + AS400JDBCResultSet rs = (AS400JDBCResultSet)pstmt.getResultSet(); + this._updateCount.add(Integer.valueOf(-1)); + RSESQLResultSet sqlRS = new RSESQLResultSet(); + this._results.add(sqlRS); + this._resultSetCount++; + AS400JDBCResultSetMetaData rsMetadata = (AS400JDBCResultSetMetaData)rs.getMetaData(); + int colCount = rsMetadata.getColumnCount(); + sqlRS._columnHeaders = new String[colCount]; + sqlRS._fieldTypes = new int[colCount]; + sqlRS._fieldTypeNames = new String[colCount]; + for (int i = 1; i <= colCount; i++) { + sqlRS._columnHeaders[i - 1] = rsMetadata.getColumnName(i).trim(); + sqlRS._fieldTypes[i - 1] = rsMetadata.getColumnType(i); + sqlRS._fieldTypeNames[i - 1] = rsMetadata.getColumnTypeName(i); + } + sqlRS._sqlWarnings = rs.getWarnings(); + while (rs.next()) { + Object[] rows = new Object[colCount]; + sqlRS._rows.add(rows); + for (int j = 1; j <= colCount; j++) { + Object fldObj = null; + if (isString(sqlRS._fieldTypes[j - 1], sqlRS._fieldTypeNames[j - 1]) || + isNumeric(sqlRS._fieldTypes[j - 1], sqlRS._fieldTypeNames[j - 1])) { + fldObj = normalizeString(rs.getString(j)); + } else if (isDateTime(sqlRS._fieldTypes[j - 1], sqlRS._fieldTypeNames[j - 1])) { + fldObj = rs.getObject(j); + } else if (isBinary(sqlRS._fieldTypes[j - 1], sqlRS._fieldTypeNames[j - 1])) { + fldObj = rs.getBytes(j); + } + rows[j - 1] = fldObj; + } + } + moreResults = pstmt.getMoreResults(); + } else { + count = pstmt.getUpdateCount(); + moreResults = pstmt.getMoreResults(); + if (count != -1 || moreResults) + this._updateCount.add(Integer.valueOf(count)); + } + } while (moreResults || count != -1); + } + + public static boolean isDateTime(int t, String coltype) { + return !(t != 91 && + t != 92 && + t != 93); + } + + public static boolean isBinary(int t, String coltype) { + return !(t != -2 && + t != -7 && + t != 2004 && + t != -4 && + t != -3); + } + + public static boolean isString(int t, String coltype) { + return !(t != 16 && + t != 1 && + t != 2005 && + t != 70 && + t != -16 && + t != -1 && + t != -15 && + t != 2011 && + t != -9 && + t != 12); + } + + public static boolean isNumeric(int t, String coltype) { + return !(t != -5 && + t != 3 && + t != 8 && + t != 6 && + t != 4 && + t != 2 && + t != 7 && + t != 5 && + t != -6 && ( + t != 1111 || !coltype.equalsIgnoreCase("DECFLOAT"))); + } + + public boolean returnSQLStateInResponse() { + return !((!sqlOperationFailed() || !this._returnSQLStateErrors) && !this._returnSQLStateAlways); + } + + private boolean processResults() { + return !(!returnSQLStateInResponse() && (this._results == null || this._results.size() <= 0)); + } + + private boolean updateCountsExist() { + if (this._updateCount != null) + for (Integer uc : this._updateCount) { + if (uc.intValue() != -1) + return true; + } + return false; + } + + public String toJSON() { + if (!processResults()) + return ""; + StringBuilder payload = new StringBuilder("{\n"); + if (returnSQLStateInResponse()) + if (updateCountsExist() || this._sqlException != null || warningsExist(false)) { + payload.append("\"SQLStateInfo\": {\n"); + payload.append("\"rowsAffectedCounts\": "); + if (!updateCountsExist()) { + payload.append("null"); + } else { + payload.append("\"").append(this._updateCount).append("\""); + } + payload.append(",\n"); + if (this._sqlException == null) { + payload.append("\"SQLError\": null,\n"); + } else { + payload.append("\"SQLError\": {\n"); + payload.append("\"SQLState\": \"").append(this._sqlException.getSQLState()).append("\",\n"); + payload.append("\"SQLCode\": ").append(this._sqlException.getErrorCode()).append(",\n"); + payload.append("\"message\": \"").append(JSONSerializer.escapeJSON(normalizeErrorMessage(this._sqlException, false))).append("\"\n"); + payload.append("},\n"); + } + if (!warningsExist(false)) { + payload.append("\"SQLWarnings\": null"); + } else { + payload.append("\"SQLWarnings\": [\n"); + for (int i = 0; i < this._sqlWarnings.size(); i++) { + if (i != 0) + payload.append(",\n"); + SQLWarning warning = this._sqlWarnings.get(i); + while (warning != null) { + payload.append("{\n"); + payload.append("\"SQLState\": \"").append(warning.getSQLState()).append("\",\n"); + payload.append("\"SQLCode\": ").append(warning.getErrorCode()).append(",\n"); + payload.append("\"message\": \"").append(JSONSerializer.escapeJSON(normalizeErrorMessage(warning, false))).append("\"\n"); + payload.append("}"); + warning = warning.getNextWarning(); + if (warning != null) + payload.append(",\n"); + } + } + payload.append("\n]\n"); + } + payload.append("\n}"); + } else { + payload.append("\"SQLStateInfo\": null"); + } + if (this._results != null && !this._results.isEmpty()) { + if (returnSQLStateInResponse()) + payload.append(",\n"); + boolean paramsProcessed = false; + int resultSetsProcessed = 0; + for (RSESQLResult r : this._results) { + String rsIdentifier = "resultSet"; + if (this._resultSetCount > 1) + rsIdentifier = String.valueOf(rsIdentifier) + ++resultSetsProcessed; + if (resultSetsProcessed > 1 || paramsProcessed) + payload.append(",\n"); + payload.append("\"" + rsIdentifier + "\" : "); + if (!r.isEmpty()) { + r.toJSON(payload); + continue; + } + payload.append("[ ]"); + } + } + payload.append("\n}"); + return payload.toString(); + } + + public boolean sqlOperationFailed() { + return !(this._sqlException == null && (!this._treatWarningAsError || !warningsExist(false))); + } + + public SQLException getException() { + return this._sqlException; + } + + public String getErrorInfoAsString() { + if (this._sqlException == null) + return ""; + String msg = normalizeErrorMessage(this._sqlException, false); + msg = String.valueOf(msg) + " (SQLState=" + this._sqlException.getSQLState() + ", ErrorCode=" + this._sqlException.getErrorCode() + ")"; + return msg; + } + + private String normalizeString(String s) { + if (s == null || s.isEmpty()) + return s; + return CommonUtil.trimRight(s); + } + + private String normalizeErrorMessage(SQLException se, boolean doChained) { + if (se == null) + return ""; + String msg = se.getMessage(); + if (msg == null) + msg = ""; + StringBuilder sbMsg = new StringBuilder(msg); + Throwable t = se.getCause(); + while (t != null) { + if (t.getMessage() != null) + sbMsg.append(" ").append(t.getMessage()); + t = t.getCause(); + } + if (doChained && se instanceof SQLWarning) { + SQLWarning warning = ((SQLWarning)se).getNextWarning(); + while (warning != null) { + sbMsg.append(" ").append(warning.getMessage()); + warning = warning.getNextWarning(); + } + } + return sbMsg.toString(); + } + + private static String[] sanitizeColHdrsJSON(String[] columnHeaders) { + String[] ar = new String[columnHeaders.length]; + int m = 0; + byte b; + int i; + String[] arrayOfString1; + for (i = (arrayOfString1 = columnHeaders).length, b = 0; b < i; ) { + String h = arrayOfString1[b]; + ar[m++] = JSONSerializer.escapeJSON(h); + b++; + } + return ar; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"alwaysReturnSQLStateInformation", "treatWarmingsAsErrors", "sqlStatement"}) + @Schema(required = true, name = "RSEAPI_SQLRequest", description = "SQL request to run.") + public static class RSEAPI_SQLRequest { + @Schema(required = false, description = "Always return SQL state information. Default value is whatever has been set for the session.") + public Boolean alwaysReturnSQLStateInformation; + + @Schema(required = false, description = "Treat SQL warnings as errors. Default value is whatever has been set for the session.") + public Boolean treatWarningsAsErrors; + + @Schema(required = true, description = "The SQL statement to be run.") + public String sqlStatement; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/SecurityAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/SecurityAPIImpl.java new file mode 100644 index 0000000..55dae21 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/SecurityAPIImpl.java @@ -0,0 +1,1050 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Bin4; +import com.ibm.as400.access.AS400Exception; +import com.ibm.as400.access.AS400Message; +import com.ibm.as400.access.AS400UnsignedBin2; +import com.ibm.as400.access.AS400UnsignedBin4; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.ProgramParameter; +import com.ibm.as400.access.ServiceProgramCall; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CertificateStoreProcessor; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class SecurityAPIImpl extends APIImpl { + + private static final AS400Bin4 intConverter = new AS400Bin4(); + + private static final AS400UnsignedBin4 uintConverter = new AS400UnsignedBin4(); + + private static final AS400UnsignedBin2 uShortConverter = new AS400UnsignedBin2(); + + public static String securityDCMListCertificates(Session session, RSEAPI_DCMCertListRequest req) { + AsyncLogger.traceDebug("securityDCMListCertificates", null, new Object[0]); + AS400 sys = null; + JSONSerializer json = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, true); + String aliasFilter = null; + Set typesFilter = null; + Integer daysUntilExpirationFilter = null; + boolean excludeExpiredFilter = false; + if (req.filters != null) { + aliasFilter = CommonUtil.hasContent(req.filters.certAlias) ? req.filters.certAlias.trim() : null; + daysUntilExpirationFilter = req.filters.daysUntilExpiration; + excludeExpiredFilter = req.filters.excludeExpired; + if (req.filters.certTypes != null && !req.filters.certTypes.isEmpty()) { + for (String s : req.filters.certTypes) { + if (!CommonUtil.hasContent(s)) + continue; + s.toUpperCase(); + if (typesFilter == null) + typesFilter = new HashSet<>(); + if (CertificateStoreProcessor.isSupportCertificateType(s)) + typesFilter.add(s); + } + if (typesFilter.size() == 0) + typesFilter = null; + } + } + Map certMap = certstore.listCertificates(typesFilter, aliasFilter, daysUntilExpirationFilter, excludeExpiredFilter); + json = new JSONSerializer(); + json.startObject().startArray("certificates"); + if (certMap != null) + for (Map.Entry entry : certMap.entrySet()) { + CertificateStoreProcessor.CertificateInfo ci = entry.getValue(); + json.startObject(); + json.add("alias", ci.alias); + json.add("commonName", ci.commonName); + json.add("type", ci.type); + if (!ci.type.equals("CSR")) + json.add("daysBeforeExpiration", Integer.valueOf(ci.daysBeforeExpiration)); + json.add("keyAlgorithm", ci.keyAlgorithm); + json.add("keySize", Integer.valueOf(ci.keySize)); + json.add("hasPrivateKey", Boolean.valueOf(ci.hasPrivateKey)); + json.add("signatureAlgorithm", ci.signatureAlgorithm); + json.endObject(); + } + json.endArray().endObject(); + } catch (Exception e) { + handleException("securityDCMListCertificates", e); + } + return json.toString(); + } + + public static void securityDCMChangeCertificateStorePassword(Session session, RSEAPI_DCMCertStoreChangePasswordRequest req) { + AsyncLogger.traceDebug("securityDCMChangeCertificateStorePassword", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + boolean useSystemStash = (req.certStorePassword == null); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, !useSystemStash); + if (!CommonUtil.hasContent(req.certStorePasswordNew) || + req.daysToExpiration < 0) + throw new RestWABadRequestException(); + certstore.changeCertificateStorePassword(req.certStorePasswordNew, req.daysToExpiration, useSystemStash); + } catch (Exception e) { + handleException("securityDCMChangeCertificateStorePassword", e); + } + } + + public static String securityDCMExportCertificate(Session session, RSEAPI_DCMCertExportRequest req) { + AsyncLogger.traceDebug("securityDCMExportCertificate", null, new Object[0]); + AS400 sys = null; + JSONSerializer json = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, true); + if (!CommonUtil.hasContent(req.certAlias) || + !CertificateStoreProcessor.isSupportCertificateFormat(req.certFormat) || ( + req.certFormat.equalsIgnoreCase("PKCS12") && !CommonUtil.hasContent(req.certDataPassword))) + throw new RestWABadRequestException(); + CertificateStoreProcessor.CertificateData certdata = certstore.exportCertificate(req.certFormat, req.certAlias, req.certDataPassword); + json = new JSONSerializer(); + json.startObject(); + json.add("certFormat", certdata.format); + json.add("certData", CommonUtil.toBase64(certdata.certData)); + json.endObject(); + } catch (Exception e) { + handleException("securityDCMExportCertificate", e); + } + return json.toString(); + } + + public static void securityDCMImportCertificate(Session session, RSEAPI_DCMCertImportRequest req) { + AsyncLogger.traceDebug("securityDCMImportCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, true); + if (!CommonUtil.hasContent(req.certAlias) || + !CertificateStoreProcessor.isSupportCertificateType(req.certType) || req.certType.equalsIgnoreCase("CSR") || + !CommonUtil.hasContent(req.certData) || + !CertificateStoreProcessor.isSupportCertificateFormat(req.certFormat) || ( + req.certFormat.equalsIgnoreCase("PKCS12") && !CommonUtil.hasContent(req.certDataPassword))) + throw new RestWABadRequestException(); + byte[] certData = null; + try { + certData = CommonUtil.fromBase64(req.certData); + } catch (Exception e) { + throw new RestWABadRequestException("Certificate data not valid or not in the correct format. %s", new Object[] { e.getMessage() }); + } + certstore.importCertificate(req.certType, req.certFormat, req.certAlias, certData, req.certDataPassword); + } catch (Exception e) { + handleException("securityDCMImportCertificate", e); + } + } + + public static void securityDCMDeleteCertificate(Session session, RSEAPI_DCMCertRequest req) { + AsyncLogger.traceDebug("securityDCMDeleteCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, true); + if (!CommonUtil.hasContent(req.certAlias)) + throw new RestWABadRequestException(); + certstore.deleteCertificate(req.certAlias); + } catch (Exception e) { + handleException("securityDCMDeleteCertificate", e); + } + } + + public static String securityDCMGetCertificate(Session session, RSEAPI_DCMCertRequest req) { + AsyncLogger.traceDebug("securityDCMGetCertificate", null, new Object[0]); + AS400 sys = null; + JSONSerializer json = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, req.certStoreType, req.certStorePath, req.certStorePassword, true); + if (!CommonUtil.hasContent(req.certAlias)) + throw new RestWABadRequestException(); + CertificateStoreProcessor.CertificateInfo certinfo = certstore.getCertificateInfo(req.certAlias); + json = new JSONSerializer(); + json.startObject(); + if (certinfo != null) { + boolean isCSR = certinfo.type.equalsIgnoreCase("CSR"); + boolean isCA = certinfo.type.equalsIgnoreCase("CA"); + json.add("alias", certinfo.alias); + if (isCA) + json.add("trusted", Boolean.valueOf(certinfo.trusted)); + json.add("subject", certinfo.subjectDN); + if (!isCSR) + json.add("issuer", certinfo.issuerDN); + json.add("keyAlgorithm", certinfo.keyAlgorithm); + json.add("keySize", Integer.valueOf(certinfo.keySize)); + json.add("hasPrivateKey", Boolean.valueOf(certinfo.hasPrivateKey)); + if (certinfo.hasPrivateKey) + json.add("keyStorageLocation", certinfo.keyStorageLocation); + if (isCSR && !certinfo.signatureAlgorithm.equalsIgnoreCase("UNKNOWN")) + json.add("signatureAlgorithm", certinfo.signatureAlgorithm); + if (!isCSR) { + json.add("serialNumber", certinfo.serialNumber); + json.add("effectiveDate", certinfo.effectiveDate); + json.add("expirationDate", certinfo.expirationDate); + } + if (certinfo.sanList != null && !certinfo.sanList.isEmpty()) + json.add("subjectAlternativeNames", certinfo.sanList); + if (certinfo.keyUsageList != null && !certinfo.keyUsageList.isEmpty()) + json.add("keyUsages", certinfo.keyUsageList); + } + json.endObject(); + } catch (Exception e) { + handleException("securityDCMListCertificates", e); + } + return json.toString(); + } + + public static String securityDCMAppidList(Session session, String appidFilter, String apptypeFilter) { + AsyncLogger.traceDebug("securityDCMAppidList", null, new Object[0]); + AS400 sys = null; + JSONSerializer json = null; + try { + sys = session.getAS400(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, "CMS", "*SYSTEM", null, false); + if (!CommonUtil.hasContent(appidFilter)) + appidFilter = "*"; + if (!CommonUtil.hasContent(apptypeFilter)) + apptypeFilter = "*"; + apptypeFilter = apptypeFilter.toUpperCase(); + if (!apptypeFilter.equals("*") && !CertificateStoreProcessor.CertificateStoreProcessorCMS.dcmAppTypes.contains(apptypeFilter)) + throw new RestWABadRequestException(); + Map appidMap = certstore.listAppDefinitions(appidFilter, apptypeFilter.toUpperCase()); + json = new JSONSerializer(); + json.startObject().startArray("appDefinitions"); + if (appidMap != null) + for (Map.Entry entry : appidMap.entrySet()) { + CertificateStoreProcessor.AppDefinition appdef = entry.getValue(); + json.startObject(); + json.add("appDefinitionID", appdef.appid); + json.add("appType", appdef.appType); + json.add("description", appdef.description); + json.add("assignedCertificates", appdef.certList); + json.endObject(); + } + json.endArray().endObject(); + } catch (Exception e) { + handleException("securityDCMAppidList", e); + } + return json.toString(); + } + + public static void securityDCMAppDefAssociateCertificate(Session session, RSEAPI_DCMCertAppDefAssociateRequest req) { + AsyncLogger.traceDebug("securityDCMAppDefAssociateCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, "CMS", "*SYSTEM", null, false); + if (!CommonUtil.hasContent(req.certAliases) || req.certAliases.size() >= 5 || + !CommonUtil.hasContent(req.appDefinitionID)) + throw new RestWABadRequestException(); + ArrayList certAliases = new ArrayList<>(); + for (String s : req.certAliases) { + if (CommonUtil.hasContent(s)) + certAliases.add(s); + } + certstore.associateCertificatesToAppDefinition(req.appDefinitionID, certAliases); + } catch (Exception e) { + handleException("securityDCMAppDefAssociateCertificate", e); + } + } + + public static void securityDCMAppDefDisassociateCertificate(Session session, RSEAPI_DCMCertAppDefDisassociateRequest req) { + AsyncLogger.traceDebug("securityDCMAppDefDisassociateCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, "CMS", "*SYSTEM", null, false); + if (!CommonUtil.hasContent(req.appDefinitionID)) + throw new RestWABadRequestException(); + certstore.disassociateCertificatesFromAppDefinition(req.appDefinitionID); + } catch (Exception e) { + handleException("securityDCMAppDefDisassociateCertificate", e); + } + } + + public static void securityDCMAppDefTrustCertificate(Session session, RSEAPI_DCMCertAppDefTrustRequest req) { + AsyncLogger.traceDebug("securityDCMAppDefTrustCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, "CMS", "*SYSTEM", null, false); + if (!CommonUtil.hasContent(req.certAlias) || + !CommonUtil.hasContent(req.appDefinitionID)) + throw new RestWABadRequestException(); + ArrayList certAliases = new ArrayList<>(); + certAliases.add(req.certAlias); + certstore.trustCertificateForAppDefinition(req.appDefinitionID, certAliases); + } catch (Exception e) { + handleException("securityDCMAppDefTrustCertificate", e); + } + } + + public static void securityDCMAppDefUntrustCertificate(Session session, RSEAPI_DCMCertAppDefTrustRequest req) { + AsyncLogger.traceDebug("securityDCMAppDefUntrustCertificate", null, new Object[0]); + AS400 sys = null; + try { + sys = session.getAS400(); + if (req == null) + throw new RestWABadRequestException(); + CertificateStoreProcessor certstore = CertificateStoreProcessor.getInstance(sys, "CMS", "*SYSTEM", null, false); + if (!CommonUtil.hasContent(req.certAlias) || + !CommonUtil.hasContent(req.appDefinitionID)) + throw new RestWABadRequestException(); + ArrayList certAliases = new ArrayList<>(); + certAliases.add(req.certAlias); + certstore.untrustCertificateForAppDefinition(req.appDefinitionID, certAliases); + } catch (Exception e) { + handleException("securityDCMAppDefUntrustCertificate", e); + } + } + + public static String securityTLSGetAttributes(Session session) { + AsyncLogger.traceDebug("securityTLSGetAttributes", null, new Object[0]); + AS400 sys = null; + SecurityTLSAttributes tlsa = null; + try { + sys = session.getAS400(); + String format = "TLSA0100"; + int initialReceiverVariableSize = 1024; + ServiceProgramCall spc = CommonUtil.getServiceProgramCall(sys, "/QSYS.LIB/QSOTLSA.SRVPGM", "QsoRtvTLSA", 4, true, false); + CharConverter charConverter = new CharConverter(sys.getCcsid(), sys); + ProgramParameter[] pgmParmList = spc.getParameterList(); + byte[] bytes = null; + bytes = charConverter.stringToByteArray(format); + pgmParmList[0].setInputData(bytes); + pgmParmList[1].setOutputDataLength(initialReceiverVariableSize); + bytes = intConverter.toBytes(initialReceiverVariableSize); + pgmParmList[2].setInputData(bytes); + if (!spc.run()) { + AS400Message[] msgs = spc.getMessageList(); + StringBuilder sb = new StringBuilder(); + if (msgs != null) + for (int j = 0; j < msgs.length; j++) + sb.append(msgs[j].getID()).append(":").append(msgs[j].getText()).append(" "); + throw new AS400Exception(msgs); + } + tlsa = new SecurityTLSAttributes(format); + byte[] outBuf = pgmParmList[1].getOutputData(); + int offset = 0; + int i; + for (i = 0; i < 10; i++) { + tlsa.addProtocol(uShortConverter.toInt(outBuf, offset), true, false, false); + offset += 2; + } + offset = 24; + for (i = 0; i < 10; i++) { + tlsa.addProtocol(uShortConverter.toInt(outBuf, offset), false, true, false); + offset += 2; + } + offset = 48; + for (i = 0; i < 10; i++) { + tlsa.addProtocol(uShortConverter.toInt(outBuf, offset), false, false, true); + offset += 2; + } + offset = 72; + for (i = 0; i < 64; i++) { + tlsa.addCipherSpec(uShortConverter.toInt(outBuf, offset), true, false, false); + offset += 2; + } + offset = 204; + for (i = 0; i < 64; i++) { + tlsa.addCipherSpec(uShortConverter.toInt(outBuf, offset), false, true, false); + offset += 2; + } + offset = 336; + for (i = 0; i < 64; i++) { + tlsa.addCipherSpec(uShortConverter.toInt(outBuf, offset), false, false, true); + offset += 2; + } + offset = 468; + for (i = 0; i < 32; i++) { + tlsa.addSignature(uShortConverter.toInt(outBuf, offset), true, false); + offset += 2; + } + offset = 536; + for (i = 0; i < 32; i++) { + tlsa.addSignature(uShortConverter.toInt(outBuf, offset), false, true); + offset += 2; + } + offset = 604; + for (i = 0; i < 32; i++) { + tlsa.addSigAlgCertificate(uShortConverter.toInt(outBuf, offset), true, false); + offset += 2; + } + offset = 672; + for (i = 0; i < 32; i++) { + tlsa.addSigAlgCertificate(uShortConverter.toInt(outBuf, offset), false, true); + offset += 2; + } + offset = 740; + for (i = 0; i < 32; i++) { + tlsa.addEllipticalCurve(uShortConverter.toInt(outBuf, offset), true, false); + offset += 2; + } + offset = 808; + for (i = 0; i < 32; i++) { + tlsa.addEllipticalCurve(uShortConverter.toInt(outBuf, offset), false, true); + offset += 2; + } + tlsa.setDefaultMinimumRSAKeySize(uintConverter.toLong(outBuf, 876)); + byte[] slice = Arrays.copyOfRange(outBuf, 976, 980); + boolean handshakeConnectionCount = ((slice[0] & 0x80) != 0); + boolean secureSessionCaching = ((slice[0] & 0x40) != 0); + boolean middleboxCompatibilityMode = ((slice[1] & 0x80) != 0); + boolean auditSecureTelnetHandshakes = ((slice[1] & 0x40) != 0); + tlsa.setHandshakeConnectionCount(handshakeConnectionCount); + tlsa.setSecureSessionCaching(secureSessionCaching); + tlsa.setMiddleboxCompatibilityMode(middleboxCompatibilityMode); + tlsa.setAuditSecureTelnetHandshakes(auditSecureTelnetHandshakes); + } catch (Exception e) { + handleException("securityTLSGetAttributes", e); + } + return tlsa.toJSON(); + } + + public static String securityTLSGetStatistics(Session session) { + AsyncLogger.traceDebug("securityTLSGetStatistics", null, new Object[0]); + AS400 sys = null; + JSONSerializer json = null; + try { + sys = session.getAS400(); + String format = "TLSA0200"; + int initialReceiverVariableSize = 32768; + ServiceProgramCall spc = CommonUtil.getServiceProgramCall(sys, "/QSYS.LIB/QSOTLSA.SRVPGM", "QsoRtvTLSA", 4, true, false); + CharConverter charConverter = new CharConverter(sys.getCcsid(), sys); + ProgramParameter[] pgmParmList = spc.getParameterList(); + byte[] bytes = null; + bytes = charConverter.stringToByteArray(format); + pgmParmList[0].setInputData(bytes); + pgmParmList[1].setOutputDataLength(initialReceiverVariableSize); + bytes = intConverter.toBytes(initialReceiverVariableSize); + pgmParmList[2].setInputData(bytes); + if (!spc.run()) { + AS400Message[] msgs = spc.getMessageList(); + StringBuilder sb = new StringBuilder(); + if (msgs != null) + for (int i = 0; i < msgs.length; i++) + sb.append(msgs[i].getID()).append(":").append(msgs[i].getText()).append(" "); + throw new AS400Exception(msgs); + } + byte[] outBuf = pgmParmList[1].getOutputData(); + long flag = uintConverter.toLong(outBuf, 0); + if (flag == 0L) + throw new RestWANotFoundException("The specified resource was not found.", new Object[0]); + json = new JSONSerializer(); + json.startObject(); + json.startObject("protocolCounters"); + json.add("TLSv13", Long.valueOf(uintConverter.toLong(outBuf, 8))); + json.add("TLSv12", Long.valueOf(uintConverter.toLong(outBuf, 12))); + json.add("TLSv11", Long.valueOf(uintConverter.toLong(outBuf, 16))); + json.add("TLSv10", Long.valueOf(uintConverter.toLong(outBuf, 20))); + json.add("SSLv3", Long.valueOf(uintConverter.toLong(outBuf, 24))); + json.endObject(); + json.startObject("cipherSuiteCounters"); + json.add("AES_128_GCM_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 48))); + json.add("AES_256_GCM_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 52))); + json.add("CHACHA20_POLY1305_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 56))); + json.add("ECDHE_ECDSA_AES_128_GCM_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 60))); + json.add("ECDHE_ECDSA_AES_256_GCM_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 64))); + json.add("ECDHE_ECDSA_CHACHA20_POLY1305_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 68))); + json.add("ECDHE_RSA_AES_128_GCM_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 72))); + json.add("ECDHE_RSA_AES_256_GCM_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 76))); + json.add("ECDHE_RSA_CHACHA20_POLY1305_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 80))); + json.add("RSA_AES_128_GCM_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 84))); + json.add("RSA_AES_256_GCM_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 88))); + json.add("ECDHE_ECDSA_AES_128_CBC_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 92))); + json.add("ECDHE_ECDSA_AES_256_CBC_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 96))); + json.add("ECDHE_RSA_AES_128_CBC_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 100))); + json.add("ECDHE_RSA_AES_256_CBC_SHA384", Long.valueOf(uintConverter.toLong(outBuf, 104))); + json.add("RSA_AES_128_CBC_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 108))); + json.add("RSA_AES_128_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 112))); + json.add("RSA_AES_256_CBC_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 116))); + json.add("RSA_AES_256_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 120))); + json.add("ECDHE_ECDSA_3DES_EDE_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 124))); + json.add("ECDHE_RSA_3DES_EDE_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 128))); + json.add("RSA_3DES_EDE_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 132))); + json.add("ECDHE_ECDSA_RC4_128_SHA", Long.valueOf(uintConverter.toLong(outBuf, 136))); + json.add("ECDHE_RSA_RC4_128_SHA", Long.valueOf(uintConverter.toLong(outBuf, 140))); + json.add("RSA_RC4_128_SHA", Long.valueOf(uintConverter.toLong(outBuf, 144))); + json.add("RSA_RC4_128_MD5", Long.valueOf(uintConverter.toLong(outBuf, 148))); + json.add("RSA_DES_CBC_SHA", Long.valueOf(uintConverter.toLong(outBuf, 152))); + json.add("RSA_EXPORT_RC4_40_MD5", Long.valueOf(uintConverter.toLong(outBuf, 156))); + json.add("RSA_EXPORT_RC2_CBC_40_MD5", Long.valueOf(uintConverter.toLong(outBuf, 160))); + json.add("ECDHE_ECDSA_NULL_SHA", Long.valueOf(uintConverter.toLong(outBuf, 164))); + json.add("ECDHE_RSA_NULL_SHA", Long.valueOf(uintConverter.toLong(outBuf, 168))); + json.add("RSA_NULL_SHA256", Long.valueOf(uintConverter.toLong(outBuf, 172))); + json.add("RSA_NULL_SHA", Long.valueOf(uintConverter.toLong(outBuf, 176))); + json.add("RSA_NULL_MD5", Long.valueOf(uintConverter.toLong(outBuf, 180))); + json.endObject(); + json.endObject(); + } catch (Exception e) { + handleException("securityTLSGetStatistics", e); + } + return json.toString(); + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"alias", "certTypes", "daysUntilExpiration", "excludeExpired"}) + public static class RSEAPI_DCMCertListFilters { + @Schema(required = false, description = "Aliase name filter. A simple generic name can be specified. For example, myCert*") + public String certAlias; + + @Schema(required = false, description = "Certificate types filter. Valid values: CA, SERVER_CLIENT.") + public List certTypes; + + @Schema(required = false, description = "Days until expiration filter.") + public Integer daysUntilExpiration; + + @Schema(required = false, description = "Whether to exclude expired certificates.") + public boolean excludeExpired; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"certStoreType", "certStorePath", "certStorePassword", "filters"}) + @Schema(required = true, name = "RSEAPI_DCMCertListRequest", description = "List certificate request.") + public static class RSEAPI_DCMCertListRequest { + @Schema(required = true, description = "The type of the certificate store. Valid values: CMS.") + public String certStoreType; + + @Schema(required = true, description = "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION.") + public String certStorePath; + + @Schema(required = true, description = "The certificate store password.") + public String certStorePassword; + + @Schema(required = false, description = "One or more combination of filters.") + public SecurityAPIImpl.RSEAPI_DCMCertListFilters filters; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"certStoreType", "certStorePath", "certStorePassword", "certStorePasswordNew", "daysToExpiration"}) + @Schema(required = true, name = "RSEAPI_DCMCertStoreChangePasswordRequest", description = "Change certificate store password request.") + public static class RSEAPI_DCMCertStoreChangePasswordRequest { + @Schema(required = true, description = "The type of the certificate store. Valid values: CMS.") + public String certStoreType; + + @Schema(required = true, description = "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION.") + public String certStorePath; + + @Schema(required = true, description = "The certificate store password. If field omitted or set to null, the system stash will be used.") + public String certStorePassword; + + @Schema(required = true, description = "The new certificate store password.") + public String certStorePasswordNew; + + @Schema(required = false, defaultValue = "0", description = "Number of days before password expires.") + public int daysToExpiration; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"certStoreType", "certStorePath", "certStorePassword", "certFormat", "certAlias", "certDataPassword"}) + @Schema(required = true, name = "RSEAPI_DCMCertExportRequest", description = "Export certificate request.") + public static class RSEAPI_DCMCertExportRequest { + @Schema(required = true, description = "The type of the certificate store. Valid values: CMS.") + public String certStoreType; + + @Schema(required = true, description = "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION.") + public String certStorePath; + + @Schema(required = true, description = "The certificate store password.") + public String certStorePassword; + + @Schema(required = true, description = "The format of the certificate. Possible values: PKCS12, DER, or PEM.") + public String certFormat; + + @Schema(required = true, description = "The certificate label.") + public String certAlias; + + @Schema(required = false, description = "The password to access the certificate data that is returned. This field is only used when certificate type is SERVER_CLIENT.") + public String certDataPassword; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"certStoreType", "certStorePath", "certStorePassword", "certType", "certFormat", "certAlias", "certData", "certDataPassword"}) + @Schema(required = true, name = "RSEAPI_DCMCertImportRequest", description = "Import certificate request.") + public static class RSEAPI_DCMCertImportRequest { + @Schema(required = true, description = "The type of the certificate store. Valid values: CMS.") + public String certStoreType; + + @Schema(required = true, description = "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION.") + public String certStorePath; + + @Schema(required = true, description = "The certificate store password.") + public String certStorePassword; + + @Schema(required = true, description = "The certificate type. Possible values: CA, or SERVER_CLIENT") + public String certType; + + @Schema(required = true, description = "The format of the certificate. Possible values: PKCS12, DER, or PEM.") + public String certFormat; + + @Schema(required = false, description = "The certificate label. This property is ignored when certificate type is set to CERTIFICATE_SIGNING_REQUEST.") + public String certAlias; + + @Schema(required = true, description = "Base64-encoded binary data object representing the certificate to be imported.") + public String certData; + + @Schema(required = false, description = "The password to access the certificate data. This property is only used when the certificate type is set to SERVER_CLIENT.") + public String certDataPassword; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"certStoreType", "certStorePath", "certStorePassword", "certAlias"}) + @Schema(required = true, name = "RSEAPI_DCMCertRequest", description = "Certificate request.") + public static class RSEAPI_DCMCertRequest { + @Schema(required = true, description = "The type of the certificate store. Valid values: CMS.") + public String certStoreType; + + @Schema(required = true, description = "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or *SIGNATUREVERIFICATION.") + public String certStorePath; + + @Schema(required = true, description = "The certificate store password.") + public String certStorePassword; + + @Schema(required = true, description = "The certificate label.") + public String certAlias; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"appDefinitionID", "certAliases"}) + @Schema(required = true, name = "RSEAPI_DCMCertAppDefAssociateRequest", description = "Associate certificate(s) to an application definition.") + public static class RSEAPI_DCMCertAppDefAssociateRequest { + @Schema(required = true, description = "The application definition identifier.") + public String appDefinitionID; + + @Schema(required = true, description = "The certificate label. Maximum of 4.") + public List certAliases; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"appDefinitionID"}) + @Schema(required = true, name = "RSEAPI_DCMCertAppDefDisassociateRequest", description = "Disassociate certificates from an application definition.") + public static class RSEAPI_DCMCertAppDefDisassociateRequest { + @Schema(required = true, description = "The application definition identifier.") + public String appDefinitionID; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"appDefinitionID", "certAlias"}) + @Schema(required = true, name = "RSEAPI_DCMCertAppDefTrustRequest", description = "Add/remove a CA certificate to/from an application definition list of trusted CA certificates.") + public static class RSEAPI_DCMCertAppDefTrustRequest { + @Schema(required = true, description = "The application definition identifier.") + public String appDefinitionID; + + @Schema(required = true, description = "The certificate label.") + public String certAlias; + } + + private static class SecurityTLSAttributes { + public static final int TLS_SSLV2_PROTOCOL = 2; + + public static final int TLS_SSLV3_PROTOCOL = 768; + + public static final int TLS_TLSV10_PROTOCOL = 769; + + public static final int TLS_TLSV11_PROTOCOL = 770; + + public static final int TLS_TLSV12_PROTOCOL = 771; + + public static final int TLS_TLSV13_PROTOCOL = 772; + + public static final int TLS_RSA_WITH_NULL_MD5 = 1; + + public static final int TLS_RSA_WITH_NULL_SHA = 2; + + public static final int TLS_RSA_WITH_NULL_SHA256 = 59; + + public static final int TLS_RSA_WITH_RC4_128_SHA = 5; + + public static final int TLS_RSA_WITH_DES_CBC_SHA = 9; + + public static final int TLS_RSA_WITH_3DES_EDE_CBC_SHA = 10; + + public static final int TLS_RSA_EXPORT_WITH_RC4_40_MD5 = 3; + + public static final int TLS_RSA_WITH_RC4_128_MD5 = 4; + + public static final int TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5 = 6; + + public static final int TLS_RSA_WITH_RC2_CBC_128_MD5 = 65281; + + public static final int TLS_RSA_WITH_DES_CBC_MD5 = 65282; + + public static final int TLS_RSA_WITH_3DES_EDE_CBC_MD5 = 65283; + + public static final int TLS_RSA_WITH_AES_128_CBC_SHA = 47; + + public static final int TLS_RSA_WITH_AES_256_CBC_SHA = 53; + + public static final int TLS_RSA_WITH_AES_128_CBC_SHA256 = 60; + + public static final int TLS_RSA_WITH_AES_256_CBC_SHA256 = 61; + + public static final int TLS_RSA_WITH_AES_128_GCM_SHA256 = 156; + + public static final int TLS_RSA_WITH_AES_256_GCM_SHA384 = 157; + + public static final int TLS_ECDHE_ECDSA_WITH_NULL_SHA = 49158; + + public static final int TLS_ECDHE_ECDSA_WITH_RC4_128_SHA = 49159; + + public static final int TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA = 49160; + + public static final int TLS_ECDHE_RSA_WITH_NULL_SHA = 49168; + + public static final int TLS_ECDHE_RSA_WITH_RC4_128_SHA = 49169; + + public static final int TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA = 49170; + + public static final int TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256 = 49187; + + public static final int TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384 = 49188; + + public static final int TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 = 49191; + + public static final int TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 = 49192; + + public static final int TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 = 49195; + + public static final int TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 = 49196; + + public static final int TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 49199; + + public static final int TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 = 49200; + + public static final int TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 = 52392; + + public static final int TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 = 52393; + + public static final int TLS_AES_128_GCM_SHA256 = 4865; + + public static final int TLS_AES_256_GCM_SHA384 = 4866; + + public static final int TLS_CHACHA20_POLY1305_SHA256 = 4867; + + public static final int TLS_SIG_ALG_RSA_MD5 = 257; + + public static final int TLS_SIG_ALG_RSA_SHA1 = 513; + + public static final int TLS_SIG_ALG_RSA_SHA224 = 769; + + public static final int TLS_SIG_ALG_RSA_SHA256 = 1025; + + public static final int TLS_SIG_ALG_RSA_SHA384 = 1281; + + public static final int TLS_SIG_ALG_RSA_SHA512 = 1537; + + public static final int TLS_SIG_ALG_ECDSA_SHA1 = 515; + + public static final int TLS_SIG_ALG_ECDSA_SHA224 = 771; + + public static final int TLS_SIG_ALG_ECDSA_SHA256 = 1027; + + public static final int TLS_SIG_ALG_ECDSA_SHA384 = 1283; + + public static final int TLS_SIG_ALG_ECDSA_SHA512 = 1539; + + public static final int TLS_SIG_ALG_RSA_PSS_SHA256 = 2052; + + public static final int TLS_SIG_ALG_RSA_PSS_SHA384 = 2053; + + public static final int TLS_SIG_ALG_RSA_PSS_SHA512 = 2054; + + public static final int TLS_SIG_ALG_ED25519 = 2055; + + public static final int TLS_SIG_ALG_ED448 = 2056; + + public static final int TLS_CURVE_SECP192R1 = 19; + + public static final int TLS_CURVE_SECP224R1 = 21; + + public static final int TLS_CURVE_SECP256R1 = 23; + + public static final int TLS_CURVE_SECP384R1 = 24; + + public static final int TLS_CURVE_SECP521R1 = 25; + + public static final int TLS_CURVE_X25519 = 29; + + public static final int TLS_CURVE_X448 = 30; + + private static Map protoMap = new HashMap<>(); + + private static Map cipherMap = new HashMap<>(); + + private static Map signatureMap = new HashMap<>(); + + private static Map curveMap = new HashMap<>(); + + private String format; + + static { + protoMap.put(Integer.valueOf(2), "SSLv2"); + protoMap.put(Integer.valueOf(768), "SSLv3"); + protoMap.put(Integer.valueOf(769), "TLSv1.0"); + protoMap.put(Integer.valueOf(770), "TLSv1.1"); + protoMap.put(Integer.valueOf(771), "TLSv1.2"); + protoMap.put(Integer.valueOf(772), "TLSv1.3"); + cipherMap.put(Integer.valueOf(1), "RSA_WITH_NULL_MD5"); + cipherMap.put(Integer.valueOf(2), "RSA_WITH_NULL_SHA"); + cipherMap.put(Integer.valueOf(59), "RSA_WITH_NULL_SHA256"); + cipherMap.put(Integer.valueOf(5), "RSA_WITH_RC4_128_SHA"); + cipherMap.put(Integer.valueOf(9), "RSA_WITH_DES_CBC_SHA"); + cipherMap.put(Integer.valueOf(10), "RSA_WITH_3DES_EDE_CBC_SHA"); + cipherMap.put(Integer.valueOf(3), "RSA_EXPORT_WITH_RC4_40_MD5"); + cipherMap.put(Integer.valueOf(4), "RSA_WITH_RC4_128_MD5"); + cipherMap.put(Integer.valueOf(6), "RSA_EXPORT_WITH_RC2_CBC_40_MD5"); + cipherMap.put(Integer.valueOf(65281), "RSA_WITH_RC2_CBC_128_MD5"); + cipherMap.put(Integer.valueOf(65282), "RSA_WITH_DES_CBC_MD5"); + cipherMap.put(Integer.valueOf(65283), "RSA_WITH_3DES_EDE_CBC_MD5"); + cipherMap.put(Integer.valueOf(47), "RSA_WITH_AES_128_CBC_SHA"); + cipherMap.put(Integer.valueOf(53), "RSA_WITH_AES_256_CBC_SHA"); + cipherMap.put(Integer.valueOf(60), "RSA_WITH_AES_128_CBC_SHA256"); + cipherMap.put(Integer.valueOf(61), "RSA_WITH_AES_256_CBC_SHA256"); + cipherMap.put(Integer.valueOf(156), "RSA_WITH_AES_128_GCM_SHA256"); + cipherMap.put(Integer.valueOf(157), "RSA_WITH_AES_256_GCM_SHA384"); + cipherMap.put(Integer.valueOf(49158), "ECDHE_ECDSA_WITH_NULL_SHA"); + cipherMap.put(Integer.valueOf(49159), "ECDHE_ECDSA_WITH_RC4_128_SHA"); + cipherMap.put(Integer.valueOf(49160), "ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA"); + cipherMap.put(Integer.valueOf(49168), "ECDHE_RSA_WITH_NULL_SHA"); + cipherMap.put(Integer.valueOf(49169), "ECDHE_RSA_WITH_RC4_128_SHA"); + cipherMap.put(Integer.valueOf(49170), "ECDHE_RSA_WITH_3DES_EDE_CBC_SHA"); + cipherMap.put(Integer.valueOf(49187), "ECDHE_ECDSA_WITH_AES_128_CBC_SHA256"); + cipherMap.put(Integer.valueOf(49188), "ECDHE_ECDSA_WITH_AES_256_CBC_SHA384"); + cipherMap.put(Integer.valueOf(49191), "ECDHE_RSA_WITH_AES_128_CBC_SHA256"); + cipherMap.put(Integer.valueOf(49192), "ECDHE_RSA_WITH_AES_256_CBC_SHA384"); + cipherMap.put(Integer.valueOf(49195), "ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"); + cipherMap.put(Integer.valueOf(49196), "ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"); + cipherMap.put(Integer.valueOf(49199), "ECDHE_RSA_WITH_AES_128_GCM_SHA256"); + cipherMap.put(Integer.valueOf(49200), "ECDHE_RSA_WITH_AES_256_GCM_SHA384"); + cipherMap.put(Integer.valueOf(52392), "ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"); + cipherMap.put(Integer.valueOf(52393), "ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"); + cipherMap.put(Integer.valueOf(4865), "AES_128_GCM_SHA256"); + cipherMap.put(Integer.valueOf(4866), "AES_256_GCM_SHA384"); + cipherMap.put(Integer.valueOf(4867), "CHACHA20_POLY1305_SHA256"); + signatureMap.put(Integer.valueOf(257), "RSA_MD5"); + signatureMap.put(Integer.valueOf(513), "RSA_SHA1"); + signatureMap.put(Integer.valueOf(769), "RSA_SHA224"); + signatureMap.put(Integer.valueOf(1025), "RSA_SHA256"); + signatureMap.put(Integer.valueOf(1281), "RSA_SHA384"); + signatureMap.put(Integer.valueOf(1537), "RSA_SHA512"); + signatureMap.put(Integer.valueOf(515), "ECDSA_SHA1"); + signatureMap.put(Integer.valueOf(771), "ECDSA_SHA224"); + signatureMap.put(Integer.valueOf(1027), "ECDSA_SHA256"); + signatureMap.put(Integer.valueOf(1283), "ECDSA_SHA384"); + signatureMap.put(Integer.valueOf(1539), "ECDSA_SHA512"); + signatureMap.put(Integer.valueOf(2052), "RSA_PSS_SHA256"); + signatureMap.put(Integer.valueOf(2053), "RSA_PSS_SHA384"); + signatureMap.put(Integer.valueOf(2054), "RSA_PSS_SHA512"); + signatureMap.put(Integer.valueOf(2055), "ED25519"); + signatureMap.put(Integer.valueOf(2056), "ED448"); + curveMap.put(Integer.valueOf(19), "Secp192r1"); + curveMap.put(Integer.valueOf(21), "Secp224r1"); + curveMap.put(Integer.valueOf(23), "Secp256r1"); + curveMap.put(Integer.valueOf(24), "Secp384r1"); + curveMap.put(Integer.valueOf(25), "Secp521r1"); + curveMap.put(Integer.valueOf(29), "x25519"); + curveMap.put(Integer.valueOf(30), "x448"); + } + + private List supportedProtocols = new ArrayList<>(); + + private List eligibleDefaultProtocols = new ArrayList<>(); + + private List defaultProtocols = new ArrayList<>(); + + private List supportedCipherSuites = new ArrayList<>(); + + private List eligibleDefaultCipherSuites = new ArrayList<>(); + + private List defaultCipherSuites = new ArrayList<>(); + + private List supportedSignatureAlgorithms = new ArrayList<>(); + + private List defaultSignatureAlgorithms = new ArrayList<>(); + + private List supportedSignatureAlgorithmCertificates = new ArrayList<>(); + + private List defaultSignatureAlgorithmCertificates = new ArrayList<>(); + + private List supportedNamedCurves = new ArrayList<>(); + + private List defaultNamedCurves = new ArrayList<>(); + + private long defaultMinimumRSAKeySize; + + boolean handshakeConnectionCount; + + boolean secureSessionCaching; + + boolean middleboxCompatibilityMode; + + boolean auditSecureTelnetHandshakes; + + public SecurityTLSAttributes(String format) { + this.format = format; + } + + public void setDefaultMinimumRSAKeySize(long defaultMinimumRSAKeySize) { + this.defaultMinimumRSAKeySize = defaultMinimumRSAKeySize; + } + + public void setHandshakeConnectionCount(boolean handshakeConnectionCount) { + this.handshakeConnectionCount = handshakeConnectionCount; + } + + public void setSecureSessionCaching(boolean secureSessionCaching) { + this.secureSessionCaching = secureSessionCaching; + } + + public void setMiddleboxCompatibilityMode(boolean middleboxCompatibilityMode) { + this.middleboxCompatibilityMode = middleboxCompatibilityMode; + } + + public void setAuditSecureTelnetHandshakes(boolean auditSecureTelnetHandshakes) { + this.auditSecureTelnetHandshakes = auditSecureTelnetHandshakes; + } + + public void addProtocol(int p, boolean supportedProto, boolean eligibleDefaultProto, boolean defaultProto) { + if (p == 0) + return; + String v = protoMap.get(Integer.valueOf(p)); + if (v == null) + v = Integer.toString(p); + if (supportedProto) { + this.supportedProtocols.add(v); + } else if (eligibleDefaultProto) { + this.eligibleDefaultProtocols.add(v); + } else if (defaultProto) { + this.defaultProtocols.add(v); + } + } + + public void addCipherSpec(int cipher, boolean supportedCipher, boolean eligibleCipher, boolean defaultCipher) { + if (cipher == 0) + return; + String v = cipherMap.get(Integer.valueOf(cipher)); + if (v == null) + v = Integer.toString(cipher); + if (supportedCipher) { + this.supportedCipherSuites.add(v); + } else if (eligibleCipher) { + this.eligibleDefaultCipherSuites.add(v); + } else if (defaultCipher) { + this.defaultCipherSuites.add(v); + } + } + + public void addSignature(int signature, boolean supportedSig, boolean defaultSig) { + if (signature == 0) + return; + String v = signatureMap.get(Integer.valueOf(signature)); + if (v == null) + v = Integer.toString(signature); + if (supportedSig) { + this.supportedSignatureAlgorithms.add(v); + } else if (defaultSig) { + this.defaultSignatureAlgorithms.add(v); + } + } + + public void addSigAlgCertificate(int cert, boolean supportedCert, boolean defaultCert) { + if (cert == 0) + return; + String v = signatureMap.get(Integer.valueOf(cert)); + if (v == null) + v = Integer.toString(cert); + if (supportedCert) { + this.supportedSignatureAlgorithmCertificates.add(v); + } else if (defaultCert) { + this.defaultSignatureAlgorithmCertificates.add(v); + } + } + + public void addEllipticalCurve(int curve, boolean supportedCurve, boolean defaultCurve) { + if (curve == 0) + return; + String v = curveMap.get(Integer.valueOf(curve)); + if (v == null) + v = Integer.toString(curve); + if (supportedCurve) { + this.supportedNamedCurves.add(v); + } else if (defaultCurve) { + this.defaultNamedCurves.add(v); + } + } + + public String toJSON() { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + if (this.format.equals("TLSA0100")) { + if (this.supportedProtocols != null && !this.supportedProtocols.isEmpty()) + json.add("supportedProtocols", this.supportedProtocols); + if (this.eligibleDefaultProtocols != null && !this.supportedProtocols.isEmpty()) + json.add("eligibleDefaultProtocols", this.eligibleDefaultProtocols); + if (this.defaultProtocols != null && !this.supportedProtocols.isEmpty()) + json.add("defaultProtocols", this.defaultProtocols); + if (this.supportedCipherSuites != null && !this.supportedCipherSuites.isEmpty()) + json.add("supportedCipherSuites", this.supportedCipherSuites); + if (this.eligibleDefaultCipherSuites != null && !this.eligibleDefaultCipherSuites.isEmpty()) + json.add("eligibleDefaultCipherSuites", this.eligibleDefaultCipherSuites); + if (this.defaultCipherSuites != null && !this.defaultCipherSuites.isEmpty()) + json.add("defaultCipherSuites", this.defaultCipherSuites); + if (this.supportedSignatureAlgorithms != null && !this.supportedSignatureAlgorithms.isEmpty()) + json.add("supportedSignatureAlgorithms", this.supportedSignatureAlgorithms); + if (this.defaultSignatureAlgorithms != null && !this.defaultSignatureAlgorithms.isEmpty()) + json.add("defaultSignatureAlgorithms", this.defaultSignatureAlgorithms); + if (this.supportedSignatureAlgorithmCertificates != null && !this.supportedSignatureAlgorithmCertificates.isEmpty()) + json.add("supportedSignatureAlgorithmCertificates", this.supportedSignatureAlgorithmCertificates); + if (this.defaultSignatureAlgorithmCertificates != null && !this.defaultSignatureAlgorithmCertificates.isEmpty()) + json.add("defaultSignatureAlgorithmCertificates", this.defaultSignatureAlgorithmCertificates); + if (this.supportedNamedCurves != null && !this.supportedNamedCurves.isEmpty()) + json.add("supportedNamedCurves", this.supportedNamedCurves); + if (this.defaultNamedCurves != null && !this.defaultNamedCurves.isEmpty()) + json.add("defaultNamedCurves", this.defaultNamedCurves); + json.add("defaultMinimumRSAKeySize", Long.valueOf(this.defaultMinimumRSAKeySize)); + json.add("handshakeConnectionCounts", Boolean.valueOf(this.handshakeConnectionCount)); + json.add("secureSessionCaching", Boolean.valueOf(this.secureSessionCaching)); + json.add("auditSecureTelnetHandshakes", Boolean.valueOf(this.auditSecureTelnetHandshakes)); + } + json.endObject(); + return json.toString(); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/SessionAPIImpl.java b/src/main/java/dev/alexzaw/rest4i/api/SessionAPIImpl.java new file mode 100644 index 0000000..95ef9a0 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/SessionAPIImpl.java @@ -0,0 +1,465 @@ +package dev.alexzaw.rest4i.api; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.BinaryConverter; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.Job; +import com.ibm.as400.access.JobLog; +import com.ibm.as400.access.ObjectDescription; +import com.ibm.as400.access.ProgramParameter; +import com.ibm.as400.access.QueuedMessage; +import com.ibm.as400.access.ServiceProgramCall; +import com.ibm.as400.access.User; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedMustAuthenticateException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.session.SessionRepository; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class SessionAPIImpl extends APIImpl { + + public static Session sessionLogin(RSEAPI_LoginCredentials loginCredentials) { + if (loginCredentials == null) + throw new RestWABadRequestException("User ID or password is not set or not valid.", new Object[0]); + String host = loginCredentials.host; + String userid = loginCredentials.userid; + char[] password = null; + if (loginCredentials.password != null) { + password = loginCredentials.password.toCharArray(); + loginCredentials.password = null; + } + if (!CommonUtil.hasContent(userid) || userid.length() > 10 || !CommonUtil.hasContent(password)) + throw new RestWABadRequestException("User ID or password is not set or not valid.", new Object[0]); + if (!CommonUtil.hasContent(host)) + host = "localhost"; + if (!GlobalProperties.isIBMi() && (host.trim().equalsIgnoreCase("localhost") || host.trim().equals("127.0.0.1"))) + throw new RestWABadRequestException("Host name not set or server is not an IBM i server.", new Object[0]); + if (!GlobalProperties.isIncludeUserid(userid) || GlobalProperties.isExcludeUserid(userid)) { + AsyncLogger.traceAudit("login", "Login attempt by user '%s' failed. ", new Object[] { loginCredentials.userid }); + throw new RestWAUnauthorizedException("User ID or password is not set or not valid.", new Object[0]); + } + Session session = null; + try { + session = SessionRepository.createSession(host, userid, password); + } catch (Exception e) { + if (e instanceof com.ibm.as400.access.AS400SecurityException) + AsyncLogger.traceAudit("login", "Login attempt by user '%s' failed. " + e.getMessage(), new Object[] { loginCredentials.userid }); + handleException("getSessionToken", e, false); + } + AsyncLogger.traceAudit("login", "User '%s' authenticated.", new Object[] { session.getUserid() }); + return session; + } + + public static Session getSession(String sessionToken) { + if (!CommonUtil.hasContent(sessionToken)) + throw new RestWAUnauthorizedMustAuthenticateException("Authorization token not found.", new Object[0]); + Session session = SessionRepository.findSession(sessionToken); + if (session == null) + throw new RestWAUnauthorizedMustAuthenticateException("Authorization token not valid or may have expired.", new Object[0]); + String remoteAddrHex = "-" + CommonUtil.stringToHex(CommonUtil.getRemoteAddr()); + if (!sessionToken.endsWith(remoteAddrHex)) + throw new RestWAUnauthorizedMustAuthenticateException("Requestor is not originator of the authorization token.", new Object[0]); + return session; + } + + public static void sessionLogout(Session session) { + SessionRepository.deleteSession(session); + } + + public static SessionSettingsAndJobInfo sessionQuery(Session session, Integer maxJobLogMessages, String envvars, String jobLogFilter) { + AS400 sys = null; + SessionSettingsAndJobInfo sessionInfo = null; + boolean getLibl = true; + boolean getCurrentLib = true; + boolean getCCSID = true; + boolean getJobID = true; + boolean getJobLog = false; + boolean getEnvVars = false; + if (maxJobLogMessages == null) + maxJobLogMessages = new Integer(0); + long maxMessages = CommonUtil.getNumericProperty(null, maxJobLogMessages.toString(), -1L, 2147483647L, 0L); + if (maxMessages != 0L) + getJobLog = true; + if (CommonUtil.hasContent(envvars)) + getEnvVars = true; + try { + sys = session.getAS400(); + Job[] jobs = sys.getJobs(2); + if (jobs.length > 0) { + Job rmtCmdJob = jobs[0]; + List sysLibl = null; + List usrLibl = null; + QSYSLibraryInfo curLib = null; + Integer ccsid = null; + String jobID = null; + List jobLogList = null; + Map envVariableList = null; + if (getCurrentLib) + curLib = getLibInfo(sys, rmtCmdJob.getCurrentLibrary()); + if (getLibl) { + sysLibl = getLibInfo(sys, rmtCmdJob.getSystemLibraryList()); + usrLibl = getLibInfo(sys, rmtCmdJob.getUserLibraryList()); + } + if (getCCSID) + ccsid = new Integer(rmtCmdJob.getCodedCharacterSetID()); + if (getJobID) + jobID = String.valueOf(rmtCmdJob.getNumber()) + "/" + rmtCmdJob.getUser() + "/" + rmtCmdJob.getName(); + if (getJobLog) { + Set filters = null; + if (CommonUtil.hasContent(jobLogFilter)) { + jobLogFilter = jobLogFilter.replaceAll("\\s", "").toUpperCase(); + filters = (Set)Arrays.stream(jobLogFilter.split(",")).collect(Collectors.toSet()); + } + jobLogList = new ArrayList<>(); + JobLog jobLog = rmtCmdJob.getJobLog(); + jobLog.setListDirection(false); + int jobLogOffset = (maxMessages == -1L || filters != null) ? -1 : 0; + QueuedMessage[] qmsgs = jobLog.getMessages(jobLogOffset, (int)maxMessages); + int counter = 0; + byte b; + int i; + QueuedMessage[] arrayOfQueuedMessage1; + for (i = (arrayOfQueuedMessage1 = qmsgs).length, b = 0; b < i; ) { + QueuedMessage qmsg = arrayOfQueuedMessage1[b]; + String msgID = qmsg.getID(); + if (filters == null || msgID == null || !filters.contains(msgID)) { + String mh = String.valueOf(msgID) + ": " + qmsg.getText(); + if (CommonUtil.hasContent(mh)) { + jobLogList.add(0, mh); + counter++; + } + if (maxMessages != -1L && counter >= maxMessages) + break; + } + b++; + } + } + if (getEnvVars) + envVariableList = getEnvVariables(sys, Arrays.asList(envvars.split(","))); + String homeDir = (new User(sys, session.getUserid())).getHomeDirectory(); + sessionInfo = new SessionSettingsAndJobInfo(session, jobID, ccsid, sysLibl, usrLibl, curLib, jobLogList, envVariableList, homeDir); + } + } catch (Exception e) { + handleException("getSessionSettings", e); + } + return sessionInfo; + } + + public static SetSessionSettingsResults sessionRefresh(Session session, RSEAPI_SessionSettings insettings) { + SetSessionSettingsResults sr = null; + try { + sr = session.setSettings(insettings); + } catch (Exception e) { + handleException("setSessionSettings", e); + } + return sr; + } + + private static String[] extractBasicCredentials(String authCredentials) throws Exception { + String encodedUserPassword = authCredentials.replaceFirst("Basic ", ""); + String usernameAndPassword = null; + byte[] decodedBytes = Base64.getDecoder().decode(encodedUserPassword); + usernameAndPassword = new String(decodedBytes, "UTF-8"); + String[] pair = usernameAndPassword.split(":"); + return pair; + } + + private static QSYSLibraryInfo getLibInfo(AS400 sys, String libName) throws Exception { + if (!CommonUtil.hasContent(libName)) + return null; + ObjectDescription objDesc = new ObjectDescription(sys, "/QSYS.LIB/" + libName + ".LIB"); + return new QSYSLibraryInfo(objDesc.getName(), + objDesc.getValueAsString(202), + objDesc.getValueAsString(203)); + } + + private static ArrayList getLibInfo(AS400 sys, String[] libList) throws Exception { + ArrayList arr = new ArrayList<>(); + if (libList != null && libList.length > 0) { + byte b; + int i; + String[] arrayOfString; + for (i = (arrayOfString = libList).length, b = 0; b < i; ) { + String lib = arrayOfString[b]; + QSYSLibraryInfo li = getLibInfo(sys, lib); + if (li != null) + arr.add(li); + b++; + } + } + return arr; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"host", "userid", "password"}) + @Schema(required = true, name = "RSEAPI_LoginCredentials", description = "The login credentials.") + public static class RSEAPI_LoginCredentials { + @Schema(required = false, defaultValue = "localhost", description = "The IBM i server from which objects are to be accessed.") + public String host; + + @Schema(required = true, description = "The user ID.") + public String userid; + + @Schema(required = true, description = "The password.") + public String password; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(propOrder = {"resetSettings", "continueOnError", "libraryList", "clCommands", "envVariables", "sqlDefaultSchema", "sqlTreatWarningsAsErrors", "sqlProperties", "sqlStatements"}) + @Schema(required = true, name = "RSEAPI_SessionSettings", description = "The settings for the session.") + public static class RSEAPI_SessionSettings { + @Schema(required = false, defaultValue = "false", description = "Replace existing sessions attributes. A value of false will merge settings in request with existing session settings.") + public Boolean resetSettings; + + @Schema(required = false, defaultValue = "false", description = "Continue with session processing if an error occurs.") + public Boolean continueOnError; + + @Schema(required = false, description = "Library to be added to library list of the remote command host server job tied to session.") + public List libraryList; + + @Schema(required = false, description = "CL command to be run in remote command host server job tied to session.") + public List clCommands; + + @Schema(required = false, description = "Environment variable to set in remote command host server job tied to session.") + public Map envVariables; + + @Schema(required = false, description = "Default SQL schema to use when running SQL statements in database host server job. ") + public String sqlDefaultSchema; + + @Schema(required = false, defaultValue = "false", description = "Treat SQL warnings as errors. ") + public Boolean sqlTreatWarningsAsErrors; + + @Schema(required = false, description = "Java toolbox JDBC property. ") + public Map sqlProperties; + + @Schema(required = false, description = "SQL statment to be run in database host server job tied to session.") + public List sqlStatements; + } + + public static Map getEnvVariables(AS400 as400, List envVariables) throws Exception { + if (envVariables == null || envVariables.isEmpty()) + return null; + HashMap map = new HashMap<>(); + ServiceProgramCall spc = CommonUtil.getServiceProgramCall(as400, "/QSYS.LIB/QHTTPSVR.LIB/QZHBCGI.SRVPGM", "QtmhGetEnv", 6, true, false); + CharConverter charConverter = new CharConverter(as400.getCcsid(), as400); + ProgramParameter[] pgmParmList = spc.getParameterList(); + for (String envvar : envVariables) { + if (!CommonUtil.hasContent(envvar)) + continue; + pgmParmList[0].setOutputDataLength(8192); + pgmParmList[1].setInputData(BinaryConverter.intToByteArray(8192)); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(envvar) + "\000")); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(envvar.length())); + try { + String value = ""; + if (spc.run()) { + int envValueLen = BinaryConverter.byteArrayToInt(pgmParmList[2].getOutputData(), 0); + if (envValueLen > 0) { + byte[] output = pgmParmList[0].getOutputData(); + value = charConverter.byteArrayToString(output, 0, envValueLen); + } + } + map.put(envvar, value); + } catch (Exception e) { + e.printStackTrace(); + throw new RestWAInternalServerErrorException("Unable to process the request due to an internal error. %s", new Object[] { "getenv() failed. " + e.getMessage() }); + } + } + return map; + } + + public static class SessionSettingsAndJobInfo { + public Session session; + + public String jobID; + + public Integer ccsid; + + public List syslibl; + + public List usrlibl; + + public SessionAPIImpl.QSYSLibraryInfo curLib; + + public List jobLog; + + public Map envVariables; + + public String homeDirectory; + + public SessionSettingsAndJobInfo(Session session, String jobID, Integer ccsid, List sysLibl, List usrLibl, SessionAPIImpl.QSYSLibraryInfo curLib, List jobLog, Map envVariables, String homeDir) { + this.session = session; + this.jobID = jobID; + this.ccsid = ccsid; + this.syslibl = sysLibl; + this.usrlibl = usrLibl; + this.curLib = curLib; + this.jobLog = jobLog; + this.envVariables = envVariables; + this.homeDirectory = homeDir; + } + + public String toJSON() { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + this.session.toJSON(json); + json.startObject("jobRemoteCommand"); + json.add("id", this.jobID); + json.add("ccsid", this.ccsid); + json.add("homeDirectory", this.homeDirectory); + if (this.curLib == null) { + json.add("curLib", (String)null); + } else { + this.curLib.toJSON("curLib", json); + } + json.startArray("systemLibl"); + if (this.syslibl != null && !this.syslibl.isEmpty()) + for (SessionAPIImpl.QSYSLibraryInfo libInfo : this.syslibl) + libInfo.toJSON(null, json); + json.endArray(); + json.startArray("userLibl"); + if (this.usrlibl != null && !this.usrlibl.isEmpty()) + for (SessionAPIImpl.QSYSLibraryInfo libInfo : this.usrlibl) + libInfo.toJSON(null, json); + json.endArray(); + json.add("envVariables", this.envVariables); + json.add("jobLog", this.jobLog); + json.endObject(); + json.endObject(); + return json.toString(); + } + } + + public static class SetSessionSettingsResults { + public static class ErrorSource { + public String _source; + + public String _message; + + public ErrorSource(String source, String message) { + this._source = source; + this._message = message; + } + + JSONSerializer toJSON(String type, JSONSerializer ser) { + ser.startObject(); + ser.add(type, this._source); + ser.add("errorMessage", this._message); + ser.endObject(); + return ser; + } + } + + private List _clCommandErrors = null; + + private List _sqlStatementErrors = null; + + private List _libraryListErrors = null; + + private List _envVariableErrors = null; + + private List _sqlInitializationErrors = null; + + public SetSessionSettingsResults() {} + + public SetSessionSettingsResults(List clCommandErrors, List sqlStatementErrors, List libraryListErrors, List envVariableErrors, List sqlInitializationError) { + this._clCommandErrors = clCommandErrors; + this._sqlStatementErrors = sqlStatementErrors; + this._libraryListErrors = libraryListErrors; + this._envVariableErrors = envVariableErrors; + this._sqlInitializationErrors = sqlInitializationError; + } + + public boolean sqlInitializationErrors() { + return (this._sqlInitializationErrors != null && !this._sqlInitializationErrors.isEmpty()); + } + + public boolean errorsOccurred() { + return !((this._clCommandErrors == null || this._clCommandErrors.isEmpty()) && ( + this._sqlStatementErrors == null || this._sqlStatementErrors.isEmpty()) && ( + this._libraryListErrors == null || this._libraryListErrors.isEmpty()) && ( + this._envVariableErrors == null || this._envVariableErrors.isEmpty()) && ( + this._sqlInitializationErrors == null || this._sqlInitializationErrors.isEmpty())); + } + + public String toJSON() { + JSONSerializer json = new JSONSerializer(); + json.startObject(); + if (this._clCommandErrors != null && !this._clCommandErrors.isEmpty()) { + json.startArray("clCommandErrors"); + for (ErrorSource es : this._clCommandErrors) + es.toJSON("clCommand", json); + json.endArray(); + } + if (this._sqlInitializationErrors != null && !this._sqlInitializationErrors.isEmpty()) + json.add("sqlInitializationErrors", this._sqlInitializationErrors); + if (this._sqlStatementErrors != null && !this._sqlStatementErrors.isEmpty()) { + json.startArray("sqlStatementErrors"); + for (ErrorSource es : this._sqlStatementErrors) + es.toJSON("sqlStatement", json); + json.endArray(); + } + if (this._libraryListErrors != null && !this._libraryListErrors.isEmpty()) { + json.startArray("libraryListErrors"); + for (ErrorSource es : this._libraryListErrors) + es.toJSON("library", json); + json.endArray(); + } + if (this._envVariableErrors != null && !this._envVariableErrors.isEmpty()) { + json.startArray("envVariableErrors"); + for (ErrorSource es : this._envVariableErrors) + es.toJSON("variable", json); + json.endArray(); + } + json.endObject(); + return json.toString(); + } + } + + public static class QSYSLibraryInfo { + public String name_ = ""; + + public String attribute_ = ""; + + public String description_ = ""; + + public QSYSLibraryInfo(String name, String attribute, String text) { + if (CommonUtil.hasContent(name)) + this.name_ = name; + if (CommonUtil.hasContent(attribute)) + this.attribute_ = attribute; + if (CommonUtil.hasContent(text)) + this.description_ = text; + } + + public void toJSON(String identifier, JSONSerializer json) { + if (identifier == null) { + json.startObject(); + } else { + json.startObject(identifier); + } + json.add("name", this.name_); + json.add("attribute", this.attribute_); + json.add("description", this.description_); + json.endObject(); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/AdminRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/AdminRESTService.java new file mode 100644 index 0000000..b8c1b76 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/AdminRESTService.java @@ -0,0 +1,166 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.AdminAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "Administration Services", description = "Administration Services provide APIs that give information about RSE API and the runtime environment of the RSE API server. To use the APIs, the authenticated user must authenticate to localhost and have the administrator role or have *ALLOBJ special authority.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class AdminRESTService extends RESTService { + + @Path("/v1/admin/settings") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"adminUsers\": [\"USER1\",\"USER2\"],\n \"includeUsers\": [\"USER1\",\"USER2\"],\n \"excludeUsers\": [],\n \"maxFileSize\": 3072000,\n \"maxSessionInactivity\": 7200,\n \"maxSessionLifetime\": 21600,\n \"maxSessionUseCount\": 1000,\n \"maxSessionWaitTime\": 300,\n \"maxSessions\": 100,\n \"maxSessionsPerUser\": 20,\n \"sessionCleanupInterval\": 300\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get the general settings being used for RSE API. ", description = "The settings are global in nature and include settings for tuning, environment, and administrator override categories. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminGetSettings(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = AdminAPIImpl.adminGetSettings(session); + } catch (Exception e) { + return handleException("getSettings", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/admin/settings") + @POST + @Consumes({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Successful request, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Set settings for RSE API. ", description = "The settings are global in nature and include settings for tuning, environment, and administrator override categories. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminSetSettings(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The settings for RSE API.", required = true, content = {@Content(schema = @Schema(implementation = AdminAPIImpl.RSEAPI_Settings.class), mediaType = "application/json", example = "{\n \"persist\": false,\n \"adminUsers\": [\"USER1\",\"USER2\"],\n \"includeUsers\": [\"USER1\",\"USER2\"],\n \"excludeUsers\": [],\n \"maxFileSize\": 3072000,\n \"maxSessionInactivity\": 7200,\n \"maxSessionLifetime\": 21600,\n \"maxSessionUseCount\": 1000,\n \"maxSessionWaitTime\": 300,\n \"maxSessions\": 100,\n \"maxSessionsPerUser\": 20,\n \"sessionCleanupInterval\": 300\n}")}) AdminAPIImpl.RSEAPI_Settings rseapiSettings) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + AdminAPIImpl.adminSetSettings(session, rseapiSettings); + } catch (Exception e) { + return handleException("setRSEAPISettings", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/admin/sessions") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"totalSessions\": 3,\n \"sessions\": [\n {\n \"userid\": \"USER1\",\n \"sessionCount\": 1\n },\n {\n \"userid\": \"USER2\",\n \"sessionCount\": 2\n }\n ]\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get information about sessions. ", description = "Get information about sessions. The information that is returned applies to active sessions on the server. Active sessions may include sessions that are expired but have not been reclaimed by RSE API.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminGetSessions(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = AdminAPIImpl.adminGetSessions(session); + } catch (Exception e) { + return handleException("getSessions", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/admin/sessions") + @DELETE + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Successful request, no content."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Delete sessions.", description = "Delete sessions. You can delete all active sessions or only sessions tied to a user ID. Sessions that are deleted are marked as expired.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminClearSessions(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "The session(s) to delete. Specify the user ID to delete all sessions created by the user, or the special value of \\*ALL to delete all sessions for all users. ", required = false) @DefaultValue("*ALL") @QueryParam("user") String user) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + AdminAPIImpl.adminClearSessions(session, user); + } catch (Exception e) { + return handleException("clearSessions", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/admin/memory") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"jvmFreeMemory\": 17428920,\n \"jvmMaxMemory\": 4294967296,\n \"jvmTotalMemory\": 78249984\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get information about server memory usage. ", description = "Get information about the JVM memory usage of the server running RSE API. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminGetMemoryUsage(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = AdminAPIImpl.adminGetMemoryUsage(session); + } catch (Exception e) { + return handleException("getMemoryUsage", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/admin/environment") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"rseapiBasepath\": \"rseapi\",\n \"rseapiHostname\": \"UT30P44\",\n \"rseapiPort\": 2012,\n \"rseapiVersion\": \"1.0.6\",\n \"osName\": \"OS/400\",\n \"osVersion\": \"V7R5M0\",\n \"javaVersion\": \"1.8.0_351\"\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get information about server environment. ", description = "Get information about server environment, such as host, operating system, Java version, and port. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response adminGetEnvironment(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = AdminAPIImpl.adminGetEnvironment(session); + } catch (Exception e) { + return handleException("getEnvironment", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/CLCommandRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/CLCommandRESTService.java new file mode 100644 index 0000000..4651cc6 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/CLCommandRESTService.java @@ -0,0 +1,85 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.CLCommandAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "CL Command Services", description = "CL Command Services provide APIs for running CL commands. ")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class CLCommandRESTService extends RESTService { + + @Path("/v1/cl") + @PUT + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Command(s) issued successfully, error(s) may have occurred.", content = {@Content(mediaType = "application/json", example = "{\n \"totalIssued\": 2,\n \"totalSuccesses\": 1,\n \"totalFailures\": 1,\n \"commandOutputList\": [\n {\n \"success\": false,\n \"command\": \"qsys/crtlib lib1\",\n \"output\": [\n \"CPF2111: Library LIB1 already exists. \"\n ]\n },\n {\n \"success\": true,\n \"command\": \"qsys/crtsrcpf lib1/qrpglesrc\",\n \"output\": [\n \"CPC7301: File QRPGLESRC created in library LIB1. \"\n ]\n }\n ]\n}")}), @APIResponse(responseCode = "204", description = "All command(s) issued successfully, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Run one or more CL commands on the server.", description = "Run one or more CL commands on the server. If a command fails, any messages relating to the error is returned. If the command succeeds, no data is returned. By default, a response payload will be returned if an error occurs that will include the \nfirst level message text. If you want the full message details, set the includeMessageHelpText property to true. If you \nwant messages returned in all cases, set the includeMessageOnSuccess property to true.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response clRunCLCommand(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "List of CL commands to run. If more than one CL command is to be run, you can indicate whether all commands should be run even if an error is encountered while running a CL command.", required = false, content = {@Content(schema = @Schema(implementation = CLCommandAPIImpl.RSEAPI_CLCommands.class), mediaType = "application/json", example = "{\n \"continueOnError\": true,\n \"includeMessageOnSuccess\": true,\n \"includeMessageHelpText\": false,\n \"clCommands\": [\n \"qsys/crtlib lib1\",\n \"qsys/crtsrcpf lib1/qrpglesrc\"\n ]\n}")}) CLCommandAPIImpl.RSEAPI_CLCommands clCommands) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + CLCommandAPIImpl.CLCommandOperationResult output = null; + try { + session = getSession(req, authorization, true); + output = CLCommandAPIImpl.clRunCLCommand(session, clCommands); + payload = output.toJSON(); + } catch (Exception e) { + return handleException("runCLCommand", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + if (payload != null) + return Response.status(Response.Status.OK).entity(payload).build(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/cl/{commandname}") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"definition\": \"\"\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Retrieves the command definition for the specified CL command. ", description = "Retrieves the command definition for the specified CL command. The command definition is returned as an XML document. The generated command information XML source is called Command Definition Markup Language or CDML. See the Document Type Definition (DTD) in /QIBM/XML/DTD/QcdCLCmd.dtd for the definition of the CDML tag language returned by this API.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response clGetCommandDefinition(@Context HttpServletRequest req, @HeaderParam("Authorization") String authorization, @Parameter(description = "The library name. Valid values are a specific name, or one of the following special values: \n- \\*CURLIB - The current library is searched. \n- \\*LIBL - The library list is searched. This is the default.", required = false) @DefaultValue("*LIBL") @QueryParam("library") String library, @Parameter(description = "Boolean indicating whether case should be ignored. The default value is 'true'. If set to 'false', the library and command name will not be uppercased.", required = false) @DefaultValue("true") @QueryParam("ignorecase") boolean ignorecase, @Parameter(description = "The CL command name.", required = true) @PathParam("commandname") String commandName) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = CLCommandAPIImpl.clGetCommandDefinition(session, commandName, library, ignorecase); + } catch (Exception e) { + return handleException("getCommandDefinition", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/DatabaseRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/DatabaseRESTService.java new file mode 100644 index 0000000..0b16a1f --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/DatabaseRESTService.java @@ -0,0 +1,193 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.api.jaxrs.util.ResponseFormatter; +import dev.alexzaw.rest4i.database.DatabaseManager; +import dev.alexzaw.rest4i.database.QueryResult; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import java.sql.SQLException; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +/** + * REST service for database operations with multiple output format support. + * Follows IBM RSE API patterns and delegates to the integration service implementation. + * + * @author alexzaw + */ +@Path("/") +@Tags({@Tag(name = "Database Services", description = "Database query services with multiple output format support including JSON, XML, CSV, HTML, and Excel formats.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class DatabaseRESTService extends RESTService { + public static final String COPYRIGHT = "Licensed Materials - Property of IBM, 5770-SS1 (C) Copyright IBM Corp. 2021, 2021. All rights reserved. US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP Schedule Contract with IBM Corp."; + + // Use the database manager directly + private final DatabaseManager databaseManager = new DatabaseManager(); + + /** + * Database Query Request DTO - mirrors the delegate's structure + */ + public static class DatabaseQueryRequest { + public String sql; + public String format = "json"; + public boolean treatWarningsAsErrors = false; + public boolean alwaysReturnSQLStateInformation = false; + } + + @Path("/v1/database/query") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8", "application/xml; charset=utf-8", + "text/csv; charset=utf-8", "text/html; charset=utf-8", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "SQL query executed successfully.", + content = {@Content(mediaType = "application/json")}), + @APIResponse(responseCode = "400", description = "Bad request - invalid SQL or parameters."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "403", description = "Forbidden - dangerous SQL detected."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Execute SQL query with multiple output formats", + description = "Execute a SQL query against the IBM i database with support for multiple output formats including JSON, XML, CSV, HTML, and Excel.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response executeQuery(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "Output format for results (json, xml, csv, html, excel)") + @QueryParam("format") String format, + @RequestBody(description = "The SQL query and formatting options.", required = true, + content = {@Content(schema = @Schema(implementation = DatabaseQueryRequest.class))}) + DatabaseQueryRequest queryRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (queryRequest == null || queryRequest.sql == null || queryRequest.sql.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"SQL query is required\"}") + .type("application/json") + .build(); + } + + // Validate the SQL query for safety + if (!databaseManager.validateQuery(queryRequest.sql)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("{\"error\": \"Dangerous SQL operation detected\"}") + .type("application/json") + .build(); + } + + // Use format from query param if provided, otherwise use request format + String outputFormat = (format != null) ? format : queryRequest.format; + + // Execute the query using DatabaseManager + QueryResult result = databaseManager.executeQuery(session, queryRequest.sql); + + // Format and return the result + return ResponseFormatter.formatQueryResult( + result, + outputFormat, + queryRequest.treatWarningsAsErrors, + queryRequest.alwaysReturnSQLStateInformation + ); + + } catch (Exception e) { + return handleException("executeQuery", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/database/query") + @GET + @Produces({"application/json; charset=utf-8", "application/xml; charset=utf-8", + "text/csv; charset=utf-8", "text/html; charset=utf-8", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "SQL query executed successfully."), + @APIResponse(responseCode = "400", description = "Bad request - invalid SQL or parameters."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "403", description = "Forbidden - dangerous SQL detected."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Execute SQL query via GET with multiple output formats", + description = "Execute a SQL query via GET request with support for multiple output formats.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response executeQueryGet(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "The SQL query to execute.", required = true) + @QueryParam("sql") String sql, + @Parameter(description = "Output format: json, xml, csv, html, excel") + @QueryParam("format") String format) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (sql == null || sql.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"SQL query parameter is required\"}") + .type("application/json") + .build(); + } + + // Validate the SQL query for safety + if (!databaseManager.validateQuery(sql)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("{\"error\": \"Dangerous SQL operation detected\"}") + .type("application/json") + .build(); + } + + // Execute the query using DatabaseManager + QueryResult result = databaseManager.executeQuery(session, sql); + + // Format and return the result + return ResponseFormatter.formatQueryResult( + result, + format, + false, // treatWarningsAsErrors defaults to false for GET + false // alwaysReturnSQLStateInformation defaults to false for GET + ); + + } catch (Exception e) { + return handleException("executeQueryGet", e); + } finally { + releaseSession(session); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/FileOperationsRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/FileOperationsRESTService.java new file mode 100644 index 0000000..31b7e06 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/FileOperationsRESTService.java @@ -0,0 +1,420 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.alexzaw.rest4i.fileops.SMBFileManager; +import jcifs.CIFSContext; +import jcifs.smb.SmbFileInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +/** + * REST service for file operations with SMB/CIFS support. + * Follows IBM RSE API patterns and delegates to the integration service implementation. + * + * @author alexzaw + */ +@Path("/") +@Tags({@Tag(name = "File Operations Services", description = "SMB/CIFS file operations including list, upload, download, zip, and existence checking for network file shares.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class FileOperationsRESTService extends RESTService { + public static final String COPYRIGHT = "Licensed Materials - Property of IBM, 5770-SS1 (C) Copyright IBM Corp. 2021, 2021. All rights reserved. US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP Schedule Contract with IBM Corp."; + + // Use the SMB file manager directly + private final SMBFileManager fileManager = new SMBFileManager(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * File Operation Request DTO - mirrors the delegate's structure + */ + public static class FileOperationRequest { + public String path; + public String domain; + public String username; + public String password; + } + + /** + * Upload Request DTO - mirrors the delegate's structure + */ + public static class UploadRequest { + public String folder; + public String filename; + public String domain; + public String username; + public String password; + public byte[] fileContent; + } + + @Path("/v1/files/list") + @GET + @Produces({MediaType.APPLICATION_JSON}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "Directory listing successful.", + content = {@Content(mediaType = MediaType.APPLICATION_JSON)}), + @APIResponse(responseCode = "400", description = "Bad request - path parameter required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "404", description = "Directory not found."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "List files and directories", + description = "Lists files and directories in the specified SMB path.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response listFiles(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "The SMB path to list.", required = true) + @QueryParam("path") String path, + @Parameter(description = "SMB domain") + @QueryParam("domain") String domain, + @Parameter(description = "SMB username") + @QueryParam("username") String username, + @Parameter(description = "SMB password") + @QueryParam("password") String password) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (path == null || path.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Path parameter is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Get authentication context + CIFSContext auth = fileManager.getAuthContext(domain, username, password); + + // List files + List> fileList = fileManager.listFiles(path, auth); + + // Transform file list to match API contract + List> transformedFiles = new ArrayList<>(); + for (Map file : fileList) { + Map transformedFile = new HashMap<>(); + transformedFile.put("name", file.get("fileName")); + transformedFile.put("size", Integer.parseInt(file.get("size"))); + transformedFile.put("isDirectory", Boolean.parseBoolean(file.get("isDirectory"))); + transformedFiles.add(transformedFile); + } + + // Format response to match scalar.json API contract + Map data = new HashMap<>(); + data.put("path", path); + data.put("files", transformedFiles); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("data", data); + + return Response.ok(objectMapper.writeValueAsString(response)) + .type(MediaType.APPLICATION_JSON) + .build(); + + } catch (Exception e) { + return handleException("listFiles", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/files/download") + @GET + @Produces({MediaType.APPLICATION_OCTET_STREAM}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "File downloaded successfully."), + @APIResponse(responseCode = "400", description = "Bad request - path parameter required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "404", description = "File not found."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Download a file", + description = "Downloads a file from the specified SMB path.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response downloadFile(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "The SMB path to the file.", required = true) + @QueryParam("path") String path, + @Parameter(description = "SMB domain") + @QueryParam("domain") String domain, + @Parameter(description = "SMB username") + @QueryParam("username") String username, + @Parameter(description = "SMB password") + @QueryParam("password") String password) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (path == null || path.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Path parameter is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Get authentication context + CIFSContext auth = fileManager.getAuthContext(domain, username, password); + + // Read file content + try (SmbFileInputStream fileStream = fileManager.readFile(path, auth)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = fileStream.read(buffer)) != -1) { + baos.write(buffer, 0, bytesRead); + } + + // Extract filename from path + String fileName = path.substring(path.lastIndexOf('/') + 1); + if (fileName.isEmpty()) { + fileName = "download"; + } + + return Response.ok(baos.toByteArray()) + .type(MediaType.APPLICATION_OCTET_STREAM) + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .build(); + } + + } catch (Exception e) { + return handleException("downloadFile", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/files/upload") + @POST + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "File uploaded successfully."), + @APIResponse(responseCode = "400", description = "Bad request - missing required parameters."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Upload a file", + description = "Uploads a file to the specified SMB path.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response uploadFile(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @RequestBody(description = "Upload request with file data.", required = true, + content = {@Content(schema = @Schema(implementation = UploadRequest.class))}) + UploadRequest uploadRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (uploadRequest == null || uploadRequest.folder == null || uploadRequest.filename == null || uploadRequest.fileContent == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Folder, filename, and fileContent are required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Get authentication context + CIFSContext auth = fileManager.getAuthContext(uploadRequest.domain, uploadRequest.username, uploadRequest.password); + + // Convert byte array to input stream + try (InputStream inputStream = new ByteArrayInputStream(uploadRequest.fileContent)) { + fileManager.uploadFile(uploadRequest.folder, uploadRequest.filename, inputStream, auth); + } + + // Format response + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "File uploaded successfully"); + response.put("folder", uploadRequest.folder); + response.put("filename", uploadRequest.filename); + + return Response.ok(objectMapper.writeValueAsString(response)) + .type(MediaType.APPLICATION_JSON) + .build(); + + } catch (Exception e) { + return handleException("uploadFile", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/files/zip") + @GET + @Produces({"application/zip"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "Directory zipped successfully."), + @APIResponse(responseCode = "400", description = "Bad request - path parameter required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "404", description = "Directory not found."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Zip directory contents", + description = "Creates a ZIP archive of all files in the specified directory.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response zipDirectory(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "The SMB path to the directory.", required = true) + @QueryParam("path") String path, + @Parameter(description = "SMB domain") + @QueryParam("domain") String domain, + @Parameter(description = "SMB username") + @QueryParam("username") String username, + @Parameter(description = "SMB password") + @QueryParam("password") String password) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (path == null || path.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Path parameter is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Get authentication context + CIFSContext auth = fileManager.getAuthContext(domain, username, password); + + // Create ZIP archive + ByteArrayOutputStream zipStream = new ByteArrayOutputStream(); + fileManager.zipDirectory(path, zipStream, auth); + + // Extract directory name from path for filename + String dirName = path.substring(path.lastIndexOf('/') + 1); + if (dirName.isEmpty()) { + dirName = "archive"; + } + + return Response.ok(zipStream.toByteArray()) + .type("application/zip") + .header("Content-Disposition", "attachment; filename=\"" + dirName + ".zip\"") + .build(); + + } catch (Exception e) { + return handleException("zipDirectory", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/files/exists") + @GET + @Produces({MediaType.APPLICATION_JSON}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "Existence check completed."), + @APIResponse(responseCode = "400", description = "Bad request - path parameter required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Check if file or directory exists", + description = "Checks whether a file or directory exists at the specified SMB path.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response checkExists(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @Parameter(description = "The SMB path to check.", required = true) + @QueryParam("path") String path, + @Parameter(description = "SMB domain") + @QueryParam("domain") String domain, + @Parameter(description = "SMB username") + @QueryParam("username") String username, + @Parameter(description = "SMB password") + @QueryParam("password") String password) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (path == null || path.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Path parameter is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Get authentication context + CIFSContext auth = fileManager.getAuthContext(domain, username, password); + + // Check if file/directory exists + boolean exists = fileManager.exists(path, auth); + + // Format response + Map response = new HashMap<>(); + response.put("success", true); + response.put("path", path); + response.put("exists", exists); + + return Response.ok(objectMapper.writeValueAsString(response)) + .type(MediaType.APPLICATION_JSON) + .build(); + + } catch (Exception e) { + return handleException("checkExists", e); + } finally { + releaseSession(session); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/HealthRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/HealthRESTService.java new file mode 100644 index 0000000..41862c9 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/HealthRESTService.java @@ -0,0 +1,38 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.Consumes; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.json.Json; +import javax.json.JsonObject; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; + +@Path("/") +@Tags({@Tag(name = "Health", description = "Simple health/ready checks.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class HealthRESTService extends RESTService { + + @GET + @Path("/v1/health/ping") + @Operation(summary = "Ping", description = "Lightweight liveness check.") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Service is alive") + }) + @SecurityRequirement(name = "basicAuth") + public Response ping() { + JsonObject json = Json.createObjectBuilder() + .add("ok", true) + .build(); + + return Response.ok(json).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/IFSRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/IFSRESTService.java new file mode 100644 index 0000000..043f328 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/IFSRESTService.java @@ -0,0 +1,131 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.IFSAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "IFS Services", description = "Integrated File System (IFS) Services provide APIs for accessing objects in a way that is like personal computer and UNIX operating systems. This includes listing objects in directories, reading from files, and writing to files.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class IFSRESTService extends RESTService { + + @Path("/v1/ifs/list") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"objects\": [\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCLSRC.FILE\",\n \"description\": \"\\\"test\\\"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QRPGLESRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QSQDSRC.FILE\",\n \"description\": \"SQL PROCEDURES\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QSRVSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QWOBJ.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QAUDJR0043.JRNRCV\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"\"\n }\n ]\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Gets a list of objects in the specified path.", description = "Gets a list of objects in the specified path. The information returned includes the name, whether object is a directory, the description, and the object subtype. For objects that are not in the QSYS.LIB file system, any part of the path may contain an asterisk (\\*) , which is a wildcard that means zero or more instances of any character. For example, a path of '/tmp/am\\*1.txt' will return all objects in directory '/tmp' that have names that begin with 'am' and end with '1.txt'. For QSYS.LIB objects, only generic names may be specified in any part of the path. A generic name is a character string that contains one or more characters followed by an asterisk. For example, '/qsys.lib/am\\*.lib' will return all libraries that have names that start with 'am'. Another example is ‘/qsys.lib/am\\*.\\*’, which would return all objects that start with ‘am’. This would be equivalent to specifying a path of ‘/qsys.lib/am\\*'.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response ifsListDir(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "The working directory. For example, /u/IBM/test", required = true) @QueryParam("path") String path, @Parameter(description = "Subtype of objects to return. Valid values include a specific object type (*LIB, *FILE, *PGM, *OUTQ, etc.) or *ALL. Note that many file system objects do not have a subtype. For example, any Root, QOpenSys or UDFS object. ", required = false) @QueryParam("subtype") String subtype, @Parameter(description = "Whether to show hidden files.", required = false) @DefaultValue("false") @QueryParam("includehidden") boolean includehidden) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = IFSAPIImpl.ifsListDir(session, path, subtype, includehidden); + } catch (Exception e) { + return handleException("getFiles", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/ifs/{path}") + @GET + @Consumes({"*/*"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"ccsid\": 819,\n \"content\": \"Hello world\\n\"\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get the content of a file.", description = "Get the content of a file. The content is returned in a JSON object. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response ifsGetFileContent(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "Whether to return checksum for the file.", required = false) @HeaderParam("ETag") boolean etag, @Parameter(description = "Path to file for which the data is to be read.", required = true) @PathParam("path") String path) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + IFSAPIImpl.IFSFileOperationResult output = null; + try { + session = getSession(req, authorization, true); + output = IFSAPIImpl.ifsGetFileContent(session, path, etag); + payload = output.fileContentInternal_.toJSON(); + } catch (Exception e) { + return handleException("getFileContent", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + if (output.etagHeader_ != null) + return Response.status(Response.Status.OK).header("ETag", output.etagHeader_).entity(payload).build(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/ifs/{path}") + @PUT + @Consumes({"application/json", "text/plain"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Successful request, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "412", description = "Precondition failed."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Write a string to a file.", description = "Write a string to a file. The file must exist and its contents will be replaced by the string specified in the request.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response ifsPutFileContent(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "If the If-Match HTTP header is passed, RSE API will check to see if the Etag (MD5 hash of the object content) matches the provided Etag value. If this value matches, the operation will proceed. If the match fails, the system will return a 412 (Precondition Failed) error.", required = false) @HeaderParam("If-Match") String matchEtag, @Parameter(description = "Path to file in which the data will be written.", required = true) @PathParam("path") String path, @RequestBody(description = "File content.", required = true, content = {@Content(schema = @Schema(implementation = IFSAPIImpl.RSEAPI_FileContent.class), mediaType = "application/json", example = "{\n \"content\": \"some data that will be written to file.\"\n}"), @Content(mediaType = "text/plain", example = "some data that will be written to file.\n")}) IFSAPIImpl.RSEAPI_FileContent fileContentInfo) { + CommonUtil._serviceContext.set(req); + Session session = null; + String newChecksum = null; + try { + session = getSession(req, authorization, true); + newChecksum = IFSAPIImpl.ifsPutFileContent(session, path, matchEtag, fileContentInfo.content); + } catch (Exception e) { + return handleException("putFileContent", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + if (matchEtag != null && newChecksum != null) + return Response.status(Response.Status.NO_CONTENT).header("ETag", newChecksum).build(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/ifs/{path}/info") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"path\": \"/qsys.lib/user1.lib\",\n \"description\": \"user1's lib\",\n \"isDir\": true,\n \"subType\": \"PROD\",\n \"owner\": \"USER1\",\n \"ccsid\": 37,\n \"lastModified\": 1680559633,\n \"size\": 401408,\n \"recordLength\": -1,\n \"numberOfRecords\": -1\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Returns information about the object referenced by the path.", description = "Returns information about the object referenced by the path. The information returned includes the name, whether object is a directory, the description, the object subtype, CCSID, size, last modified timestamp, and object subtype.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response ifsGetFileInfo(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "Path to object for which information is to be returned.", required = true) @PathParam("path") String path) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = IFSAPIImpl.ifsGetFileInfo(session, path); + } catch (Exception e) { + return handleException("getFileInfo", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/PDFOperationsRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/PDFOperationsRESTService.java new file mode 100644 index 0000000..b737813 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/PDFOperationsRESTService.java @@ -0,0 +1,334 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.pdf.PDFManager; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +/** + * REST service for PDF operations including conversion, merge, rotate, and unprotect. + * Follows IBM RSE API patterns and delegates to the integration service implementation. + * + * @author alexzaw + */ +@Path("/") +@Tags({@Tag(name = "PDF Operations Services", description = "PDF operations including HTML to PDF conversion, merge, rotate, and unprotect functionality with Base64 encoding support.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class PDFOperationsRESTService extends RESTService { + public static final String COPYRIGHT = "Licensed Materials - Property of IBM, 5770-SS1 (C) Copyright IBM Corp. 2021, 2021. All rights reserved. US Government Users Restricted Rights - Use, duplication or disclosure restricted by GSA ADP Schedule Contract with IBM Corp."; + + // Use the PDF manager directly + private final PDFManager pdfManager = new PDFManager(); + + /** + * HTML to PDF Conversion Request DTO - matches scalar.json API contract + */ + public static class ConvertRequest { + public String html; + public String size = "A4"; + public String orientation = "portrait"; + } + + /** + * PDF Merge Request DTO - mirrors the delegate's structure + */ + public static class MergeRequest { + public List pdfFiles; + } + + /** + * PDF Rotation Request DTO - mirrors the delegate's structure + */ + public static class RotateRequest { + public String pdfFile; + public int rotation = 90; + } + + /** + * PDF Unprotect Request DTO - mirrors the delegate's structure + */ + public static class UnprotectRequest { + public String pdfFile; + } + + @Path("/v1/pdf/convert") + @POST + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({"application/pdf"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "HTML converted to PDF successfully.", + content = {@Content(mediaType = "application/pdf")}), + @APIResponse(responseCode = "400", description = "Bad request - htmlContent is required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Convert HTML to PDF", + description = "Converts HTML content to PDF format with customizable page size and orientation.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response convertHtmlToPdf(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @RequestBody(description = "HTML conversion request.", required = true, + content = {@Content(schema = @Schema(implementation = ConvertRequest.class))}) + ConvertRequest convertRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (convertRequest == null || convertRequest.html == null || convertRequest.html.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"HTML content is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Use default values if not provided + String pageSize = (convertRequest.size != null) ? convertRequest.size : "A4"; + String orientation = (convertRequest.orientation != null) ? convertRequest.orientation : "portrait"; + + // Convert HTML to PDF + ByteArrayOutputStream pdfStream = pdfManager.convertHtmlToPdf( + convertRequest.html, pageSize, orientation); + + return Response.ok(pdfStream.toByteArray()) + .type("application/pdf") + .header("Content-Disposition", "attachment; filename=\"converted.pdf\"") + .build(); + + } catch (Exception e) { + return handleException("convertHtmlToPdf", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/pdf/merge") + @POST + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({"application/pdf"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "PDFs merged successfully.", + content = {@Content(mediaType = "application/pdf")}), + @APIResponse(responseCode = "400", description = "Bad request - at least one PDF file is required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Merge PDF files", + description = "Merges multiple PDF files into a single PDF document.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response mergePdfs(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @RequestBody(description = "PDF merge request with Base64 encoded PDF files.", required = true, + content = {@Content(schema = @Schema(implementation = MergeRequest.class))}) + MergeRequest mergeRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (mergeRequest == null || mergeRequest.pdfFiles == null || mergeRequest.pdfFiles.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"At least one PDF file is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Convert Base64 encoded PDF files to input streams + List pdfStreams = new ArrayList<>(); + try { + for (String base64Pdf : mergeRequest.pdfFiles) { + if (base64Pdf == null || base64Pdf.trim().isEmpty()) { + continue; + } + byte[] pdfBytes = Base64.getDecoder().decode(base64Pdf); + pdfStreams.add(new ByteArrayInputStream(pdfBytes)); + } + + if (pdfStreams.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"No valid PDF files provided\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Merge PDFs + ByteArrayOutputStream mergedStream = pdfManager.mergePdfs(pdfStreams); + + return Response.ok(mergedStream.toByteArray()) + .type("application/pdf") + .header("Content-Disposition", "attachment; filename=\"merged.pdf\"") + .build(); + } finally { + // Close input streams + for (InputStream stream : pdfStreams) { + try { + stream.close(); + } catch (IOException e) { + // Log but don't fail the response + } + } + } + + } catch (Exception e) { + return handleException("mergePdfs", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/pdf/rotate") + @POST + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({"application/pdf"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "PDF rotated successfully.", + content = {@Content(mediaType = "application/pdf")}), + @APIResponse(responseCode = "400", description = "Bad request - PDF file is required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Rotate PDF pages", + description = "Rotates all pages in a PDF document by the specified angle.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response rotatePdf(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @RequestBody(description = "PDF rotation request with Base64 encoded PDF file.", required = true, + content = {@Content(schema = @Schema(implementation = RotateRequest.class))}) + RotateRequest rotateRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (rotateRequest == null || rotateRequest.pdfFile == null || rotateRequest.pdfFile.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"PDF file is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Decode Base64 PDF file + byte[] pdfBytes = Base64.getDecoder().decode(rotateRequest.pdfFile); + + // Use default rotation if not provided + int rotation = (rotateRequest.rotation > 0) ? rotateRequest.rotation : 90; + + // Rotate PDF + try (InputStream pdfStream = new ByteArrayInputStream(pdfBytes)) { + ByteArrayOutputStream rotatedStream = pdfManager.rotatePdf(pdfStream, rotation); + + return Response.ok(rotatedStream.toByteArray()) + .type("application/pdf") + .header("Content-Disposition", "attachment; filename=\"rotated.pdf\"") + .build(); + } + + } catch (Exception e) { + return handleException("rotatePdf", e); + } finally { + releaseSession(session); + } + } + + @Path("/v1/pdf/unprotect") + @POST + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({"application/pdf"}) + @APIResponses({ + @APIResponse(responseCode = "200", description = "PDF unprotected successfully.", + content = {@Content(mediaType = "application/pdf")}), + @APIResponse(responseCode = "400", description = "Bad request - PDF file is required."), + @APIResponse(responseCode = "401", description = "Unauthorized request."), + @APIResponse(responseCode = "500", description = "Internal server error.") + }) + @Operation(summary = "Remove PDF protection", + description = "Removes protection from a PDF document by converting it to images and back to PDF.") + @SecurityRequirements({ + @SecurityRequirement(name = "bearerHttpAuthentication"), + @SecurityRequirement(name = "basicHttpAuthentication") + }) + public Response unprotectPdf(@Context HttpServletRequest req, + @Parameter(description = "The authorization HTTP header.") + @HeaderParam("Authorization") String authorization, + @RequestBody(description = "PDF unprotection request with Base64 encoded PDF file.", required = true, + content = {@Content(schema = @Schema(implementation = UnprotectRequest.class))}) + UnprotectRequest unprotectRequest) { + + CommonUtil._serviceContext.set(req); + Session session = null; + + try { + session = getSession(req, authorization, true); + + if (unprotectRequest == null || unprotectRequest.pdfFile == null || unprotectRequest.pdfFile.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"PDF file is required\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // Decode Base64 PDF file + byte[] pdfBytes = Base64.getDecoder().decode(unprotectRequest.pdfFile); + + // Unprotect PDF + try (InputStream pdfStream = new ByteArrayInputStream(pdfBytes)) { + ByteArrayOutputStream unprotectedStream = pdfManager.unprotectPdf(pdfStream); + + return Response.ok(unprotectedStream.toByteArray()) + .type("application/pdf") + .header("Content-Disposition", "attachment; filename=\"unprotected.pdf\"") + .build(); + } + + } catch (Exception e) { + return handleException("unprotectPdf", e); + } finally { + releaseSession(session); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/QSYSRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/QSYSRESTService.java new file mode 100644 index 0000000..61850d8 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/QSYSRESTService.java @@ -0,0 +1,56 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.Consumes; +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.QSYSAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "QSYS Services", description = "QSYS Services provide APIs for accessing QSYS objects. ")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class QSYSRESTService extends RESTService { + + @Path("/v1/qsys/search/{objectName}") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"objects\": [\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/AXISLIBS.FILE\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"SAVF\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/DEALER.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/FLGHT400.FILE\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"SAVF\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/IWSDB1.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCLSRC.FILE\",\n \"description\": \"\\\"test\\\"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n }\n ]\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Returns a list of QSYS.LIB objects that match the search criteria.", description = "Returns a list of QSYS.LIB objects that match the search criteria. The filter is the object name, and may be a value of \\*ALL, in which case all objects that match the search criteria is returned, or a generic name. A generic name is a character string that contains one or more characters followed by an asterisk (\\*). If a generic name is specified, all objects that have names with the same prefix as the generic name are returned.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response qsysGetQsysObjects(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "The library or set of libraries that are searched for objects. Valid values are a specific name, or one of the following special values: \n- \\*ALL - All libraries are searched. \n- \\*ALLUSR - All user libraries are searched. \n- \\*CURLIB - The current library is searched. \n- \\*LIBL - The library list is searched. \n- \\*USRLIBL - The user portion of the library list is searched. ", required = false) @DefaultValue("*USRLIBL") @QueryParam("objectLibrary") String objectLibrary, @Parameter(description = "The type of object to search. Valid values include: \\*FILE, \\*PGM, \\*LIB, etc., or \\*ALL. ", required = false) @DefaultValue("*ALL") @QueryParam("objectType") String objectType, @Parameter(description = "Object subtype. For example, PF-SRC, PF-DTA, SAVF, etc., or \\*ALL. Note that not all objects have subtypes.", required = false) @DefaultValue("*ALL") @QueryParam("objectSubtype") String objectSubtype, @Parameter(description = "The member name to match for objects of type PF-SRC or PF-DTA. Valid values are a specific name, or an extended generic name where the asterisk (\\*) may be placed in any part of the name, or \\*ALL. Note that members are not searched for unless the object subtype that starts with the prefix PF. For example, PF, or PF-SRC.", required = false) @DefaultValue("") @QueryParam("memberName") String memberName, @Parameter(description = "The member type to match for members of objects of type PF-SRC or PF-DTA. Valid values are a specific name, such as C, CBLLE, RPGLE, SQLRPGLE, etc., or \\*ALL. ", required = false) @DefaultValue("*ALL") @QueryParam("memberType") String memberType, @Parameter(description = "The object name. Valid values are a specific name, a generic name (for example, AM\\*), or one of the following special values: \n- \\*ALL - All object names are searched. \n- \\*ALLUSR - All objects that are libraries in QSYS or the library list are searched. The object library must either be \\*ALL, \\*LIBL or QSYS. The object type must be \\*LIB. A list of user libraries is returned. \n", required = true) @PathParam("objectName") String objectName) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + QSYSAPIImpl.QSYSOperationResult output = QSYSAPIImpl.qsysGetQsysObjects(session, objectName, objectLibrary, objectType, objectSubtype, memberName, memberType); + payload = output.objectList_.toJSON(); + } catch (Exception e) { + return handleException("getQsysObjects", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RESTService.java new file mode 100644 index 0000000..6640fbb --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RESTService.java @@ -0,0 +1,124 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import dev.alexzaw.rest4i.api.SessionAPIImpl; +import dev.alexzaw.rest4i.exception.RestWAForbiddenException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedException; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.session.SessionRepository; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import java.io.IOException; +import java.util.Base64; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +public abstract class RESTService { + + protected static final String VERSION_V1 = "/v1"; + + protected static final String VERSION_V1_ADMIN = "/v1/admin"; + + protected static final String VERSION_V1_CL = "/v1/cl"; + + protected static final String VERSION_V1_IFS = "/v1/ifs"; + + protected static final String VERSION_V1_QSYS = "/v1/qsys"; + + protected static final String VERSION_V1_INFO = "/v1/info"; + + protected static final String VERSION_V1_SESSION = "/v1/session"; + + protected static final String VERSION_V1_SQL = "/v1/sql"; + + protected static final String VERSION_V1_SECURITY = "/v1/security"; + + protected static final String VERSION_V1_DATABASE = "/v1/database"; + + protected static final String VERSION_V1_FILES = "/v1/files"; + + protected static final String VERSION_V1_PDF = "/v1/pdf"; + + protected static Response handleException(String classSignature, Exception e, int httpStatus, String s) { + Response errorResponse = null; + if (e instanceof WebApplicationException) { + errorResponse = ((WebApplicationException)e).getResponse(); + } else { + String errorMsg = e.getMessage(); + AsyncLogger.traceException(classSignature, e, s, new Object[0]); + if (s != null && !s.trim().isEmpty()) + errorMsg = s; + errorResponse = (new RestWAInternalServerErrorException(errorMsg, new Object[0])).getResponse(); + CommonUtil._serviceContext.remove(); + } + return errorResponse; + } + + protected static Response handleException(String classSignature, Exception e) { + return handleException(classSignature, e, 500, ""); + } + + protected static Session getSession(HttpServletRequest req, String authorization, boolean allowBasicAuth) { + checkSecureConnection(req); + String token = null; + SessionAPIImpl.RSEAPI_LoginCredentials creds = null; + Session session = null; + if (authorization != null) + if (authorization.startsWith("Bearer")) { + token = authorization.replaceFirst("Bearer ", "").trim(); + session = SessionAPIImpl.getSession(token); + } else if (allowBasicAuth && authorization.startsWith("Basic")) { + String encodedUserPassword = authorization.replaceFirst("Basic ", "").trim(); + String usernameAndPassword = null; + try { + byte[] decodedBytes = Base64.getDecoder().decode(encodedUserPassword); + usernameAndPassword = new String(decodedBytes, "UTF-8"); + } catch (IOException e) { + AsyncLogger.traceDebug("getSession", "Base64 decoder error: " + e.getMessage(), new Object[0]); + } + int seperatorIndex; + if (usernameAndPassword != null && (seperatorIndex = usernameAndPassword.indexOf(':')) != -1) { + creds = new SessionAPIImpl.RSEAPI_LoginCredentials(); + creds.userid = usernameAndPassword.substring(0, seperatorIndex); + creds.password = usernameAndPassword.substring(seperatorIndex + 1); + session = SessionAPIImpl.sessionLogin(creds); + } + } + if (session == null) + throw new RestWAUnauthorizedException("User ID or password is not set or not valid.", new Object[0]); + session.lock(); + try { + session.setLastUsed(); + if (creds != null) + session.setSingleUse(true); + } finally { + session.unlock(); + } + return session; + } + + protected static void releaseSession(Session session) { + if (session != null) { + if (session.getSingleUse()) + SessionAPIImpl.sessionLogout(session); + session.unlock(); + } + } + + protected static void checkSecureConnection(HttpServletRequest req) { + if (GlobalProperties.useSecureConnections() && !req.isSecure()) + throw new RestWAForbiddenException("Resource must be accessed with a secure connection.", new Object[0]); + } + + @PostConstruct + private void restService_PostConstruct() {} + + @PreDestroy + private void restService_PreDestroy() { + SessionRepository.destroy(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEMessageBodyWriter.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEMessageBodyWriter.java new file mode 100644 index 0000000..7292684 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEMessageBodyWriter.java @@ -0,0 +1,68 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import com.ibm.as400.access.IFSFileInputStream; +import dev.alexzaw.rest4i.api.APIImpl; +import dev.alexzaw.rest4i.api.IFSAPIImpl; +import dev.alexzaw.rest4i.api.SQLAPIImpl; +import dev.alexzaw.rest4i.util.AsyncLogger; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.io.Writer; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.ext.MessageBodyWriter; +import javax.ws.rs.ext.Provider; + +@Provider +public class RSEMessageBodyWriter implements MessageBodyWriter { + + public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + return !(type != APIImpl.RSEResult.class && + type != IFSAPIImpl.IFSFileOperationResult.class && + type != SQLAPIImpl.SQLOperationResult.class); + } + + public long getSize(APIImpl.RSEResult rseResult, Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { + return -1L; + } + + public void writeTo(APIImpl.RSEResult rseResult, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, OutputStream out) throws IOException, WebApplicationException { + Writer writer = null; + IFSFileInputStream iStream = null; + int bytesRead = 0; + byte[] buffer = null; + try { + if (rseResult instanceof IFSAPIImpl.IFSFileOperationResult) { + IFSAPIImpl.IFSFileOperationResult filePayload = (IFSAPIImpl.IFSFileOperationResult)rseResult; + iStream = filePayload.iStream_; + buffer = new byte[131072]; + if (filePayload.fileContentInternal_ != null && filePayload.fileContentInternal_.content != null) { + AsyncLogger.traceDebug("MBWwriteTo", "Writing IFS string data", new Object[0]); + writer = new PrintWriter(out); + writer.write(filePayload.fileContentInternal_.content); + writer.flush(); + } else if (iStream != null) { + AsyncLogger.traceDebug("MBWwriteTo", "Writing IFS binary data", new Object[0]); + while ((bytesRead = iStream.read(buffer)) > 0) + out.write(buffer, 0, bytesRead); + out.flush(); + } + } else if (rseResult instanceof SQLAPIImpl.SQLOperationResult) { + AsyncLogger.traceDebug("MBWwriteTo", "Writing SQL string data", new Object[0]); + SQLAPIImpl.SQLOperationResult sqlResults = (SQLAPIImpl.SQLOperationResult)rseResult; + writer = new PrintWriter(out); + writer.write(sqlResults.toJSON()); + writer.flush(); + } + } finally { + try { + if (iStream != null) + iStream.close(); + } catch (Exception exception) {} + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEResourceConfig.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEResourceConfig.java new file mode 100644 index 0000000..9e4fb0c --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/RSEResourceConfig.java @@ -0,0 +1,39 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import java.util.HashSet; +import java.util.Set; +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; +import org.eclipse.microprofile.openapi.annotations.Components; +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; +import org.eclipse.microprofile.openapi.annotations.info.Info; +import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; + +@ApplicationPath("/api") +@OpenAPIDefinition(info = @Info(title = "Remote System Explorer API (RSE API) Documentation", description = "IBM Remote System Explorer API is a collection of REST APIs that allow a client to work with various components on an IBM i host system, including QSYS objects, IFS files, CL Commands, database operations, file operations, and PDF processing. Enhanced with integration services by alexzaw.", version = "1.0.8"), components = @Components(securitySchemes = {@SecurityScheme(securitySchemeName = "bearerHttpAuthentication", description = "Bearer token authentication.", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "Bearer [token]"), @SecurityScheme(securitySchemeName = "basicHttpAuthentication", description = "Basic authentication.", type = SecuritySchemeType.HTTP, scheme = "basic")})) +public class RSEResourceConfig extends Application { + + private final Set singletons = new HashSet(); + + public RSEResourceConfig() { + singletons.add(new AdminRESTService()); + singletons.add(new SessionRESTService()); + singletons.add(new CLCommandRESTService()); + singletons.add(new IFSRESTService()); + singletons.add(new QSYSRESTService()); + singletons.add(new ServerInfoRESTService()); + singletons.add(new SQLRESTService()); + singletons.add(new SecurityRESTService()); + singletons.add(new DatabaseRESTService()); + singletons.add(new FileOperationsRESTService()); + singletons.add(new PDFOperationsRESTService()); + singletons.add(new HealthRESTService()); + singletons.add(new RSEMessageBodyWriter()); +} +@Override + public Set getSingletons() { + return singletons; +} + +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SQLRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SQLRESTService.java new file mode 100644 index 0000000..721b664 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SQLRESTService.java @@ -0,0 +1,57 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.SQLAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "SQL Services", description = "SQL Services provide APIs associated with performing SQL operations. ")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SQLRESTService extends RESTService { + + @Path("/v1/sql") + @PUT + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "SQL statements(s) issued.", content = {@Content(mediaType = "application/json", example = "{\n \"resultSet\": [\n {\n \"CUSNUM\": 938472,\n \"LSTNAM\": \"Henning\",\n \"INIT\": \"G K\",\n \"STREET\": \"4859 Elm Ave\",\n \"CITY\": \"Dallas\",\n \"STATE\": \"TX\",\n \"ZIPCOD\": 75217,\n \"CDTLMT\": 5000,\n \"CHGCOD\": 3,\n \"BALDUE\": 37,\n \"CDTDUE\": 0\n },\n {\n \"CUSNUM\": 839283,\n \"LSTNAM\": \"Jones\",\n \"INIT\": \"B D\",\n \"STREET\": \"21B NW 135 St\",\n \"CITY\": \"Clay\",\n \"STATE\": \"NY\",\n \"ZIPCOD\": 13041,\n \"CDTLMT\": 400,\n \"CHGCOD\": 1,\n \"BALDUE\": 100,\n \"CDTDUE\": 0\n }\n ]\n}")}), @APIResponse(responseCode = "204", description = "SQL statement(s) issued successfully, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Run a SQL statement on the server.", description = "Run a SQL statement on the server. If SQL statement fails, any messages relating to the error is returned. \n\nSQL state information is returned only if errors are detected. You have the option of indicating whether the state information is returned on all responses. By default, if a SQL statement is run successfully and there is no result set to return, no data is returned in the response.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response runSQLStatements(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The SQL statement to be run on the server.", required = true, content = {@Content(schema = @Schema(implementation = SQLAPIImpl.RSEAPI_SQLRequest.class), mediaType = "application/json", example = "{\n \"alwaysReturnSQLStateInformation\": false,\n \"treatWarningsAsErrors\": false,\n \"sqlStatement\": \"select * from QIWS.QCUSTCDT\"\n}")}) SQLAPIImpl.RSEAPI_SQLRequest sqlRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + SQLAPIImpl.SQLOperationResult output = null; + try { + session = getSession(req, authorization, true); + output = SQLAPIImpl.runSQLStatement(session, sqlRequest); + } catch (Exception e) { + return handleException("runSQLStatements", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + if (!output.processResults()) + return Response.status(Response.Status.NO_CONTENT).build(); + return Response.status(Response.Status.OK).entity(output).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SecurityRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SecurityRESTService.java new file mode 100644 index 0000000..49f1326 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SecurityRESTService.java @@ -0,0 +1,323 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.SecurityAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "Security Services", description = "Security Services provide APIs relating to security, such as the mangement of digital certificates and the retrieval of TLS system information. \n\nAll the digital certificate management APIs require the Digital Certificate Manager, option 34 of the IBM i licensed program (5761-SS1), be installed. In addition, the authenticated user must have the *ALLOBJ and *SECADM special authorities.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SecurityRESTService extends RESTService { + + @Path("/v1/security/dcm/certstore/changepassword") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Change digital certificate store password.", description = "Change digital certificate store password. For system certificate stores of type CMS, if the current password is omitted, the system stash file will be used.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMChangeCertificateStorePassword(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to change a digital certificate store password.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertStoreChangePasswordRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": null,\n \"certStorePasswordNew\": \"myNewPassw0rd\",\n \"daysToExpiration\": 0\n}")}) SecurityAPIImpl.RSEAPI_DCMCertStoreChangePasswordRequest dcmResetCertStorePasswordRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMChangeCertificateStorePassword(session, dcmResetCertStorePasswordRequest); + } catch (Exception e) { + return handleException("securityDCMResetCertificateStorePassword", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/cert/list") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"certificates\": [\n {\n \"certAlias\": \"GLOBAL-MULTISAN\",\n \"commonName\": \"myserver.ibm.com\",\n \"type\": \"SERVER_CLIENT\",\n \"daysBeforeExpiration\": 1831,\n \"keyAlgorithm\": \"ECDSA\",\n \"keySize\": 256,\n \"keyStorageLocation\": \"SOFTWARE\",\n \"signatureAlgorithm\": \"ECDSA_SHA256\"\n },\n {\n \"certAlias\": \"LOCAL_CERTIFICATE_AUTHORITY_106F947K(5)\",\n \"commonName\": \"Local CA for myserver on July 17\",\n \"type\": \"CA\",\n \"daysBeforeExpiration\": 6807,\n \"keyAlgorithm\": \"ECDSA\",\n \"keySize\": 256,\n \"signatureAlgorithm\": \"ECDSA_SHA256\"\n }\n ]\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Retrieve a list of certificates in a certificate store.", description = "Retrieve a list of certificates in a certificate store. You can filter what is returned by alias, certificate type, days until expiration, and whether to include expired certificates. When filtering by alias, you can specify a generic alias for alias. A generic alias is a character string that contains one or more characters followed by an asterisk (\\*). If a generic alias is specified, all certificates that have an alias with the same prefix as the generic alias are returned. Note that if the daysUntilExpiration filter is specified, CSR certificates will not be returned in the response since CSR certificates do not expire.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMListCertificates(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to list certificates in a certificate store.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertListRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"filters\": { \n \"certAlias\": \"*\",\n \"certTypes\": [\"SERVER_CLIENT\", \"CA\", \"CSR\"],\n \"daysUntilExpiration\": 5000,\n \"excludeExpired\": false\n }}")}) SecurityAPIImpl.RSEAPI_DCMCertListRequest dcmCertListRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityDCMListCertificates(session, dcmCertListRequest); + } catch (Exception e) { + return handleException("securityDCMListCertificates", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/security/dcm/cert/export") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n\"certFormat\": \"PKCS12\",\n \"certData\": \"BASE64-BLOB\"\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Export a digital certificate.", description = "Export a digital certificate. Only server/client and CA certificates can be exported. Certificates can be exported in the DER, PEM, or PKCS12 formats. A server/client or CA certificate that is to include the private key must be exported in the PKCS12 format. CA certificates without private keys cannot be exported in the PKCS12 format. \n\nWhen exporting certificates in the PKCS12 format, a password for the exported certificate must be specified. \n\nThe certificate data in the response will be encoded in Base64, even for certificate data returned in the PEM format. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMExportCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to export a digital certificate.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertExportRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certFormat\": \"PKCS12\",\n \"certAlias\": \"mylabel\",\n \"certDataPassword\": \"myPassw0rd\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertExportRequest dcmCertExportRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityDCMExportCertificate(session, dcmCertExportRequest); + } catch (Exception e) { + return handleException("securityDCMExportCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/security/dcm/cert/import") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Import a digital certificate.", description = "Import a digital certificate. Only server/client or CA certificates can be imported. A certificate can be imported in the following formats: PKCS12, DER, or PEM. If the certificate to be imported includes a private key, then the PKCS12 format must be used. \n\nIf importing a CA certificate and the certificate includes a private key, the PKCS12 format must be used and the certificate type must be set to SERVER_CLIENT. When importing CA certificate that do not contain a private key, the PEM or DER format must be used. \n\nThe certificate data in the request must be encoded in Base64, which includes certificate data in the PEM format. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMImportCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to import a digital certificate. ", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertImportRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certType\": \"SERVER_CLIENT\",\n \"certFormat\": \"PKCS12\",\n \"certAlias\": \"mylabel\",\n \"certData\": \"BASE64-BLOB\",\n \"certDataPassword\": \"myPassw0rd\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertImportRequest dcmCertImportRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMImportCertificate(session, dcmCertImportRequest); + } catch (Exception e) { + return handleException("securityDCMImportCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/cert/info") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"alias\": \"UNIQUE-SAN\",\n \"trusted\": true, \"subject\": \"C=US,SP=Minnesota,O=IBM,CN=UniQue\", \"issuer\": \"C=US,SP=Any,O=IBM Web Administration for i,CN=mysystem_CERTIFICATE_AUTHORITY\", \"keyAlgorithm\": \"ECDSA\", \"keySize\": 256, \"hasPrivateKey\": true, \"signatureAlgorithm\": \"RSA_SHA256\", \"keyStorageLocation\": \"SOFTWARE\", \"serialNumber\": \"6526CFBC0601B8\", \"effectiveDate\": \"10/10/23 11:39:24\", \"expirationDate\": \"10/10/24 11:39:24\", \"subjectAlternativeNames\": [\n \"booboo.ibm.com\",\n \"9.9.9.9\"\n ],\n \"keyUsages\": [\n \"DIGITAL_SIGNATURE\",\n \"NONREPUDIATION\",\n \"KEY_ENCIPHERMENT\",\n \"KEY_AGREEMENT\"\n ]\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get detailed certificate information.", description = "Get detailed certificate information, such as subject, issuer, subject alternative names, and serial number.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMGetCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to get detailed information about a digital certificate.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certAlias\": \"mylabel\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertRequest dcmCertInfoRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityDCMGetCertificate(session, dcmCertInfoRequest); + } catch (Exception e) { + return handleException("getCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/security/dcm/cert/delete") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Delete a digital certificate.", description = "Delete a digital certificate.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMDeleteCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to delete a digital certificate.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertRequest.class), mediaType = "application/json", example = "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certAlias\": \"mylabel\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertRequest dcmCertDeleteRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMDeleteCertificate(session, dcmCertDeleteRequest); + } catch (Exception e) { + return handleException("deleteCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/appdef/list") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"appDefinitions\": [\n {\n \"appDefinitionID\": \"QIBM_OS400_QRW_SVR_DDM_DRDA\",\n \"appType\": \"SERVER\",\n \"description\": \"IBM i DDM/DRDA Server - TCP/IP\",\n \"certAliases\": [ ]\n }\n ]\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "List application definitions.", description = "Retrieve a list of application definitions. You can filter what is returned by application definition ID and application type. When filtering by application definition ID, you can specify a generic ID. A generic ID is a character string that contains one or more characters followed by an asterisk (\\*). If a generic ID is specified, all application definition IDs that have an ID with the same prefix as the generic ID are returned. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMAppidList(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "Application definition ID filter. ", required = false) @QueryParam("idFilter") String idFilter, @Parameter(description = "Application type filter. Possible values: SERVER, CLIENT, SERVER_CLIENT, OBJECT_SIGNING. If not specifed, all server and client application definitions are returned.", required = false) @QueryParam("typeFilter") String typeFilter) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityDCMAppidList(session, idFilter, typeFilter); + } catch (Exception e) { + return handleException("securityDCMAssignAppidCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/security/dcm/appdef/associate") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Associate digital certificates to an application definition.", description = "Associate digital certificates to an application definition. A maximum of 4 certificates can be specified. \n\nOn successful completion, the specified certificates will replace any pre-existing certificates associated with the application definition.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMAppDefAssociateCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to assign digital certificates to an application definition.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertAppDefAssociateRequest.class), mediaType = "application/json", example = "{\n \"appDefinitionID\": \"myappdef\",\n \"certAliases\": [\"mylabel1\",\"mylabel2\"]\n}")}) SecurityAPIImpl.RSEAPI_DCMCertAppDefAssociateRequest dcmCertAppDefIDRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMAppDefAssociateCertificate(session, dcmCertAppDefIDRequest); + } catch (Exception e) { + return handleException("securityDCMAssignAppidCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/appdef/disassociate") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Disassociate digital certificates from an application definition.", description = "Disassociate digital certificates from an application definition. All certificates associated with the application definition will be disassociated.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMAppDefDisassociateCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to disassociate digital certificates from an application definition.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertAppDefDisassociateRequest.class), mediaType = "application/json", example = "{\n \"appDefinitionID\": \"myappdef\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertAppDefDisassociateRequest dcmCertAppDefIDRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMAppDefDisassociateCertificate(session, dcmCertAppDefIDRequest); + } catch (Exception e) { + return handleException("securityDCMAppDefDisassociateCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/appdef/trust") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Add certificate authority (CA) digital certificate to the application definition CA trust list.", description = "Add a CA certificate to the application definition CA trust list. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMAppDefTrustCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to add an CA to the application definition CA trust list.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertAppDefTrustRequest.class), mediaType = "application/json", example = "{\n \"appDefinitionID\": \"myappdef\",\n \"certAlias\": \"mylabel1\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertAppDefTrustRequest dcmCertAppDefTrustRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMAppDefTrustCertificate(session, dcmCertAppDefTrustRequest); + } catch (Exception e) { + return handleException("securityDCMAppDefTrustCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/dcm/appdef/untrust") + @POST + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Request successful, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Remove a certificate authority (CA) digital certificate from the application definition CA trust list.", description = "Remove a certificate authority (CA) digital certificate from the application definition CA trust list. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityDCMAppDefUntrustCertificate(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The API properties required to remove a CA digital certificate from an application definition CA trust list.", required = true, content = {@Content(schema = @Schema(implementation = SecurityAPIImpl.RSEAPI_DCMCertAppDefTrustRequest.class), mediaType = "application/json", example = "{\n \"appDefinitionID\": \"myappdef\",\n \"certAlias\": \"mylabel1\"\n}")}) SecurityAPIImpl.RSEAPI_DCMCertAppDefTrustRequest dcmCertAppDefTrustRequest) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, true); + SecurityAPIImpl.securityDCMAppDefUntrustCertificate(session, dcmCertAppDefTrustRequest); + } catch (Exception e) { + return handleException("securityDCMAppDefUntrustCertificate", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/security/tls") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"supportedProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ],\n \"eligibleDefaultProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ], \"defaultProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ], \"supportedCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"eligibleDefaultCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"defaultCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"supportedSignatureAlgorithms\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"defaultSignatureAlgorithms\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"supportedSignatureAlgorithmCertificates\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"ECDSA_SHA224\",\n \"ECDSA_SHA1\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\",\n \"RSA_SHA224\",\n \"RSA_SHA1\",\n \"RSA_MD5\"\n ], \"defaultSignatureAlgorithmCertificates\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"supportedNamedCurves\": [\n \"x25519\",\n \"x448\",\n \"Secp256r1\",\n \"Secp384r1\",\n \"Secp521r1\"\n ], \"defaultNamedCurves\": [\n \"Secp256r1\",\n \"Secp384r1\",\n \"x25519\",\n \"Secp521r1\",\n \"x448\"\n ], \"defaultMinimumRSAKeySize\": 0, \"handshakeConnectionCounts\": false, \"secureSessionCaching\": true, \"auditSecureTelnetHandshakes\": false}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Retrieve system transport layer security (TLS) attributes.", description = "The API retrieves TLS attributes for the system. The system level settings are based on TLS System Values and System Service Tools (SST) Advanced Analysis command TLSCONFIG that allows viewing or altering of system-wide system TLS default properties.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityTLSGetAttributes(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityTLSGetAttributes(session); + } catch (Exception e) { + return handleException("listCertificates", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/security/tls/stats") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"protocolCounters\": {\n \"TLSv13\": 5,\n \"TLSv12\": 10,\n \"TLSv11\": 0,\n \"TLSv10\": 0,\n \"SSLv3\": 0 },\n \"cipherSuiteCounters\": {\n \"AES_128_GCM_SHA256\": 0,\n \"AES_256_GCM_SHA384\": 0,\n \"CHACHA20_POLY1305_SHA256\": 0,\n \"ECDHE_ECDSA_AES_128_GCM_SHA256\": 0,\n \"ECDHE_ECDSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_ECDSA_CHACHA20_POLY1305_SHA256\": 0,\n \"ECDHE_RSA_AES_128_GCM_SHA256\": 0,\n \"ECDHE_RSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_RSA_CHACHA20_POLY1305_SHA256\": 0,\n \"RSA_AES_128_GCM_SHA256\": 15,\n \"RSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_ECDSA_AES_128_CBC_SHA256\": 0,\n \"ECDHE_ECDSA_AES_256_CBC_SHA384\": 0,\n \"ECDHE_RSA_AES_128_CBC_SHA256\": 0,\n \"ECDHE_RSA_AES_256_CBC_SHA384\": 0,\n \"RSA_AES_128_CBC_SHA256\": 0,\n \"RSA_AES_128_CBC_SHA\": 0,\n \"RSA_AES_256_CBC_SHA256\": 0,\n \"RSA_AES_256_CBC_SHA\": 0,\n \"ECDHE_ECDSA_3DES_EDE_CBC_SHA\": 0,\n \"ECDHE_RSA_3DES_EDE_CBC_SHA\": 0,\n \"RSA_3DES_EDE_CBC_SHA\": 0,\n \"ECDHE_ECDSA_RC4_128_SHA\": 0,\n \"ECDHE_RSA_RC4_128_SHA\": 0,\n \"RSA_RC4_128_SHA\": 0,\n \"RSA_RC4_128_MD5\": 0,\n \"RSA_DES_CBC_SHA\": 0,\n \"RSA_EXPORT_RC4_40_MD5\": 0,\n \"RSA_EXPORT_RC2_CBC_40_MD5\": 0,\n \"ECDHE_ECDSA_NULL_SHA\": 0,\n \"ECDHE_RSA_NULL_SHA\": 0,\n \"RSA_NULL_SHA256\": 0,\n \"RSA_NULL_SHA\": 0,\n \"RSA_NULL_MD5\": 0\n }\n}")}), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "404", description = "The specified resource was not found."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Retrieve system transport layer security (TLS) statistics.", description = "The API retrieves TLS statistics. The information returned includes TLS handshake connection counts by protocol type and cipher suite on the system since the last reset for the system. The System Service Tools (SST) Advanced Analysis command TLSCONFIG connectionCounts option identifies the system level setting to enable handshake connection counting. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response securityTLSGetStatistics(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + payload = SecurityAPIImpl.securityTLSGetStatistics(session); + } catch (Exception e) { + return handleException("listCertificates", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/ServerInfoRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/ServerInfoRESTService.java new file mode 100644 index 0000000..dfbac78 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/ServerInfoRESTService.java @@ -0,0 +1,60 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.Consumes; +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JSONSerializer; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "Server Information Services", description = "Server Information Services provide APIs about RSE API. ")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class ServerInfoRESTService extends RESTService { + + @Path("/v1/info/serverdetails") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"rseapiBasepath\": \"rseapi\",\n \"rseapiHostname\": \"UT30P44\",\n \"rseapiPort\": 2012,\n \"rseapiVersion\": \"1.0.6\"\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get information about the RSE API.", description = "Get information about the RSE API.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication"), @SecurityRequirement(name = "basicHttpAuthentication")}) + public Response serverInfoGetVersionInfo(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, true); + JSONSerializer json = new JSONSerializer(); + json.startObject(); + json.add("rseapiBasepath", GlobalProperties.getRSEAPIBasePath()); + json.add("rseapiHostname", GlobalProperties.getHostName()); + json.add("rseapiPort", Integer.valueOf(CommonUtil.getServerPort())); + json.add("rseapiVersion", GlobalProperties.getRSEAPIVersion()); + json.endObject(); + payload = json.toString(); + } catch (Exception e) { + return handleException("getVersionInfo", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SessionRESTService.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SessionRESTService.java new file mode 100644 index 0000000..5cff0d4 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/SessionRESTService.java @@ -0,0 +1,125 @@ +package dev.alexzaw.rest4i.api.jaxrs; + +import javax.ws.rs.core.MediaType; +import dev.alexzaw.rest4i.api.SessionAPIImpl; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.CommonUtil; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirements; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.eclipse.microprofile.openapi.annotations.tags.Tags; + +@Path("/") +@Tags({@Tag(name = "Session Services", description = "Session Services provide APIs for authenticating a user and managing sessions that are tied to an authenticated user. The user must have a user profile on the IBM i server to be accessed. Once authenticated, a bearer token is returned and must be submitted on requests when invoking protected APIs in an HTTP authorization header.")}) +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SessionRESTService extends RESTService { + + @Path("/v1/session") + @POST + @Consumes({"application/json"}) + @APIResponses({@APIResponse(responseCode = "201", description = "Successful request, new resource created."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Authenticate with user credentials and return an embedded token.", description = "Authenticate with user credentials and return an embedded token to access different RSE APIs. On succesful authentication, a token is returned in the Authorization HTTP header. The client must send this token in the Authorization HTTP header when making requests to protected RSE APIs. For example: \n> Authorization: Bearer 4eaa14e6-ea9c-4dde-b6f7-f542b34d5309-60da75d3-3132 \n\nFor optimal performance, specify localhost to access objects located on the same system in which the RSE APIs are hosted on. If accessing objects on a remote system using the RSE APIs, the host servers on the remote system must be enabled for secure communications. \n\nAlternatively, the non-session APIs may be invoked using basic authentication. In this case, the credentials must always be sent on every request.") + public Response sessionLogin(@Context HttpServletRequest req, @RequestBody(description = "The user credentials to be authenticated.", required = true, content = {@Content(schema = @Schema(implementation = SessionAPIImpl.RSEAPI_LoginCredentials.class), mediaType = "application/json", example = "{\n \"host\": \"localhost\",\n \"userid\": \"user\",\n \"password\": \"pwd\"\n}")}) SessionAPIImpl.RSEAPI_LoginCredentials loginCredentials) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + checkSecureConnection(req); + session = SessionAPIImpl.sessionLogin(loginCredentials); + } catch (Exception e) { + return handleException("authLogin", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.CREATED).header("Authorization", "Bearer " + session.getToken()).build(); + } + + @Path("/v1/session") + @DELETE + @APIResponses({@APIResponse(responseCode = "204", description = "Successful request, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Logout, releasing resources tied to the session.", description = "Logout, releasing resources tied to the session. If a logout is not performed, it will be discarded after a period of idle time (default is 2 hours).") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication")}) + public Response sessionLogout(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization) { + CommonUtil._serviceContext.set(req); + Session session = null; + try { + session = getSession(req, authorization, false); + SessionAPIImpl.sessionLogout(session); + } catch (Exception e) { + return handleException("authLogout", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @Path("/v1/session") + @GET + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "200", description = "Successful request.", content = {@Content(mediaType = "application/json", example = "{\n \"sessionInfo\": {\n \"userID\": \"user1\",\n \"host\": \"localhost\",\n \"expiration\": \"2023-04-04T00:56:35Z\",\n \"creation\": \"2023-04-03T22:04:50Z\",\n \"lastUsed\": \"2023-04-03T22:56:35Z\",\n \"domain\": \"rseapi\",\n \"expired\": false\n },\n \"sessionSettings\": {\n \"libraryList\": [],\n \"clCommands\": [],\n \"envVariables\": {},\n \"sqlDefaultSchema\": null,\n \"sqlTreatWarningsAsErrors\": false,\n \"sqlProperties\": {\n \"XA loosely coupled support\": \"0\",\n \"access\": \"all\",\n \"auto commit\": \"true\",\n \"autocommit exception\": \"false\",\n \"bidi implicit reordering\": \"true\",\n \"bidi numeric ordering\": \"false\",\n \"bidi string type\": \"5\",\n \"big decimal\": \"true\",\n \"block criteria\": \"2\",\n \"block size\": \"32\",\n \"character truncation\": \"true\",\n \"concurrent access resolution\": \"2\",\n \"cursor hold\": \"true\",\n \"cursor sensitivity\": \"asensitive\",\n \"data compression\": \"true\",\n \"data truncation\": \"true\",\n \"database name\": \"\",\n \"date format\": \"iso\",\n \"date separator\": \"\",\n \"decfloat rounding mode\": \"half even\",\n \"decimal separator\": \"\",\n \"driver\": \"toolbox\",\n \"errors\": \"basic\",\n \"extended dynamic\": \"false\",\n \"extended metadata\": \"false\",\n \"full open\": \"false\",\n \"hold input locators\": \"true\",\n \"hold statements\": \"false\",\n \"ignore warnings\": \"01003,0100C,01567\",\n \"lazy close\": \"false\",\n \"libraries\": \"*LIBL\",\n \"lob threshold\": \"32768\",\n \"maximum blocked input rows\": \"32000\",\n \"maximum precision\": \"31\",\n \"maximum scale\": \"31\",\n \"metadata source\": \"1\",\n \"minimum divide scale\": \"0\",\n \"naming\": \"system\",\n \"numeric range error\": \"true\",\n \"package\": \"\",\n \"package add\": \"true\",\n \"package cache\": \"false\",\n \"package ccsid\": \"13488\",\n \"package criteria\": \"default\",\n \"package error\": \"warning\",\n \"package library\": \"QGPL\",\n \"portNumber\": \"0\",\n \"prefetch\": \"true\",\n \"proxy server\": \"\",\n \"qaqqinilib\": \"\",\n \"query optimize goal\": \"0\",\n \"query replace truncated parameter\": \"\",\n \"query storage limit\": \"-1\",\n \"query timeout mechanism\": \"qqrytimlmt\",\n \"remarks\": \"system\",\n \"secondary URL\": \"\",\n \"server trace\": \"0\",\n \"sort\": \"hex\",\n \"sort language\": \"\",\n \"sort table\": \"\",\n \"sort weight\": \"shared\",\n \"time format\": \"iso\",\n \"time separator\": \":\",\n \"trace\": \"false\",\n \"transaction isolation\": \"read uncommitted\",\n \"translate binary\": \"false\",\n \"translate boolean\": \"true\",\n \"translate hex\": \"character\",\n \"true autocommit\": \"false\",\n \"use block update\": \"false\",\n \"variable field compression\": \"all\"\n },\n \"sqlStatements\": []\n },\n \"jobRemoteCommand\": {\n \"id\": \"094268/QUSER/QZRCSRVS\",\n \"ccsid\": 37,\n \"homeDirectory\": \"/home/USER1\",\n \"curLib\": null,\n \"systemLibl\": [\n {\n \"name\": \"QSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library\"\n },\n {\n \"name\": \"QSYS2\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library for CPI's\"\n },\n {\n \"name\": \"QHLPSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"\"\n },\n {\n \"name\": \"QUSRSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library for Users\"\n }\n ],\n \"userLibl\": [\n {\n \"name\": \"QGPL\",\n \"attribute\": \"PROD\",\n \"description\": \"General Purpose Library\"\n },\n {\n \"name\": \"QTEMP\",\n \"attribute\": \"TEST\",\n \"description\": \"\"\n }\n ],\n \"envVariables\": {},\n \"jobLog\": []\n }\n}")}), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Get information about the session. ", description = "Get information about the session. The information returned includes session settings in addition to information about any host server jobs tied to the session.") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication")}) + public Response sessionQuery(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @Parameter(description = "Comma seperated list of environment variables to return from the remote command host server job.", required = false) @QueryParam("envvars") String envvars, @Parameter(description = "Maximum number of message log records to return from the remote command host server job.", required = false) @DefaultValue("0") @QueryParam("maxjoblogrecords") Integer maxJobLogMessages, @Parameter(description = "Comma seperated list of message IDs. Only remote command host server job log messages that do not match the filter message IDs will be returned.", required = false) @QueryParam("joblogfilter") String jobLogFilter) { + CommonUtil._serviceContext.set(req); + Session session = null; + String payload = null; + try { + session = getSession(req, authorization, false); + SessionAPIImpl.SessionSettingsAndJobInfo sessionInfo = SessionAPIImpl.sessionQuery(session, maxJobLogMessages, envvars, jobLogFilter); + payload = sessionInfo.toJSON(); + } catch (Exception e) { + return handleException("authQuery", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + return Response.status(Response.Status.OK).entity(payload).build(); + } + + @Path("/v1/session") + @PUT + @Consumes({"application/json"}) + @Produces({"application/json; charset=utf-8"}) + @APIResponses({@APIResponse(responseCode = "204", description = "Successful request, no content."), @APIResponse(responseCode = "400", description = "Bad request."), @APIResponse(responseCode = "401", description = "Unauthorized request was made."), @APIResponse(responseCode = "403", description = "The request is forbidden."), @APIResponse(responseCode = "500", description = "Unable to process the request due to an internal server error.")}) + @Operation(summary = "Refresh session settings.", description = "Refresh session settings. The settings affect the remote command and database host server jobs that are tied to the session. Refreshing sesson settings may result in the ending of existing host server jobs. ") + @SecurityRequirements({@SecurityRequirement(name = "bearerHttpAuthentication")}) + public Response sessionRefresh(@Context HttpServletRequest req, @Parameter(description = "The authorization HTTP header.") @HeaderParam("Authorization") String authorization, @RequestBody(description = "The settings for the session.", required = true, content = {@Content(schema = @Schema(implementation = SessionAPIImpl.RSEAPI_SessionSettings.class), mediaType = "application/json", example = "{\n \"resetSettings\": true,\n \"continueOnError\": true,\n \"libraryList\": [\n \"lib1\",\n \"lib2\"\n ],\n \"clCommands\": [\n \"QSYS/CLRLIB LIB(BUILD)\"\n ],\n \"envVariables\": {\n \"var1\": \"var1val\",\n \"var2\": \"var2val\"\n },\n \"sqlDefaultSchema\": \"lib1\",\n \"sqlProperties\": {\n \"auto commit\": \"true\",\n \"ignore warnings\": \"01003,0100C,01567\"\n },\n \"sqlStatements\": [\n \"SET PATH = LIB1, LIB2\"\n ]\n}")}) SessionAPIImpl.RSEAPI_SessionSettings settings) { + CommonUtil._serviceContext.set(req); + Session session = null; + SessionAPIImpl.SetSessionSettingsResults ssresults = null; + try { + session = getSession(req, authorization, false); + ssresults = SessionAPIImpl.sessionRefresh(session, settings); + } catch (Exception e) { + return handleException("authRefresh", e); + } finally { + releaseSession(session); + } + CommonUtil._serviceContext.remove(); + if (ssresults != null && ssresults.errorsOccurred()) + return Response.status(Response.Status.OK).entity(ssresults.toJSON()).build(); + return Response.status(Response.Status.NO_CONTENT).build(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/api/jaxrs/util/ResponseFormatter.java b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/util/ResponseFormatter.java new file mode 100644 index 0000000..78d3bcc --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/api/jaxrs/util/ResponseFormatter.java @@ -0,0 +1,296 @@ +package dev.alexzaw.rest4i.api.jaxrs.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.alexzaw.rest4i.database.QueryResult; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for formatting database query results into various output formats. + * Supports JSON, XML, CSV, HTML, and Excel formats. + * + * @author alexzaw + */ +public class ResponseFormatter { + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + + /** + * Formats a QueryResult into the requested format and returns a JAX-RS Response. + * + * @param queryResult The query result to format + * @param format The requested format (json, xml, csv, html, excel) + * @param treatWarningsAsErrors Whether to treat warnings as errors + * @param alwaysReturnSQLStateInformation Whether to always include SQL state info + * @return JAX-RS Response with formatted content + */ + public static Response formatQueryResult(QueryResult queryResult, String format, + boolean treatWarningsAsErrors, + boolean alwaysReturnSQLStateInformation) { + try { + String outputFormat = (format != null) ? format.toLowerCase() : "json"; + + if (!queryResult.isQuery()) { + // For non-query operations, return update count + Map result = new HashMap<>(); + result.put("success", true); + result.put("rowsAffected", queryResult.getUpdateCount()); + result.put("message", "Operation completed successfully"); + + return Response.ok(JSON_MAPPER.writeValueAsString(result)) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + List> data = queryResult.getData(); + List columnNames = queryResult.getColumnNames(); + + switch (outputFormat) { + case "xml": + return formatAsXml(data, columnNames); + case "csv": + return formatAsCsv(data, columnNames); + case "html": + return formatAsHtml(data, columnNames); + case "excel": + return formatAsExcel(data, columnNames); + case "json": + default: + return formatAsJson(data, columnNames); + } + + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("{\"error\": \"Error formatting result: " + e.getMessage() + "\"}") + .type(MediaType.APPLICATION_JSON) + .build(); + } finally { + try { + queryResult.close(); + } catch (SQLException e) { + // Log warning but don't fail the response + } + } + } + + /** + * Formats data as JSON response. + */ + private static Response formatAsJson(List> data, List columnNames) throws IOException { + Map result = new HashMap<>(); + result.put("success", true); + result.put("rowCount", data.size()); + result.put("columnNames", columnNames); + result.put("data", data); + + String jsonResult = JSON_MAPPER.writeValueAsString(result); + return Response.ok(jsonResult) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + /** + * Formats data as XML response. + */ + private static Response formatAsXml(List> data, List columnNames) throws IOException { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + xml.append(" true\n"); + xml.append(" ").append(data.size()).append("\n"); + xml.append(" \n"); + + for (String columnName : columnNames) { + xml.append(" ").append(escapeXml(columnName)).append("\n"); + } + xml.append(" \n"); + xml.append(" \n"); + + for (Map row : data) { + xml.append(" \n"); + for (String columnName : columnNames) { + Object value = row.get(columnName); + String cellValue = (value != null) ? escapeXml(value.toString()) : ""; + xml.append(" <").append(escapeXml(columnName)).append(">") + .append(cellValue) + .append("\n"); + } + xml.append(" \n"); + } + xml.append(" \n"); + xml.append(""); + + return Response.ok(xml.toString()) + .type(MediaType.APPLICATION_XML) + .build(); + } + + /** + * Formats data as CSV response. + */ + private static Response formatAsCsv(List> data, List columnNames) { + StringBuilder csv = new StringBuilder(); + + // Add header row + if (!columnNames.isEmpty()) { + csv.append(String.join(",", columnNames)); + csv.append("\n"); + } + + // Add data rows + for (Map row : data) { + for (int i = 0; i < columnNames.size(); i++) { + if (i > 0) csv.append(","); + + Object value = row.get(columnNames.get(i)); + String cellValue = (value != null) ? escapeCsvValue(value.toString()) : ""; + csv.append(cellValue); + } + csv.append("\n"); + } + + return Response.ok(csv.toString()) + .type("text/csv") + .header("Content-Disposition", "attachment; filename=\"query_result.csv\"") + .build(); + } + + /** + * Formats data as HTML table response. + */ + private static Response formatAsHtml(List> data, List columnNames) { + StringBuilder html = new StringBuilder(); + html.append("\n"); + html.append("Query Results"); + html.append(""); + html.append("

Query Results

"); + html.append("

Total rows: ").append(data.size()).append("

"); + html.append(""); + + // Add header row + if (!columnNames.isEmpty()) { + html.append(""); + for (String columnName : columnNames) { + html.append(""); + } + html.append(""); + } + + // Add data rows + html.append(""); + for (Map row : data) { + html.append(""); + for (String columnName : columnNames) { + Object value = row.get(columnName); + String cellValue = (value != null) ? escapeHtml(value.toString()) : ""; + html.append(""); + } + html.append(""); + } + html.append("
").append(escapeHtml(columnName)).append("
").append(cellValue).append("
"); + html.append(""); + + return Response.ok(html.toString()) + .type(MediaType.TEXT_HTML) + .build(); + } + + /** + * Formats data as Excel spreadsheet response. + */ + private static Response formatAsExcel(List> data, List columnNames) throws IOException { + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Query Results"); + + // Create header row + if (!columnNames.isEmpty()) { + Row headerRow = sheet.createRow(0); + for (int i = 0; i < columnNames.size(); i++) { + Cell cell = headerRow.createCell(i); + cell.setCellValue(columnNames.get(i)); + } + } + + // Create data rows + for (int rowIndex = 0; rowIndex < data.size(); rowIndex++) { + Row row = sheet.createRow(rowIndex + 1); + Map rowData = data.get(rowIndex); + + for (int colIndex = 0; colIndex < columnNames.size(); colIndex++) { + Cell cell = row.createCell(colIndex); + Object value = rowData.get(columnNames.get(colIndex)); + + if (value != null) { + if (value instanceof Number) { + cell.setCellValue(((Number) value).doubleValue()); + } else { + cell.setCellValue(value.toString()); + } + } + } + } + + // Auto-size columns + for (int i = 0; i < columnNames.size(); i++) { + sheet.autoSizeColumn(i); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + workbook.write(baos); + + return Response.ok(baos.toByteArray()) + .type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .header("Content-Disposition", "attachment; filename=\"query_result.xlsx\"") + .build(); + } + } + + /** + * Escapes CSV values that contain commas, quotes, or newlines. + */ + private static String escapeCsvValue(String value) { + if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + + /** + * Escapes HTML special characters. + */ + private static String escapeHtml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** + * Escapes XML special characters. + */ + private static String escapeXml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/config/EnhancedConfigurationManager.java b/src/main/java/dev/alexzaw/rest4i/config/EnhancedConfigurationManager.java new file mode 100644 index 0000000..2901f99 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/config/EnhancedConfigurationManager.java @@ -0,0 +1,540 @@ +package dev.alexzaw.rest4i.config; + +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JsonUtils; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Enhanced Configuration Manager based on REST4i's ConfigurationManager. + * + * Provides configuration management with multiple search paths, singleton pattern, + * concurrent caching, and hot reload capability while maintaining compatibility + * with existing RSE session authentication. + * + * Features: + * - Multiple configuration search paths + * - Singleton pattern with thread-safe operations + * - Hot reload capability with file watchers + * - JSON and Properties file support + * - Integration with existing GlobalProperties where needed + * + * @author alexzaw + */ +public class EnhancedConfigurationManager { + + private static final Logger logger = Logger.getLogger(EnhancedConfigurationManager.class.getName()); + + // Singleton instance + private static volatile EnhancedConfigurationManager instance; + private static final Object INSTANCE_LOCK = new Object(); + + // Configuration search paths (in order of precedence) + private static final String[] CONFIG_SEARCH_PATHS = { + "/QIBM/UserData/OS/RSEAPI/", + "/etc/misc/configs/", + "/tmp/Rest4i-configs/" + }; + + // Configuration file names to search for + private static final String[] CONFIG_FILE_NAMES = { + "enhanced-config.properties", + "enhanced-config.json", + "rest4i-config.properties", + "rest4i-config.json" + }; + + // Internal storage + private final Map configCache = new ConcurrentHashMap<>(); + private final Map fileLastModified = new ConcurrentHashMap<>(); + private final ReentrantReadWriteLock cacheLock = new ReentrantReadWriteLock(); + + // File watching + private WatchService watchService; + private Thread watchThread; + private volatile boolean watchingEnabled = true; + + // Default configuration values + private static final Map DEFAULT_CONFIG = createDefaultConfig(); + + /** + * Creates the default configuration map. + * Using a method instead of Map.of() to support more than 10 key-value pairs in Java 11. + * + * @return The default configuration map + */ + private static Map createDefaultConfig() { + Map config = new HashMap<>(); + config.put("connection.pool.maxActive", 20); + config.put("connection.pool.maxIdle", 5); + config.put("connection.pool.minIdle", 2); + config.put("connection.pool.maxWait", 30000); + config.put("connection.pool.validationQuery", "SELECT 1 FROM SYSIBM.SYSDUMMY1"); + config.put("connection.pool.testOnBorrow", true); + config.put("connection.pool.testWhileIdle", true); + config.put("api.response.includeTimestamps", true); + config.put("api.response.includeDebugInfo", false); + config.put("security.allowApiTokens", true); + config.put("validation.strictMode", false); + + // Additional pool-specific configuration that matches AS400ConnectionPool API + config.put("connection.pool.maxConnections", 20); + config.put("connection.pool.maxInactivityTime", 300000); // 5 minutes + config.put("connection.pool.maxLifetime", 1800000); // 30 minutes + config.put("connection.pool.maxUseCount", 1000); + config.put("connection.pool.pretestConnections", true); + config.put("connection.pool.runMaintenance", true); + config.put("connection.pool.cleanupInterval", 60000); // 1 minute + + return Collections.unmodifiableMap(config); + } + + /** + * Private constructor for singleton pattern. + */ + private EnhancedConfigurationManager() { + initializeConfiguration(); + startFileWatcher(); + } + + /** + * Gets the singleton instance of EnhancedConfigurationManager. + * + * @return The singleton instance + */ + public static EnhancedConfigurationManager getInstance() { + if (instance == null) { + synchronized (INSTANCE_LOCK) { + if (instance == null) { + instance = new EnhancedConfigurationManager(); + } + } + } + return instance; + } + + /** + * Initializes the configuration by loading from all available sources. + */ + private void initializeConfiguration() { + cacheLock.writeLock().lock(); + try { + // Start with default configuration + configCache.putAll(DEFAULT_CONFIG); + + // Load configuration from files in search paths + for (String searchPath : CONFIG_SEARCH_PATHS) { + loadConfigurationsFromPath(searchPath); + } + + // Load from GlobalProperties for backwards compatibility + loadFromGlobalProperties(); + + logger.info("Configuration initialized with " + configCache.size() + " properties"); + + } finally { + cacheLock.writeLock().unlock(); + } + } + + /** + * Loads configuration files from a specific path. + * + * @param searchPath The path to search for configuration files + */ + private void loadConfigurationsFromPath(String searchPath) { + Path path = Paths.get(searchPath); + if (!Files.exists(path) || !Files.isDirectory(path)) { + logger.fine("Configuration path does not exist: " + searchPath); + return; + } + + for (String configFileName : CONFIG_FILE_NAMES) { + Path configFile = path.resolve(configFileName); + if (Files.exists(configFile) && Files.isRegularFile(configFile)) { + try { + loadConfigurationFile(configFile); + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load configuration file: " + configFile, e); + } + } + } + } + + /** + * Loads a specific configuration file. + * + * @param configFile The configuration file to load + * @throws IOException If the file cannot be read + */ + private void loadConfigurationFile(Path configFile) throws IOException { + String fileName = configFile.getFileName().toString().toLowerCase(); + long lastModified = Files.getLastModifiedTime(configFile).toMillis(); + + // Check if file has been modified since last load + String fileKey = configFile.toString(); + if (fileLastModified.containsKey(fileKey) && fileLastModified.get(fileKey) == lastModified) { + return; // File hasn't changed, skip loading + } + + logger.info("Loading configuration from: " + configFile); + + if (fileName.endsWith(".json")) { + loadJsonConfiguration(configFile); + } else if (fileName.endsWith(".properties")) { + loadPropertiesConfiguration(configFile); + } + + fileLastModified.put(fileKey, lastModified); + } + + /** + * Loads configuration from a JSON file. + * + * @param configFile The JSON configuration file + * @throws IOException If the file cannot be read or parsed + */ + private void loadJsonConfiguration(Path configFile) throws IOException { + try (InputStream inputStream = Files.newInputStream(configFile)) { + @SuppressWarnings("unchecked") + Map jsonConfig = JsonUtils.parseJsonToMap(inputStream); + flattenAndAddToCache("", jsonConfig); + } + } + + /** + * Loads configuration from a Properties file. + * + * @param configFile The Properties configuration file + * @throws IOException If the file cannot be read + */ + private void loadPropertiesConfiguration(Path configFile) throws IOException { + Properties props = new Properties(); + try (InputStream inputStream = Files.newInputStream(configFile)) { + props.load(inputStream); + } + + for (String key : props.stringPropertyNames()) { + configCache.put(key, props.getProperty(key)); + } + } + + /** + * Flattens a nested JSON structure and adds it to the cache. + * + * @param prefix The current key prefix + * @param map The map to flatten + */ + private void flattenAndAddToCache(String prefix, Map map) { + for (Map.Entry entry : map.entrySet()) { + String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map nestedMap = (Map) value; + flattenAndAddToCache(key, nestedMap); + } else { + configCache.put(key, value); + } + } + } + + /** + * Loads configuration from GlobalProperties for backwards compatibility. + */ + private void loadFromGlobalProperties() { + // Add any relevant GlobalProperties values + // This maintains compatibility with existing RSE configuration + String rseApiPath = "/QIBM/UserData/OS/RSEAPI"; + if (Files.exists(Paths.get(rseApiPath))) { + configCache.put("rse.config.path", rseApiPath); + } + + // Add other RSE-related configuration as needed + configCache.put("rse.version", "1.0.7"); // From GlobalProperties.RSEAPI_VERSION + } + + /** + * Starts the file watcher for hot reload capability. + */ + private void startFileWatcher() { + try { + watchService = FileSystems.getDefault().newWatchService(); + + // Register watch keys for all configuration directories + for (String searchPath : CONFIG_SEARCH_PATHS) { + Path path = Paths.get(searchPath); + if (Files.exists(path) && Files.isDirectory(path)) { + path.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY); + logger.fine("Registered file watcher for: " + searchPath); + } + } + + // Start the watch thread + watchThread = new Thread(this::processWatchEvents); + watchThread.setName("ConfigurationWatcher"); + watchThread.setDaemon(true); + watchThread.start(); + + logger.info("Configuration file watcher started"); + + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to start configuration file watcher", e); + } + } + + /** + * Processes file system watch events for configuration hot reload. + */ + private void processWatchEvents() { + while (watchingEnabled && watchService != null) { + try { + WatchKey key = watchService.take(); + + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + + if (kind == StandardWatchEventKinds.OVERFLOW) { + continue; + } + + @SuppressWarnings("unchecked") + WatchEvent pathEvent = (WatchEvent) event; + Path fileName = pathEvent.context(); + + if (isConfigurationFile(fileName.toString())) { + logger.info("Configuration file changed: " + fileName + ", reloading..."); + reloadConfiguration(); + } + } + + boolean valid = key.reset(); + if (!valid) { + break; // Directory no longer accessible + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + logger.log(Level.WARNING, "Error processing configuration file changes", e); + } + } + } + + /** + * Checks if a file is a configuration file we care about. + * + * @param fileName The file name to check + * @return true if it's a configuration file + */ + private boolean isConfigurationFile(String fileName) { + String lowerFileName = fileName.toLowerCase(); + return Arrays.stream(CONFIG_FILE_NAMES) + .anyMatch(configName -> lowerFileName.equals(configName.toLowerCase())); + } + + /** + * Reloads the configuration from all sources. + */ + public void reloadConfiguration() { + cacheLock.writeLock().lock(); + try { + configCache.clear(); + fileLastModified.clear(); + initializeConfiguration(); + logger.info("Configuration reloaded successfully"); + } finally { + cacheLock.writeLock().unlock(); + } + } + + /** + * Gets a string configuration value. + * + * @param key The configuration key + * @param defaultValue The default value if key is not found + * @return The configuration value + */ + public String getString(String key, String defaultValue) { + cacheLock.readLock().lock(); + try { + Object value = configCache.get(key); + return value != null ? value.toString() : defaultValue; + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Gets an integer configuration value. + * + * @param key The configuration key + * @param defaultValue The default value if key is not found + * @return The configuration value + */ + public int getInt(String key, int defaultValue) { + cacheLock.readLock().lock(); + try { + Object value = configCache.get(key); + if (value instanceof Number) { + return ((Number) value).intValue(); + } else if (value instanceof String) { + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + logger.warning("Invalid integer value for key '" + key + "': " + value); + } + } + return defaultValue; + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Gets a boolean configuration value. + * + * @param key The configuration key + * @param defaultValue The default value if key is not found + * @return The configuration value + */ + public boolean getBoolean(String key, boolean defaultValue) { + cacheLock.readLock().lock(); + try { + Object value = configCache.get(key); + if (value instanceof Boolean) { + return (Boolean) value; + } else if (value instanceof String) { + return Boolean.parseBoolean((String) value); + } + return defaultValue; + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Gets a long configuration value. + * + * @param key The configuration key + * @param defaultValue The default value if key is not found + * @return The configuration value + */ + public long getLong(String key, long defaultValue) { + cacheLock.readLock().lock(); + try { + Object value = configCache.get(key); + if (value instanceof Number) { + return ((Number) value).longValue(); + } else if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + logger.warning("Invalid long value for key '" + key + "': " + value); + } + } + return defaultValue; + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Sets a configuration value (runtime only, not persisted). + * + * @param key The configuration key + * @param value The configuration value + */ + public void setProperty(String key, Object value) { + cacheLock.writeLock().lock(); + try { + configCache.put(key, value); + } finally { + cacheLock.writeLock().unlock(); + } + } + + /** + * Gets all configuration properties as a map. + * + * @return A copy of all configuration properties + */ + public Map getAllProperties() { + cacheLock.readLock().lock(); + try { + return new HashMap<>(configCache); + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Gets all properties with a specific prefix. + * + * @param prefix The prefix to filter by + * @return A map of matching properties (with prefix removed from keys) + */ + public Map getPropertiesWithPrefix(String prefix) { + cacheLock.readLock().lock(); + try { + Map result = new HashMap<>(); + String searchPrefix = prefix.endsWith(".") ? prefix : prefix + "."; + + for (Map.Entry entry : configCache.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(searchPrefix)) { + String newKey = key.substring(searchPrefix.length()); + result.put(newKey, entry.getValue()); + } + } + + return result; + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Checks if a configuration key exists. + * + * @param key The configuration key + * @return true if the key exists + */ + public boolean hasProperty(String key) { + cacheLock.readLock().lock(); + try { + return configCache.containsKey(key); + } finally { + cacheLock.readLock().unlock(); + } + } + + /** + * Shuts down the configuration manager and releases resources. + */ + public void shutdown() { + watchingEnabled = false; + + if (watchThread != null) { + watchThread.interrupt(); + } + + if (watchService != null) { + try { + watchService.close(); + } catch (IOException e) { + logger.log(Level.WARNING, "Error closing watch service", e); + } + } + + logger.info("Configuration manager shut down"); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/database/DatabaseManager.java b/src/main/java/dev/alexzaw/rest4i/database/DatabaseManager.java new file mode 100644 index 0000000..3816a76 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/database/DatabaseManager.java @@ -0,0 +1,164 @@ +package dev.alexzaw.rest4i.database; + +import dev.alexzaw.rest4i.session.Session; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.sql.*; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Database Manager for handling database connections and queries + * using HikariCP connection pooling with IBM i integration. + * + * @author alexzaw + */ +public class DatabaseManager { + + private static final ConcurrentMap CONNECTION_POOLS = new ConcurrentHashMap<>(); + + /** + * Executes a SQL query using the session's database connection information. + * + * @param session The authenticated session containing connection details + * @param sqlQuery The SQL query to execute + * @return QueryResult containing the results + * @throws SQLException if query execution fails + */ + public QueryResult executeQuery(Session session, String sqlQuery) throws SQLException { + Connection connection = getConnection(session); + + try (PreparedStatement stmt = connection.prepareStatement(sqlQuery)) { + boolean hasResultSet = stmt.execute(); + + if (hasResultSet) { + ResultSet rs = stmt.getResultSet(); + return new QueryResult(rs, stmt); + } else { + int updateCount = stmt.getUpdateCount(); + return new QueryResult(updateCount); + } + } + } + + /** + * Executes a SQL query using an existing database connection. + * + * @param connection The database connection to use + * @param sqlQuery The SQL query to execute + * @return QueryResult containing the results + * @throws SQLException if query execution fails + */ + public QueryResult executeQueryWithConnection(Connection connection, String sqlQuery) throws SQLException { + try (PreparedStatement stmt = connection.prepareStatement(sqlQuery)) { + boolean hasResultSet = stmt.execute(); + + if (hasResultSet) { + ResultSet rs = stmt.getResultSet(); + return new QueryResult(rs, stmt); + } else { + int updateCount = stmt.getUpdateCount(); + return new QueryResult(updateCount); + } + } + } + + /** + * Gets a database connection from the connection pool for the given session. + * + * @param session The session containing connection details + * @return Database connection + * @throws SQLException if connection cannot be established + */ + private Connection getConnection(Session session) throws SQLException { + String poolKey = getPoolKey(session); + + HikariDataSource dataSource = CONNECTION_POOLS.computeIfAbsent(poolKey, k -> { + try { + return createDataSource(session); + } catch (Exception e) { + throw new RuntimeException("Failed to create data source", e); + } + }); + + return dataSource.getConnection(); + } + + /** + * Creates a HikariCP data source for the session. + * + * @param session The session containing connection details + * @return HikariDataSource configured for the session + */ + private HikariDataSource createDataSource(Session session) { + HikariConfig config = new HikariConfig(); + + // Get connection details from session + String server = session.getHost(); + String userId = session.getUserid(); + // Note: Password would need to be handled securely in production + + // IBM i JDBC URL + String jdbcUrl = String.format("jdbc:as400://%s;libraries=*LIBL;prompt=false;date format=iso;naming=system;translate binary=true;translate hex=character", server); + + config.setJdbcUrl(jdbcUrl); + config.setUsername(userId); + config.setDriverClassName("com.ibm.as400.access.AS400JDBCDriver"); + + // Connection pool settings + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + config.setLeakDetectionThreshold(60000); + + // Pool name for monitoring + config.setPoolName("IBMi-Pool-" + server + "-" + userId); + + return new HikariDataSource(config); + } + + /** + * Generates a unique pool key for the session. + * + * @param session The session + * @return Unique pool key + */ + private String getPoolKey(Session session) { + return session.getHost() + ":" + session.getUserid(); + } + + /** + * Validates a SQL query to prevent dangerous operations. + * + * @param sqlQuery The query to validate + * @return true if query is safe, false otherwise + */ + public boolean validateQuery(String sqlQuery) { + if (sqlQuery == null || sqlQuery.trim().isEmpty()) { + return false; + } + + String lowercaseQuery = sqlQuery.toLowerCase(); + + // Block dangerous operations + return !(lowercaseQuery.contains("truncate") || + lowercaseQuery.contains("drop") || + lowercaseQuery.contains("delete from") || + lowercaseQuery.contains("alter") || + lowercaseQuery.contains("create") || + lowercaseQuery.contains("grant") || + lowercaseQuery.contains("revoke")); + } + + /** + * Closes all connection pools. Should be called on application shutdown. + */ + public static void closeAllPools() { + CONNECTION_POOLS.values().forEach(HikariDataSource::close); + CONNECTION_POOLS.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/database/QueryResult.java b/src/main/java/dev/alexzaw/rest4i/database/QueryResult.java new file mode 100644 index 0000000..71ac766 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/database/QueryResult.java @@ -0,0 +1,205 @@ +package dev.alexzaw.rest4i.database; + +import java.sql.*; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Container for SQL query results that can be formatted into various output types. + * + * @author alexzaw + */ +public class QueryResult implements AutoCloseable { + + private final ResultSet resultSet; + private final PreparedStatement statement; + private final int updateCount; + private final boolean isQuery; + private List> data; + private ResultSetMetaData metaData; + + /** + * Constructor for query results (SELECT statements). + * + * @param resultSet The result set + * @param statement The statement (kept open for proper resource management) + */ + public QueryResult(ResultSet resultSet, PreparedStatement statement) throws SQLException { + this.resultSet = resultSet; + this.statement = statement; + this.updateCount = -1; + this.isQuery = true; + this.metaData = resultSet.getMetaData(); + this.data = null; // Lazy loading + } + + /** + * Constructor for non-query results (INSERT, UPDATE, DELETE statements). + * + * @param updateCount The number of affected rows + */ + public QueryResult(int updateCount) { + this.resultSet = null; + this.statement = null; + this.updateCount = updateCount; + this.isQuery = false; + this.metaData = null; + this.data = new ArrayList<>(); + } + + /** + * Returns whether this result has a result set (from SELECT query). + * + * @return true if this is a query result with data + */ + public boolean isQuery() { + return isQuery; + } + + /** + * Gets the update count for non-query operations. + * + * @return number of affected rows, or -1 if this is a query result + */ + public int getUpdateCount() { + return updateCount; + } + + /** + * Gets the result set data as a list of maps. + * Each map represents a row with column names as keys. + * + * @return List of row data + * @throws SQLException if data reading fails + */ + public List> getData() throws SQLException { + if (!isQuery) { + return data; + } + + if (data == null) { + loadData(); + } + + return data; + } + + /** + * Gets the column names from the result set. + * + * @return List of column names + * @throws SQLException if metadata reading fails + */ + public List getColumnNames() throws SQLException { + if (!isQuery || metaData == null) { + return new ArrayList<>(); + } + + List columnNames = new ArrayList<>(); + int columnCount = metaData.getColumnCount(); + + for (int i = 1; i <= columnCount; i++) { + columnNames.add(metaData.getColumnName(i)); + } + + return columnNames; + } + + /** + * Gets the column types from the result set. + * + * @return List of column type names + * @throws SQLException if metadata reading fails + */ + public List getColumnTypes() throws SQLException { + if (!isQuery || metaData == null) { + return new ArrayList<>(); + } + + List columnTypes = new ArrayList<>(); + int columnCount = metaData.getColumnCount(); + + for (int i = 1; i <= columnCount; i++) { + columnTypes.add(metaData.getColumnTypeName(i)); + } + + return columnTypes; + } + + /** + * Gets the number of columns in the result set. + * + * @return column count + * @throws SQLException if metadata reading fails + */ + public int getColumnCount() throws SQLException { + if (!isQuery || metaData == null) { + return 0; + } + + return metaData.getColumnCount(); + } + + /** + * Lazy loads the data from the result set. + * + * @throws SQLException if data reading fails + */ + private void loadData() throws SQLException { + data = new ArrayList<>(); + + if (resultSet == null) { + return; + } + + List columnNames = getColumnNames(); + + while (resultSet.next()) { + Map row = new LinkedHashMap<>(); + + for (int i = 0; i < columnNames.size(); i++) { + String columnName = columnNames.get(i); + Object value = resultSet.getObject(i + 1); + + // Handle SQL NULL values + if (resultSet.wasNull()) { + value = null; + } + + row.put(columnName, value); + } + + data.add(row); + } + } + + /** + * Gets the number of rows in the result set. + * Note: This will load all data if not already loaded. + * + * @return number of rows + * @throws SQLException if data reading fails + */ + public int getRowCount() throws SQLException { + if (!isQuery) { + return 0; + } + + return getData().size(); + } + + @Override + public void close() throws SQLException { + try { + if (resultSet != null && !resultSet.isClosed()) { + resultSet.close(); + } + } finally { + if (statement != null && !statement.isClosed()) { + statement.close(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/database/formatter/OutputFormatter.java b/src/main/java/dev/alexzaw/rest4i/database/formatter/OutputFormatter.java new file mode 100644 index 0000000..76fc3c7 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/database/formatter/OutputFormatter.java @@ -0,0 +1,350 @@ +package dev.alexzaw.rest4i.database.formatter; + +import dev.alexzaw.rest4i.database.QueryResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonGenerator; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.ByteArrayOutputStream; +import java.sql.SQLException; +import java.util.List; +import java.util.HashMap; +import java.util.Map; + + +/** + * Output formatter for database query results. + * Supports multiple output formats: JSON, XML, CSV, HTML, Excel. + * + * @author alexzaw + */ +public class OutputFormatter { + + private final ObjectMapper objectMapper; + + public OutputFormatter() { + this.objectMapper = new ObjectMapper(); + this.objectMapper.configure(JsonGenerator.Feature.ESCAPE_NON_ASCII, false); + } + + /** + * Outputs query results in JSON format. + * + * @param response HTTP response + * @param result Query result to format + * @throws IOException if writing fails + * @throws SQLException if data reading fails + */ + public void outputJson(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("application/json; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + + if (!result.isQuery()) { + // Non-query result + Map responseObj = new HashMap<>(); + responseObj.put("success", true); + responseObj.put("message", "SQL executed successfully"); + responseObj.put("rowsAffected", result.getUpdateCount()); + objectMapper.writeValue(response.getWriter(), responseObj); + } else { + // Query result + List> data = result.getData(); + Map responseObj = new HashMap<>(); + responseObj.put("success", true); + responseObj.put("rowCount", data.size()); + responseObj.put("data", data); + + objectMapper.writeValue(response.getWriter(), responseObj); + } + } + + /** + * Outputs query results in XML format. + * + * @param response HTTP response + * @param result Query result to format + * @throws IOException if writing fails + * @throws SQLException if data reading fails + */ + public void outputXml(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("application/xml; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + + PrintWriter writer = response.getWriter(); + writer.println(""); + + if (!result.isQuery()) { + writer.println(""); + writer.println(" true"); + writer.println(" SQL executed successfully"); + writer.println(" " + result.getUpdateCount() + ""); + writer.println(""); + } else { + List> data = result.getData(); + writer.println(""); + writer.println(" true"); + writer.println(" " + data.size() + ""); + writer.println(" "); + + for (Map row : data) { + writer.println(" "); + for (Map.Entry entry : row.entrySet()) { + String value = entry.getValue() != null ? escapeXml(entry.getValue().toString()) : ""; + writer.println(" <" + entry.getKey() + ">" + value + ""); + } + writer.println(" "); + } + + writer.println(" "); + writer.println(""); + } + } + + /** + * Outputs query results in CSV format. + * + * @param response HTTP response + * @param result Query result to format + * @throws IOException if writing fails + * @throws SQLException if data reading fails + */ + public void outputCsv(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("text/csv; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=\"query_result.csv\""); + + PrintWriter writer = response.getWriter(); + + if (!result.isQuery()) { + writer.println("Success,Message,RowsAffected"); + writer.println("true,\"SQL executed successfully\"," + result.getUpdateCount()); + } else { + List> data = result.getData(); + if (!data.isEmpty()) { + // Write header + Map firstRow = data.get(0); + boolean first = true; + for (String key : firstRow.keySet()) { + if (!first) writer.print(","); + writer.print("\"" + escapeCsv(key) + "\""); + first = false; + } + writer.println(); + + // Write data rows + for (Map row : data) { + first = true; + for (Object value : row.values()) { + if (!first) writer.print(","); + String valueStr = value != null ? value.toString() : ""; + writer.print("\"" + escapeCsv(valueStr) + "\""); + first = false; + } + writer.println(); + } + } + } + } + + /** + * Outputs query results in HTML format. + * + * @param response HTTP response + * @param result Query result to format + * @throws IOException if writing fails + * @throws SQLException if data reading fails + */ + public void outputHtml(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("text/html; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + + PrintWriter writer = response.getWriter(); + writer.println(""); + writer.println(""); + writer.println(""); + writer.println(" Query Results"); + writer.println(" "); + writer.println(""); + writer.println(""); + writer.println("

Query Results

"); + + if (!result.isQuery()) { + writer.println("

Success: true

"); + writer.println("

Message: SQL executed successfully

"); + writer.println("

Rows Affected: " + result.getUpdateCount() + "

"); + } else { + List> data = result.getData(); + writer.println("

Row Count: " + data.size() + "

"); + + if (!data.isEmpty()) { + writer.println(" "); + writer.println(" "); + writer.println(" "); + + // Write header + Map firstRow = data.get(0); + for (String key : firstRow.keySet()) { + writer.println(" "); + } + + writer.println(" "); + writer.println(" "); + writer.println(" "); + + // Write data rows + for (Map row : data) { + writer.println(" "); + for (Object value : row.values()) { + String valueStr = value != null ? value.toString() : ""; + writer.println(" "); + } + writer.println(" "); + } + + writer.println(" "); + writer.println("
" + escapeHtml(key) + "
" + escapeHtml(valueStr) + "
"); + } + } + + writer.println(""); + writer.println(""); + } + + /** + * Outputs query results in Excel format. + * + * @param response HTTP response + * @param result Query result to format + * @throws IOException if writing fails + * @throws SQLException if data reading fails + */ + public void outputExcel(HttpServletResponse response, QueryResult result) throws IOException, SQLException { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename=\"query_result.xlsx\""); + + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Query Results"); + + if (!result.isQuery()) { + // Non-query result + Row headerRow = sheet.createRow(0); + headerRow.createCell(0).setCellValue("Success"); + headerRow.createCell(1).setCellValue("Message"); + headerRow.createCell(2).setCellValue("RowsAffected"); + + Row dataRow = sheet.createRow(1); + dataRow.createCell(0).setCellValue("true"); + dataRow.createCell(1).setCellValue("SQL executed successfully"); + dataRow.createCell(2).setCellValue(result.getUpdateCount()); + } else { + List> data = result.getData(); + + if (!data.isEmpty()) { + // Create header row + Row headerRow = sheet.createRow(0); + Map firstRow = data.get(0); + int colIndex = 0; + for (String key : firstRow.keySet()) { + Cell cell = headerRow.createCell(colIndex++); + cell.setCellValue(key); + + // Style header cells + CellStyle headerStyle = workbook.createCellStyle(); + Font headerFont = workbook.createFont(); + headerFont.setBold(true); + headerStyle.setFont(headerFont); + cell.setCellStyle(headerStyle); + } + + // Create data rows + int rowIndex = 1; + for (Map row : data) { + Row excelRow = sheet.createRow(rowIndex++); + colIndex = 0; + for (Object value : row.values()) { + Cell cell = excelRow.createCell(colIndex++); + if (value != null) { + if (value instanceof Number) { + cell.setCellValue(((Number) value).doubleValue()); + } else { + cell.setCellValue(value.toString()); + } + } + } + } + + // Auto-size columns + for (int i = 0; i < firstRow.size(); i++) { + sheet.autoSizeColumn(i); + } + } + } + + // Write to response + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + workbook.write(baos); + baos.writeTo(response.getOutputStream()); + } + } + } + + /** + * Sends an error response in JSON format. + * + * @param response HTTP response + * @param statusCode HTTP status code + * @param message Error message + * @throws IOException if writing fails + */ + public void sendErrorResponse(HttpServletResponse response, int statusCode, String message) throws IOException { + response.setStatus(statusCode); + response.setContentType("application/json; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("error", message); + errorResponse.put("statusCode", statusCode); + + + objectMapper.writeValue(response.getWriter(), errorResponse); + } + + /** + * Escapes XML special characters. + */ + private String escapeXml(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** + * Escapes HTML special characters. + */ + private String escapeHtml(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + /** + * Escapes CSV special characters. + */ + private String escapeCsv(String text) { + return text.replace("\"", "\"\""); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWABadRequestException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWABadRequestException.java new file mode 100644 index 0000000..2c22f7f --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWABadRequestException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWABadRequestException extends RestWAException { + + private static final long serialVersionUID = 4190967117943058893L; + + public RestWABadRequestException() { + this("One or more properties in request is not valid or is missing.", new Object[0]); + } + + public RestWABadRequestException(String message, Object... args) { + super(400, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAConflictException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAConflictException.java new file mode 100644 index 0000000..73add18 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAConflictException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAConflictException extends RestWAException { + + private static final long serialVersionUID = 4445144621889651977L; + + public RestWAConflictException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAConflictException(String message, Object... args) { + super(409, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAException.java new file mode 100644 index 0000000..1e14234 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAException.java @@ -0,0 +1,165 @@ +package dev.alexzaw.rest4i.exception; + +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.time.Instant; +import java.util.HashMap; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +public abstract class RestWAException extends WebApplicationException { + + private static final long serialVersionUID = 4989164091865764152L; + + protected static final String CT_UTF8 = "application/json; charset=utf-8"; + + public static final int SC_BAD_REQUEST = 400; + + public static final int SC_CONFLICT = 409; + + public static final int SC_CREATED = 201; + + public static final int SC_FORBIDDEN = 403; + + public static final int SC_INTERNAL_SERVER_ERROR = 500; + + public static final int SC_INSUFFICIENT_STORAGE = 507; + + public static final int SC_NO_CONTENT = 204; + + public static final int SC_NOT_FOUND = 404; + + public static final int SC_NOT_MODIFIED = 304; + + public static final int SC_OK = 200; + + public static final int SC_PARTIAL_CONTENT = 206; + + public static final int SC_PRECONDITION_FAILED = 412; + + public static final int SC_REQUEST_ENTITY_TOO_LARGE = 413; + + public static final int SC_REQUEST_TIMEOUT = 408; + + public static final int SC_SERVICE_UNAVAILABLE = 503; + + public static final int SC_TOO_MANY_REQUESTS = 429; + + public static final int SC_UNAUTHORIZED = 401; + + private static HashMap statusMap = new HashMap<>(); + + static { + statusMap.put(Integer.valueOf(400), "BAD_REQUEST"); + statusMap.put(Integer.valueOf(409), "CONFLICT"); + statusMap.put(Integer.valueOf(201), "CREATED"); + statusMap.put(Integer.valueOf(403), "FORBIDDEN"); + statusMap.put(Integer.valueOf(500), "INTERNAL_SERVER_ERROR"); + statusMap.put(Integer.valueOf(507), "INSUFFICIENT_STORAGE"); + statusMap.put(Integer.valueOf(204), "NO_CONTENT"); + statusMap.put(Integer.valueOf(404), "NOT_FOUND"); + statusMap.put(Integer.valueOf(304), "NOT_MODIFIED"); + statusMap.put(Integer.valueOf(200), "OK"); + statusMap.put(Integer.valueOf(206), "PARTIAL_CONTENT"); + statusMap.put(Integer.valueOf(412), "PRECONDITION_FAILED"); + statusMap.put(Integer.valueOf(413), "ENTITY_TOO_LARGE"); + statusMap.put(Integer.valueOf(408), "REQUEST_TIMEOUT"); + statusMap.put(Integer.valueOf(503), "SERVICE_UNAVAILABLE"); + statusMap.put(Integer.valueOf(429), "TOO_MANY_REQUESTS"); + statusMap.put(Integer.valueOf(401), "UNAUTHORIZED"); + } + + public RestWAException(int statusCode, String contentType, String message, Object[] args) { + super(Response.status(statusCode).entity(toJSON(statusCode, (args == null || args.length == 0) ? message : String.format(message, args))).type(contentType).build()); + } + + public RestWAException(int statusCode, String contentType, String header, String headerValue, String message, Object[] args) { + super(Response.status(statusCode).entity(toJSON(statusCode, (args == null || args.length == 0) ? message : String.format(message, args))).header(header, headerValue).type(contentType).build()); + } + + public RestWAException(int statusCode, String message) { + this(statusCode, "application/json; charset=utf-8", message, null); + } + + public RestWAException(int statusCode, String contentType, String message) { + this(statusCode, contentType, message, null); + } + + public static String toJSON(int status, String message) { + StringBuilder sb = new StringBuilder(); + Instant timestamp = Instant.now(); + HttpServletRequest req = CommonUtil._serviceContext.get(); + String instance = null; + if (req != null) + instance = req.getRequestURI(); + if (instance == null) + instance = ""; + String method = req.getMethod(); + sb.append("{ "); + sb.append("\"").append("title").append("\": \"").append(toString(status)).append("\", "); + sb.append("\"").append("status").append("\": ").append(status).append(", "); + sb.append("\"").append("detail").append("\": \"").append(JSONSerializer.escapeJSON(message)).append("\", "); + sb.append("\"").append("method").append("\": \"").append(JSONSerializer.escapeJSON(method)).append("\", "); + sb.append("\"").append("instance").append("\": \"").append(JSONSerializer.escapeJSON(instance)).append("\", "); + sb.append("\"").append("timestamp").append("\": \"").append(timestamp.toString()).append("\" "); + sb.append("}"); + return sb.toString(); + } + + public static String toString(int status) { + String stringStatus = statusMap.get(Integer.valueOf(status)); + return (stringStatus == null) ? "Unable to process the request due to an internal error." : stringStatus; + } + + public static class ErrorInfo { + private String message = "Unable to process the request due to an internal error."; + + private int httpStatusCode = 500; + + private Integer rc = null; + + public ErrorInfo(int httpStatusCode, String message) { + this(null, httpStatusCode, message, new Object[0]); + } + + public ErrorInfo(Integer rc, int httpStatusCode, String message, Object... args) { + this.rc = rc; + this.message = (args == null || args.length == 0) ? message : String.format(message, args); + this.httpStatusCode = httpStatusCode; + } + + public int getRC() { + return this.rc.intValue(); + } + + public void generateException() { + String excMessage = this.message; + if (this.rc != null) + excMessage = String.valueOf(excMessage) + " [" + this.rc + "]"; + switch (this.httpStatusCode) { + case 400: + throw new RestWABadRequestException(excMessage, new Object[0]); + case 409: + throw new RestWAConflictException(excMessage, new Object[0]); + case 403: + throw new RestWAForbiddenException(excMessage, new Object[0]); + case 507: + throw new RestWAInsufficientStorageException(excMessage, new Object[0]); + case 404: + throw new RestWANotFoundException(excMessage, new Object[0]); + case 412: + throw new RestWAPreconditionFailedException(excMessage, new Object[0]); + case 413: + throw new RestWAReqEntityTooLargeException(excMessage, new Object[0]); + case 408: + throw new RestWARequestTimeoutException(excMessage, new Object[0]); + case 503: + throw new RestWAServiceUnavailableException(excMessage, new Object[0]); + case 401: + throw new RestWAUnauthorizedException(excMessage, new Object[0]); + } + throw new RestWAInternalServerErrorException(excMessage, new Object[0]); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAForbiddenException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAForbiddenException.java new file mode 100644 index 0000000..6d75c94 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAForbiddenException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAForbiddenException extends RestWAException { + + private static final long serialVersionUID = -6913303980223665012L; + + public RestWAForbiddenException() { + this("Not authorized to perform operation.", new Object[0]); + } + + public RestWAForbiddenException(String message, Object... args) { + super(403, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAInsufficientStorageException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAInsufficientStorageException.java new file mode 100644 index 0000000..42b61f8 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAInsufficientStorageException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAInsufficientStorageException extends RestWAException { + + private static final long serialVersionUID = 4190967117943058893L; + + public RestWAInsufficientStorageException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAInsufficientStorageException(String message, Object... args) { + super(400, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAInternalServerErrorException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAInternalServerErrorException.java new file mode 100644 index 0000000..abb7be4 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAInternalServerErrorException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAInternalServerErrorException extends RestWAException { + + private static final long serialVersionUID = -506355880927076778L; + + public RestWAInternalServerErrorException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAInternalServerErrorException(String message, Object... args) { + super(500, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWANoContentException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWANoContentException.java new file mode 100644 index 0000000..bced502 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWANoContentException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWANoContentException extends RestWAException { + + private static final long serialVersionUID = 8198515127228029616L; + + public RestWANoContentException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWANoContentException(String message, Object... args) { + super(204, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWANotFoundException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWANotFoundException.java new file mode 100644 index 0000000..54c6716 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWANotFoundException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWANotFoundException extends RestWAException { + + private static final long serialVersionUID = -8096938855779969881L; + + public RestWANotFoundException() { + this("The specified resource was not found.", new Object[0]); + } + + public RestWANotFoundException(String message, Object... args) { + super(404, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAPreconditionFailedException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAPreconditionFailedException.java new file mode 100644 index 0000000..75820ce --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAPreconditionFailedException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAPreconditionFailedException extends RestWAException { + + private static final long serialVersionUID = 4445144621889651977L; + + public RestWAPreconditionFailedException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAPreconditionFailedException(String message, Object... args) { + super(412, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAReqEntityTooLargeException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAReqEntityTooLargeException.java new file mode 100644 index 0000000..b4f9cd1 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAReqEntityTooLargeException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAReqEntityTooLargeException extends RestWAException { + + private static final long serialVersionUID = 8526782864122573210L; + + public RestWAReqEntityTooLargeException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAReqEntityTooLargeException(String message, Object... args) { + super(413, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWARequestTimeoutException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWARequestTimeoutException.java new file mode 100644 index 0000000..35ffce6 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWARequestTimeoutException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWARequestTimeoutException extends RestWAException { + + private static final long serialVersionUID = -3140219541419534066L; + + public RestWARequestTimeoutException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWARequestTimeoutException(String message, Object... args) { + super(408, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAServiceUnavailableException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAServiceUnavailableException.java new file mode 100644 index 0000000..4a740e9 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAServiceUnavailableException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAServiceUnavailableException extends RestWAException { + + private static final long serialVersionUID = 1692904038368979134L; + + public RestWAServiceUnavailableException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAServiceUnavailableException(String message, Object... args) { + super(503, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedException.java new file mode 100644 index 0000000..a72211f --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAUnauthorizedException extends RestWAException { + + private static final long serialVersionUID = -3140219541419534066L; + + public RestWAUnauthorizedException() { + this("Unable to process the request due to an internal error.", new Object[0]); + } + + public RestWAUnauthorizedException(String message, Object... args) { + super(401, "application/json; charset=utf-8", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedMustAuthenticateException.java b/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedMustAuthenticateException.java new file mode 100644 index 0000000..0105f80 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/exception/RestWAUnauthorizedMustAuthenticateException.java @@ -0,0 +1,14 @@ +package dev.alexzaw.rest4i.exception; + +public class RestWAUnauthorizedMustAuthenticateException extends RestWAException { + + private static final long serialVersionUID = -4971596259925231796L; + + public RestWAUnauthorizedMustAuthenticateException() { + this("User ID or password is not set or not valid.", new Object[0]); + } + + public RestWAUnauthorizedMustAuthenticateException(String message, Object... args) { + super(401, "application/json; charset=utf-8", "WWW-Authenticate", "Bearer", message, args); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/fileops/SMBFileManager.java b/src/main/java/dev/alexzaw/rest4i/fileops/SMBFileManager.java new file mode 100644 index 0000000..3d34c02 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/fileops/SMBFileManager.java @@ -0,0 +1,290 @@ +package dev.alexzaw.rest4i.fileops; + +import jcifs.CIFSContext; +import jcifs.context.SingletonContext; +import jcifs.smb.*; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * SMB File Manager for handling SMB/CIFS file operations. + * Provides file operations like list, read, upload, download, and zip. + * + * @author alexzaw + */ +public class SMBFileManager { + + private static final ConcurrentMap AUTH_CONTEXTS = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + + public SMBFileManager() { + this.objectMapper = new ObjectMapper(); + } + + /** + * Creates or retrieves a CIFS authentication context for the given credentials. + * + * @param domain The domain name + * @param username The username + * @param password The password + * @return CIFSContext for authenticated operations + */ + public CIFSContext getAuthContext(String domain, String username, String password) { + String key = domain + ":" + username; + + return AUTH_CONTEXTS.computeIfAbsent(key, k -> { + SingletonContext singletonContext = SingletonContext.getInstance(); + return singletonContext.withCredentials( + new NtlmPasswordAuthentication(singletonContext, domain, username, password)); + }); + } + + /** + * Lists files and directories in the specified SMB path. + * + * @param smbPath The SMB path to list + * @param auth The authentication context + * @return List of file information maps + * @throws IOException if listing fails + */ + public List> listFiles(String smbPath, CIFSContext auth) throws IOException { + List> fileList = new ArrayList<>(); + + String smbDir = smbPath.endsWith("/") ? smbPath : smbPath + "/"; + String windowsPath = smbDir.replace("smb:", "").replace("/", "\\"); + + SmbFile directory = new SmbFile(smbDir, auth); + + if (!directory.exists()) { + throw new FileNotFoundException("Directory does not exist: " + smbPath); + } + + if (!directory.isDirectory()) { + throw new IOException("Path is not a directory: " + smbPath); + } + + int fileCount = 0; + String[] urlParts = smbDir.split("/"); + String smbShare = urlParts.length >= 4 ? urlParts[3] : ""; + + for (SmbFile file : directory.listFiles()) { + fileCount++; + String fileName = file.getName(); + Map fileInfo = new HashMap<>(); + fileInfo.put("fullPath", windowsPath + fileName); + fileInfo.put("fileName", fileName); + fileInfo.put("folder", windowsPath); + fileInfo.put("share", smbShare); + fileInfo.put("srn", Integer.toString(fileCount)); + fileInfo.put("isDirectory", String.valueOf(file.isDirectory())); + fileInfo.put("size", String.valueOf(file.length())); + fileInfo.put("lastModified", String.valueOf(file.getLastModified())); + fileList.add(fileInfo); + } + + return fileList; + } + + /** + * Reads a file from the SMB path and returns its content as InputStream. + * + * @param smbPath The SMB path to the file + * @param auth The authentication context + * @return InputStream containing file content + * @throws IOException if reading fails + */ + public SmbFileInputStream readFile(String smbPath, CIFSContext auth) throws IOException { + SmbFile smbFile = new SmbFile(smbPath, auth); + + if (!smbFile.exists()) { + throw new FileNotFoundException("File does not exist: " + smbPath); + } + + if (smbFile.isDirectory()) { + throw new IOException("Path is a directory, not a file: " + smbPath); + } + + return new SmbFileInputStream(smbFile); + } + + /** + * Uploads a file to the SMB path. + * + * @param smbPath The SMB path where to upload + * @param fileName The name of the file + * @param inputStream The input stream containing file content + * @param auth The authentication context + * @throws IOException if upload fails + */ + public void uploadFile(String smbPath, String fileName, InputStream inputStream, CIFSContext auth) throws IOException { + String smbDir = smbPath.endsWith("/") ? smbPath : smbPath + "/"; + String fullPath = smbDir + fileName; + + // Ensure directory exists + SmbFile directory = new SmbFile(smbDir, auth); + if (!directory.exists()) { + directory.mkdirs(); + } + + try (SmbFileOutputStream out = new SmbFileOutputStream(new SmbFile(fullPath, auth))) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + } + } + + /** + * Creates a directory at the specified SMB path. + * + * @param smbPath The SMB path for the new directory + * @param auth The authentication context + * @return true if created, false if already exists + * @throws IOException if creation fails + */ + public boolean createDirectory(String smbPath, CIFSContext auth) throws IOException { + SmbFile smbDir = new SmbFile(smbPath, auth); + + if (smbDir.exists()) { + return false; // Already exists + } + + smbDir.mkdirs(); + return true; + } + + /** + * Checks if a file or directory exists at the specified SMB path. + * + * @param smbPath The SMB path to check + * @param auth The authentication context + * @return true if exists, false otherwise + * @throws IOException if check fails + */ + public boolean exists(String smbPath, CIFSContext auth) throws IOException { + SmbFile smbFile = new SmbFile(smbPath, auth); + return smbFile.exists(); + } + + /** + * Creates a ZIP archive of all files in the specified directory. + * + * @param smbPath The SMB path to the directory + * @param outputStream The output stream to write the ZIP to + * @param auth The authentication context + * @throws IOException if zipping fails + */ + public void zipDirectory(String smbPath, OutputStream outputStream, CIFSContext auth) throws IOException { + String smbDir = smbPath.endsWith("/") ? smbPath : smbPath + "/"; + SmbFile directory = new SmbFile(smbDir, auth); + + if (!directory.exists()) { + throw new FileNotFoundException("Directory does not exist: " + smbPath); + } + + if (!directory.isDirectory()) { + throw new IOException("Path is not a directory: " + smbPath); + } + + try (ZipOutputStream zos = new ZipOutputStream(outputStream)) { + SmbFile[] files = directory.listFiles(); + + for (SmbFile file : files) { + if (file.isDirectory()) { + continue; // Skip directories for now + } + + String fileName = file.getName(); + zos.putNextEntry(new ZipEntry(fileName)); + + try (InputStream fis = new SmbFileInputStream(file)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + zos.write(buffer, 0, bytesRead); + } + } + + zos.closeEntry(); + } + } + } + + /** + * Builds a UNC path from a file path, handling drive mappings and server resolution. + * + * @param filePath The file path to convert + * @param serverMappings Properties containing server and drive mappings + * @return UNC SMB path + */ + public String buildUncPath(String filePath, Properties serverMappings) { + List validDrives = Arrays.asList("F:", "G:", "L:", "K:", "S:", "P:", "M:", "R:"); + + String normalized = filePath + .replaceAll("[/\\\\]+", "|") // Replace any slashes/backslashes with | + .replaceAll("^\\|+", ""); + + List filePartsList = arrayTrim(new ArrayList<>(Arrays.asList(normalized.split("\\|")))); + String[] fileParts = filePartsList.toArray(new String[0]); + + String smbServer = fileParts[0]; + String smbShare = ""; + + if (validDrives.contains(fileParts[0].substring(0, Math.min(2, fileParts[0].length())))) { + smbServer = "DEFAULT"; + smbShare = serverMappings.getProperty("smb." + fileParts[0].substring(0, 1), ""); + } + + String server = getFQDN(smbServer, serverMappings); + String sanitizedPath = String.join("/", Arrays.copyOfRange(fileParts, 1, fileParts.length)) + .replaceAll("/{2,}", "/"); + + String uncPath = "smb://" + String.format("%s/%s/%s", server, smbShare, sanitizedPath) + .replaceAll("/{2,}", "/"); + + return uncPath; + } + + /** + * Gets the FQDN for a server name from the mappings. + */ + private String getFQDN(String server, Properties serverMappings) { + String inServer = server.toUpperCase(); + List validServers = Arrays.asList("MINICIRCUITS.COM", "MCL_NEW_YORK", "MCL_NEW_YORK.MINICIRCUITS.COM", "192.168.3.11", "DEFAULT"); + + if (!validServers.contains(inServer)) { + throw new RuntimeException("Unable to determine server: " + server); + } + + String outServer = serverMappings.getProperty("smb.server", "localhost"); + return outServer.toLowerCase(); + } + + /** + * Trims empty strings from a list. + */ + private List arrayTrim(List list) { + List trimmedList = new ArrayList<>(); + for (String s : list) { + if (!s.trim().isEmpty()) { + trimmedList.add(s.trim()); + } + } + return trimmedList; + } + + /** + * Decodes a Base64 encoded string. + */ + public String decodeBase64(String encodedString) { + return new String(Base64.getDecoder().decode(encodedString), StandardCharsets.UTF_8); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/messages/Messages.java b/src/main/java/dev/alexzaw/rest4i/messages/Messages.java new file mode 100644 index 0000000..b0b4768 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/messages/Messages.java @@ -0,0 +1,190 @@ +package dev.alexzaw.rest4i.messages; + +public class Messages { + + public static final String REST_SERVICE_UNITIALIZED = "Service is not initialized."; + + public static final String ERR_DEFAULT_MSG = "Unable to process the request due to an internal error."; + + public static final String ERR_DEFAULT_MSGX = "Unable to process the request due to an internal error. %s"; + + public static final String ERR_BAD_PROPERTIES = "One or more properties in request is not valid or is missing."; + + public static final String ERR_FORBIDDEN = "Not authorized to perform operation."; + + public static final String ERR_NOT_FOUND = "The specified resource was not found."; + + public static final String ERR_FUNCTION_DISABLED = "The API '%s' is currently not enabled."; + + public static final String OP_NOT_SUPPORTED_PATH = "Operation on object referenced by the path '%s' is not supported."; + + public static final String OP_NOT_SUPPORTED = "Operation not supported."; + + public static final String NOT_NOW_ERROR = "Operation cannot be completed at this time."; + + public static final String BAD_PARM_VALUE = "A parameter value is not valid."; + + public static final String BAD_PARM_VALUE_LEN = "The length of a parameter value is not valid."; + + public static final String BAD_PROP_VALUE = "The value for property '%s' is not valid."; + + public static final String BAD_PARM_VALUE2 = "The value for parameter '%s' is not valid."; + + public static final String BAD_PARM_VALUE_LEN2 = "The length of the value for parameter '%s' is not valid."; + + public static final String BAD_PROP_USING_DEFAULT = "The value for property '%s' is not valid. Using default value of %s. "; + + public static final String SAVE_CONFIG_FAILED = "Save of properties failed. %s"; + + public static final String EXCEPTION_MESSAGE = "Exception in %s"; + + public static final String REFLECTED_XSS_DETECTED = "Reflected cross site scripting vulnerabilities detected in input data."; + + public static final String COMBOVALUES_NOT_SUPPORTED = "The combination of values specified is not supported."; + + public static final String QSYS_OBJECT_NAME_MISSING = "Object name not set."; + + public static final String OP_ON_PATH_FAILED = "Operation on object referenced by path '%s' failed. %s"; + + public static final String PATH_NAME_NOT_PF = "Path '%s' does not reference a physical file."; + + public static final String PATH_NAME_NOT_FOUND = "Object referenced by the path '%s' is not found or inaccessible."; + + public static final String PATH_NAME_NOT_VALID = "Path '%s' is not valid."; + + public static final String PATH_NAME_REQUIRED = "Path name not set."; + + public static final String SEARCH_TEXT_REQUIRED = "Path name and file name are required."; + + public static final String PATH_NAME_AND_TYPE_REQUIRED = "Path name and type are required."; + + public static final String UNABLE_TO_CREATE_PATH_NAME = "Unable to create the path name."; + + public static final String PATH_NAME_AND_ENCODING_REQUIRED = "Path name and encoding configuration are required."; + + public static final String PATH_NAME_AND_CONTENT_REQUIRED = "Path name and content are required."; + + public static final String UNABLE_TO_UPLOAD_CONTENT_FOR_FILE = "Unable to upload the content for the file. File not found or inaccessible."; + + public static final String UNABLE_TO_DELETE_PATH = "Unable to delete the path. File not found or permission is required."; + + public static final String UNABLE_TO_RENAME_FILE = "Unable to rename the file."; + + public static final String UNABLE_TO_COPY_FILE = "Unable to copy the file."; + + public static final String ENCODING_IS_INVALID = "The specified encoding '%s' is not valid."; + + public static final String ENCODING_SETTING_NOT_SUPPORTED = "Encoding setting is not supported."; + + public static final String DELETE_FAILED = "Unable to delete object specified by path '%s'."; + + public static final String BAD_FILE_SIZE = "File '%s' has size %d which exceeds maximum allowed file size of %d."; + + public static final String TARGET_ALREADY_EXISTS = "Target path '%s' already exists or path is not valid."; + + public static final String ETAG_CONFLICT = "Update failed. File referenced by path '%s' does not match client version."; + + public static final String PATH_NOT_DIR = "Object referenced by the path '%s' is not a directory."; + + public static final String UNABLE_CHECKSUM = "Unable to checksum file referenced by path '%s' with the error '%s'."; + + public static final String NET_ACCEPT_ERROR_SSL = "Resource must be accessed with a secure connection."; + + public static final String NET_CONNECT_ERROR_SSL = "Unable to connect to server over a secure communication channel. %s"; + + public static final String NET_CONNECT_ERROR = "Unable to connect to server. %s"; + + public static final String NET_UNKNOWN_HOST = "The host '%s' is not known."; + + public static final String NET_CONNECTION_DROPPED = "The connection to server dropped unexpectedly. Retry operation."; + + public static final String AUTH_CRED_INVALID = "User ID or password is not set or not valid."; + + public static final String AUTH_HOST_INVALID = "Host name not set or server is not an IBM i server."; + + public static final String AUTH_TOKEN_INVALID = "Authorization token not valid or may have expired."; + + public static final String AUTH_TOKEN_MISSING = "Authorization token not found."; + + public static final String AUTH_TOKEN_ORIGINATION_ERROR = "Requestor is not originator of the authorization token."; + + public static final String AUTH_TOKEN_EXPIRED = "Authorization token has expired."; + + public static final String AUTH_USER_NOTADMIN = "User not an administrator."; + + public static final String AUTH_NOT_LOCALHOST = "Host connection is not set to localhost."; + + public static final String CL_COMMAND_MISSING = "Invalid command. At least one CL command must be specified."; + + public static final String CL_COMMAND_FAILED = "CL command failed. Command is [%s]. Error message(s): %s"; + + public static final String CL_COMMAND_BAD_NAME = "CL command not specified or not valid."; + + public static final String CL_COMMAND_BAD_LIBRARY = "Library not specified or not valid."; + + public static final String SQL_STATEMENT_MISSING = "Invalid SQL statement. At least one SQL statement must be specified."; + + public static final String SQL_STATEMENT_FAIL = "Session initialization failed. The failed SQL statement is [%s]. Error message(s): %s"; + + public static final String ERROR_MAX_SESSIONS = "Maximum number of sessions has been reached."; + + public static final String ERROR_MAX_SESSIONS_USER = "Maximum number of sessions per user has been reached."; + + public static final String HOSTNAME_RESOLUTION_ERROR = "Unable to get canonical host name."; + + public static final String SESSION_IN_USE = "Request timed out while waiting for the session to become available."; + + public static final String REQUEST_INTERRUPTED = "Request interrupted while waiting for session to become available."; + + public static final String CERT_STORE_NOT_FOUND = "Certificate store not found or inaccessible."; + + public static final String CERT_KEYSTORE_NOT_VALID = "Certificate store not supported or is not a valid certificate store."; + + public static final String CERT_DATA_NOT_VALID = "Certificate data not valid or not in the correct format. %s"; + + public static final String CERT_NOT_SUPPORTED = "Certificate not supported. %s"; + + public static final String GSKMSG_UNKNOWN = "Unknown error occurred while using internal DCM SPI."; + + public static final String GSKMSG_NOSTASHFILE = "Stash file does not exist."; + + public static final String GSKMSG_CERT_NOPRIVATEKEY = "One or more certificates does not have a private key"; + + public static final String GSKMSG_DBALREADYEXISTS = "Certificate store exists."; + + public static final String GSKMSG_DBPASSWORD = "Certificate store password not valid. If this is an attempt to change the password, the new password must not match existing password."; + + public static final String GSKMSG_DBTYPE = "Certificate store type not valid."; + + public static final String GSKMSG_DBDUPKEY = "Duplicate alias in certificate store."; + + public static final String GSKMSG_DBCREATE = "Certificate store failed to create."; + + public static final String GSKMSG_CERT_NOTFOUND_ERROR = "Certificate not found."; + + public static final String GSKMSG_FILEOPEN_ERROR = "Error occurred when attempting to open file."; + + public static final String GSKMSG_CERT_DATA_ERROR = "Certificate data is not valid or is not in the correct format."; + + public static final String GSKMSG_CERT_VALIDATION_FAILURE = "Certificate validation failed. %s"; + + public static final String GSKMSG_APPDEF_NOTFOUND = "Application definition not found."; + + public static final String GSKMSG_PASSWORDNOT_VALID = "Password not valid."; + + public static final String GSKMSG_CA_INUSE = "Requested operation failed because the certificate is being used."; + + public static final String GSK_KEYSTORE_NAME_ERROR = "Keystore name is not valid."; + + public static final String GSKMSG_PKCS12_PWD_ERROR = "PKCS12 certificate password not valid."; + + public static final String GSKMSG_ALGO_ERROR = "Algorithm is not support or is not valid."; + + public static final String GSK_CCA_DEFAULTMESSAGE = "Common Cryptographic Architecture Interface error. %s"; + + public static final String GSKMSG_INDEX = "Stash file error. %s"; + + public static final String GSKMSG_CERT_EXPORT_ERROR = "The export of certificate in the requested format is not supported."; + + public static final String GSKMSG_CERT_IMPORT_ERROR = "The import of certificate in the specified format is not supported."; +} diff --git a/src/main/java/dev/alexzaw/rest4i/model/ApiResponse.java b/src/main/java/dev/alexzaw/rest4i/model/ApiResponse.java new file mode 100644 index 0000000..d9853a6 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/model/ApiResponse.java @@ -0,0 +1,305 @@ +package dev.alexzaw.rest4i.model; + +import java.time.Instant; +import java.util.Map; + +/** + * Generic API response wrapper that provides a standardized response structure + * for all REST4i integration endpoints. + * + * This wrapper ensures consistent response format across all services while + * maintaining compatibility with existing IBM i RSE API patterns. + * + * @param The type of data being returned + * @author alexzaw + */ +public class ApiResponse { + + private boolean success; + private T data; + private ErrorInfo error; + private PaginationInfo pagination; + private String message; + private String timestamp; + private Map metadata; + + /** + * Default constructor for successful responses without data. + */ + public ApiResponse() { + this.success = true; + this.timestamp = Instant.now().toString(); + } + + /** + * Constructor for successful responses with data. + * + * @param data The response data + */ + public ApiResponse(T data) { + this(); + this.data = data; + } + + /** + * Constructor for successful responses with data and message. + * + * @param data The response data + * @param message A success message + */ + public ApiResponse(T data, String message) { + this(data); + this.message = message; + } + + /** + * Constructor for error responses. + * + * @param error The error information + */ + public ApiResponse(ErrorInfo error) { + this.success = false; + this.error = error; + this.timestamp = Instant.now().toString(); + } + + /** + * Creates a successful response with data. + * + * @param The type of data + * @param data The response data + * @return ApiResponse with success=true and the provided data + */ + public static ApiResponse success(T data) { + return new ApiResponse<>(data); + } + + /** + * Creates a successful response with data and message. + * + * @param The type of data + * @param data The response data + * @param message A success message + * @return ApiResponse with success=true, data, and message + */ + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(data, message); + } + + /** + * Creates a successful response with just a message (no data). + * + * @param message A success message + * @return ApiResponse with success=true and the provided message + */ + public static ApiResponse success(String message) { + ApiResponse response = new ApiResponse<>(); + response.setMessage(message); + return response; + } + + /** + * Creates a successful paginated response. + * + * @param The type of data + * @param data The response data + * @param pagination Pagination information + * @return ApiResponse with success=true, data, and pagination info + */ + public static ApiResponse success(T data, PaginationInfo pagination) { + ApiResponse response = new ApiResponse<>(data); + response.setPagination(pagination); + return response; + } + + /** + * Creates an error response. + * + * @param error The error information + * @return ApiResponse with success=false and error details + */ + public static ApiResponse error(ErrorInfo error) { + return new ApiResponse<>(error); + } + + /** + * Creates an error response with simple error message. + * + * @param errorMessage The error message + * @return ApiResponse with success=false and error message + */ + public static ApiResponse error(String errorMessage) { + return new ApiResponse<>(new ErrorInfo("GENERAL_ERROR", errorMessage)); + } + + /** + * Creates an error response with error code and message. + * + * @param errorCode The error code + * @param errorMessage The error message + * @return ApiResponse with success=false and error details + */ + public static ApiResponse error(String errorCode, String errorMessage) { + return new ApiResponse<>(new ErrorInfo(errorCode, errorMessage)); + } + + /** + * Creates an error response from an exception. + * + * @param exception The exception + * @return ApiResponse with success=false and exception details + */ + public static ApiResponse error(Exception exception) { + return new ApiResponse<>(ErrorInfo.fromException(exception)); + } + + // Getters and Setters + + /** + * Gets the success status. + * + * @return true if the operation was successful, false otherwise + */ + public boolean isSuccess() { + return success; + } + + /** + * Sets the success status. + * + * @param success The success status + */ + public void setSuccess(boolean success) { + this.success = success; + } + + /** + * Gets the response data. + * + * @return The response data + */ + public T getData() { + return data; + } + + /** + * Sets the response data. + * + * @param data The response data + */ + public void setData(T data) { + this.data = data; + } + + /** + * Gets the error information. + * + * @return The error information, null if no error + */ + public ErrorInfo getError() { + return error; + } + + /** + * Sets the error information. + * + * @param error The error information + */ + public void setError(ErrorInfo error) { + this.error = error; + if (error != null) { + this.success = false; + } + } + + /** + * Gets the pagination information. + * + * @return The pagination information, null if not paginated + */ + public PaginationInfo getPagination() { + return pagination; + } + + /** + * Sets the pagination information. + * + * @param pagination The pagination information + */ + public void setPagination(PaginationInfo pagination) { + this.pagination = pagination; + } + + /** + * Gets the response message. + * + * @return The response message + */ + public String getMessage() { + return message; + } + + /** + * Sets the response message. + * + * @param message The response message + */ + public void setMessage(String message) { + this.message = message; + } + + /** + * Gets the response timestamp. + * + * @return The ISO-8601 formatted timestamp + */ + public String getTimestamp() { + return timestamp; + } + + /** + * Sets the response timestamp. + * + * @param timestamp The ISO-8601 formatted timestamp + */ + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + /** + * Gets additional metadata. + * + * @return The metadata map + */ + public Map getMetadata() { + return metadata; + } + + /** + * Sets additional metadata. + * + * @param metadata The metadata map + */ + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + /** + * Adds a metadata entry. + * + * @param key The metadata key + * @param value The metadata value + */ + public void addMetadata(String key, Object value) { + if (this.metadata == null) { + this.metadata = new java.util.HashMap<>(); + } + this.metadata.put(key, value); + } + + @Override + public String toString() { + return String.format("ApiResponse{success=%s, message='%s', hasData=%s, hasError=%s, timestamp='%s'}", + success, message, data != null, error != null, timestamp); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/model/ErrorInfo.java b/src/main/java/dev/alexzaw/rest4i/model/ErrorInfo.java new file mode 100644 index 0000000..7cfb25c --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/model/ErrorInfo.java @@ -0,0 +1,401 @@ +package dev.alexzaw.rest4i.model; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** + * Error information model for consistent error handling across all REST4i integration services. + * + * Provides structured error information including error codes, messages, details, + * and additional context to help with debugging and error resolution. + * + * @author alexzaw + */ +public class ErrorInfo { + + private String code; + private String message; + private String details; + private String timestamp; + private String path; + private String method; + private int httpStatus; + private List validationErrors; + private Map context; + private String stackTrace; + + /** + * Default constructor. + */ + public ErrorInfo() { + this.timestamp = Instant.now().toString(); + this.validationErrors = new ArrayList<>(); + this.context = new HashMap<>(); + } + + /** + * Constructor with error code and message. + * + * @param code The error code + * @param message The error message + */ + public ErrorInfo(String code, String message) { + this(); + this.code = code; + this.message = message; + } + + /** + * Constructor with error code, message, and details. + * + * @param code The error code + * @param message The error message + * @param details Additional error details + */ + public ErrorInfo(String code, String message, String details) { + this(code, message); + this.details = details; + } + + /** + * Constructor with error code, message, and HTTP status. + * + * @param code The error code + * @param message The error message + * @param httpStatus The HTTP status code + */ + public ErrorInfo(String code, String message, int httpStatus) { + this(code, message); + this.httpStatus = httpStatus; + } + + /** + * Creates ErrorInfo from an Exception. + * + * @param exception The exception + * @return ErrorInfo instance + */ + public static ErrorInfo fromException(Exception exception) { + ErrorInfo errorInfo = new ErrorInfo(); + + // Set error code based on exception type + errorInfo.setCode(getErrorCodeFromException(exception)); + errorInfo.setMessage(exception.getMessage() != null ? exception.getMessage() : "An error occurred"); + + // Set HTTP status based on exception type + errorInfo.setHttpStatus(getHttpStatusFromException(exception)); + + // Add stack trace for debugging (in development mode) + if (shouldIncludeStackTrace()) { + errorInfo.setStackTrace(getStackTraceString(exception)); + } + + // Add exception class as context + errorInfo.addContext("exceptionType", exception.getClass().getSimpleName()); + + return errorInfo; + } + + /** + * Creates ErrorInfo for validation errors. + * + * @param message The main validation error message + * @param validationErrors List of specific validation errors + * @return ErrorInfo instance + */ + public static ErrorInfo validation(String message, List validationErrors) { + ErrorInfo errorInfo = new ErrorInfo("VALIDATION_ERROR", message, 400); + errorInfo.setValidationErrors(validationErrors); + return errorInfo; + } + + /** + * Creates ErrorInfo for authentication errors. + * + * @param message The authentication error message + * @return ErrorInfo instance + */ + public static ErrorInfo authentication(String message) { + return new ErrorInfo("AUTHENTICATION_ERROR", message, 401); + } + + /** + * Creates ErrorInfo for authorization errors. + * + * @param message The authorization error message + * @return ErrorInfo instance + */ + public static ErrorInfo authorization(String message) { + return new ErrorInfo("AUTHORIZATION_ERROR", message, 403); + } + + /** + * Creates ErrorInfo for resource not found errors. + * + * @param message The not found error message + * @return ErrorInfo instance + */ + public static ErrorInfo notFound(String message) { + return new ErrorInfo("RESOURCE_NOT_FOUND", message, 404); + } + + /** + * Creates ErrorInfo for conflict errors. + * + * @param message The conflict error message + * @return ErrorInfo instance + */ + public static ErrorInfo conflict(String message) { + return new ErrorInfo("RESOURCE_CONFLICT", message, 409); + } + + /** + * Creates ErrorInfo for internal server errors. + * + * @param message The server error message + * @return ErrorInfo instance + */ + public static ErrorInfo serverError(String message) { + return new ErrorInfo("INTERNAL_SERVER_ERROR", message, 500); + } + + /** + * Creates ErrorInfo for database errors. + * + * @param message The database error message + * @param sqlState The SQL state if available + * @return ErrorInfo instance + */ + public static ErrorInfo database(String message, String sqlState) { + ErrorInfo errorInfo = new ErrorInfo("DATABASE_ERROR", message, 500); + if (sqlState != null) { + errorInfo.addContext("sqlState", sqlState); + } + return errorInfo; + } + + /** + * Creates ErrorInfo for timeout errors. + * + * @param message The timeout error message + * @return ErrorInfo instance + */ + public static ErrorInfo timeout(String message) { + return new ErrorInfo("REQUEST_TIMEOUT", message, 408); + } + + /** + * Gets the error code from exception type. + */ + private static String getErrorCodeFromException(Exception exception) { + String className = exception.getClass().getSimpleName(); + + if (className.contains("SQL")) { + return "DATABASE_ERROR"; + } else if (className.contains("IO") || className.contains("FileNotFound")) { + return "IO_ERROR"; + } else if (className.contains("Security") || className.contains("Auth")) { + return "SECURITY_ERROR"; + } else if (className.contains("Timeout")) { + return "REQUEST_TIMEOUT"; + } else if (className.contains("IllegalArgument") || className.contains("Invalid")) { + return "VALIDATION_ERROR"; + } else { + return "GENERAL_ERROR"; + } + } + + /** + * Gets HTTP status code from exception type. + */ + private static int getHttpStatusFromException(Exception exception) { + String className = exception.getClass().getSimpleName(); + + if (className.contains("IllegalArgument") || className.contains("Invalid")) { + return 400; // Bad Request + } else if (className.contains("Security") || className.contains("Auth")) { + return 401; // Unauthorized + } else if (className.contains("FileNotFound") || className.contains("NotFound")) { + return 404; // Not Found + } else if (className.contains("Timeout")) { + return 408; // Request Timeout + } else { + return 500; // Internal Server Error + } + } + + /** + * Determines if stack trace should be included. + */ + private static boolean shouldIncludeStackTrace() { + // Include stack trace in development mode or if specifically enabled + String includeStackTrace = System.getProperty("rest4i.error.includeStackTrace", "false"); + return "true".equalsIgnoreCase(includeStackTrace); + } + + /** + * Converts exception stack trace to string. + */ + private static String getStackTraceString(Exception exception) { + java.io.StringWriter sw = new java.io.StringWriter(); + java.io.PrintWriter pw = new java.io.PrintWriter(sw); + exception.printStackTrace(pw); + return sw.toString(); + } + + // Getters and Setters + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public int getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(int httpStatus) { + this.httpStatus = httpStatus; + } + + public List getValidationErrors() { + return validationErrors; + } + + public void setValidationErrors(List validationErrors) { + this.validationErrors = validationErrors != null ? validationErrors : new ArrayList<>(); + } + + public void addValidationError(String field, String message) { + this.validationErrors.add(new ValidationError(field, message)); + } + + public void addValidationError(ValidationError validationError) { + this.validationErrors.add(validationError); + } + + public Map getContext() { + return context; + } + + public void setContext(Map context) { + this.context = context != null ? context : new HashMap<>(); + } + + public void addContext(String key, Object value) { + this.context.put(key, value); + } + + public String getStackTrace() { + return stackTrace; + } + + public void setStackTrace(String stackTrace) { + this.stackTrace = stackTrace; + } + + /** + * Validation error details. + */ + public static class ValidationError { + private String field; + private String message; + private Object rejectedValue; + + public ValidationError() {} + + public ValidationError(String field, String message) { + this.field = field; + this.message = message; + } + + public ValidationError(String field, String message, Object rejectedValue) { + this.field = field; + this.message = message; + this.rejectedValue = rejectedValue; + } + + // Getters and Setters + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Object getRejectedValue() { + return rejectedValue; + } + + public void setRejectedValue(Object rejectedValue) { + this.rejectedValue = rejectedValue; + } + + @Override + public String toString() { + return String.format("ValidationError{field='%s', message='%s', rejectedValue=%s}", + field, message, rejectedValue); + } + } + + @Override + public String toString() { + return String.format("ErrorInfo{code='%s', message='%s', httpStatus=%d, timestamp='%s'}", + code, message, httpStatus, timestamp); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/model/PaginationInfo.java b/src/main/java/dev/alexzaw/rest4i/model/PaginationInfo.java new file mode 100644 index 0000000..a15e159 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/model/PaginationInfo.java @@ -0,0 +1,469 @@ +package dev.alexzaw.rest4i.model; + +import java.util.HashMap; +import java.util.Map; + +/** + * Pagination information model for handling large datasets in REST4i integration services. + * + * Provides comprehensive pagination metadata including page numbers, sizes, totals, + * and navigation links to support efficient data retrieval and navigation. + * + * @author alexzaw + */ +public class PaginationInfo { + + private int page; + private int size; + private int totalPages; + private long totalElements; + private boolean first; + private boolean last; + private boolean hasNext; + private boolean hasPrevious; + private int numberOfElements; + private boolean empty; + private SortInfo sort; + private Map links; + + /** + * Default constructor. + */ + public PaginationInfo() { + this.links = new HashMap<>(); + } + + /** + * Constructor with basic pagination parameters. + * + * @param page Current page number (0-based) + * @param size Number of elements per page + * @param totalElements Total number of elements across all pages + */ + public PaginationInfo(int page, int size, long totalElements) { + this(); + this.page = page; + this.size = size; + this.totalElements = totalElements; + calculateDerivedFields(); + } + + /** + * Constructor with all pagination parameters. + * + * @param page Current page number (0-based) + * @param size Number of elements per page + * @param totalElements Total number of elements across all pages + * @param numberOfElements Number of elements in current page + */ + public PaginationInfo(int page, int size, long totalElements, int numberOfElements) { + this(page, size, totalElements); + this.numberOfElements = numberOfElements; + this.empty = numberOfElements == 0; + } + + /** + * Creates pagination info from request parameters. + * + * @param page Current page number (0-based) + * @param size Number of elements per page + * @param totalElements Total number of elements + * @param numberOfElements Number of elements in current page + * @return PaginationInfo instance + */ + public static PaginationInfo of(int page, int size, long totalElements, int numberOfElements) { + return new PaginationInfo(page, size, totalElements, numberOfElements); + } + + /** + * Creates pagination info from request parameters with sorting. + * + * @param page Current page number (0-based) + * @param size Number of elements per page + * @param totalElements Total number of elements + * @param numberOfElements Number of elements in current page + * @param sort Sorting information + * @return PaginationInfo instance + */ + public static PaginationInfo of(int page, int size, long totalElements, int numberOfElements, SortInfo sort) { + PaginationInfo pagination = new PaginationInfo(page, size, totalElements, numberOfElements); + pagination.setSort(sort); + return pagination; + } + + /** + * Creates empty pagination info (no results). + * + * @param page Current page number + * @param size Number of elements per page + * @return PaginationInfo instance representing empty results + */ + public static PaginationInfo empty(int page, int size) { + PaginationInfo pagination = new PaginationInfo(page, size, 0); + pagination.setEmpty(true); + return pagination; + } + + /** + * Calculates derived fields based on basic pagination parameters. + */ + private void calculateDerivedFields() { + this.totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0; + this.first = page == 0; + this.last = page >= totalPages - 1 || totalPages == 0; + this.hasNext = !last && totalPages > 0; + this.hasPrevious = !first; + this.empty = totalElements == 0; + } + + /** + * Generates navigation links for the pagination. + * + * @param baseUrl The base URL for generating links + * @param additionalParams Additional query parameters to include + */ + public void generateLinks(String baseUrl, Map additionalParams) { + StringBuilder baseUrlBuilder = new StringBuilder(baseUrl); + + // Add additional parameters to base URL + if (additionalParams != null && !additionalParams.isEmpty()) { + boolean firstParam = !baseUrl.contains("?"); + for (Map.Entry param : additionalParams.entrySet()) { + baseUrlBuilder.append(firstParam ? "?" : "&"); + baseUrlBuilder.append(param.getKey()).append("=").append(param.getValue()); + firstParam = false; + } + } + + String urlBase = baseUrlBuilder.toString(); + String separator = urlBase.contains("?") ? "&" : "?"; + + // Self link + links.put("self", String.format("%s%spage=%d&size=%d", urlBase, separator, page, size)); + + // First page link + if (totalPages > 0) { + links.put("first", String.format("%s%spage=0&size=%d", urlBase, separator, size)); + } + + // Last page link + if (totalPages > 0) { + links.put("last", String.format("%s%spage=%d&size=%d", urlBase, separator, totalPages - 1, size)); + } + + // Previous page link + if (hasPrevious) { + links.put("prev", String.format("%s%spage=%d&size=%d", urlBase, separator, page - 1, size)); + } + + // Next page link + if (hasNext) { + links.put("next", String.format("%s%spage=%d&size=%d", urlBase, separator, page + 1, size)); + } + } + + /** + * Adds a custom link. + * + * @param rel The link relation + * @param href The link URL + */ + public void addLink(String rel, String href) { + this.links.put(rel, href); + } + + // Getters and Setters + + /** + * Gets the current page number (0-based). + * + * @return The current page number + */ + public int getPage() { + return page; + } + + /** + * Sets the current page number. + * + * @param page The current page number (0-based) + */ + public void setPage(int page) { + this.page = page; + calculateDerivedFields(); + } + + /** + * Gets the number of elements per page. + * + * @return The page size + */ + public int getSize() { + return size; + } + + /** + * Sets the number of elements per page. + * + * @param size The page size + */ + public void setSize(int size) { + this.size = size; + calculateDerivedFields(); + } + + /** + * Gets the total number of pages. + * + * @return The total number of pages + */ + public int getTotalPages() { + return totalPages; + } + + /** + * Gets the total number of elements across all pages. + * + * @return The total number of elements + */ + public long getTotalElements() { + return totalElements; + } + + /** + * Sets the total number of elements. + * + * @param totalElements The total number of elements + */ + public void setTotalElements(long totalElements) { + this.totalElements = totalElements; + calculateDerivedFields(); + } + + /** + * Checks if this is the first page. + * + * @return true if this is the first page + */ + public boolean isFirst() { + return first; + } + + /** + * Checks if this is the last page. + * + * @return true if this is the last page + */ + public boolean isLast() { + return last; + } + + /** + * Checks if there is a next page. + * + * @return true if there is a next page + */ + public boolean isHasNext() { + return hasNext; + } + + /** + * Checks if there is a previous page. + * + * @return true if there is a previous page + */ + public boolean isHasPrevious() { + return hasPrevious; + } + + /** + * Gets the number of elements in the current page. + * + * @return The number of elements in current page + */ + public int getNumberOfElements() { + return numberOfElements; + } + + /** + * Sets the number of elements in the current page. + * + * @param numberOfElements The number of elements in current page + */ + public void setNumberOfElements(int numberOfElements) { + this.numberOfElements = numberOfElements; + this.empty = numberOfElements == 0; + } + + /** + * Checks if the current page is empty. + * + * @return true if the current page has no elements + */ + public boolean isEmpty() { + return empty; + } + + /** + * Sets whether the current page is empty. + * + * @param empty true if the current page has no elements + */ + public void setEmpty(boolean empty) { + this.empty = empty; + } + + /** + * Gets the sorting information. + * + * @return The sorting information + */ + public SortInfo getSort() { + return sort; + } + + /** + * Sets the sorting information. + * + * @param sort The sorting information + */ + public void setSort(SortInfo sort) { + this.sort = sort; + } + + /** + * Gets the navigation links. + * + * @return Map of navigation links + */ + public Map getLinks() { + return links; + } + + /** + * Sets the navigation links. + * + * @param links Map of navigation links + */ + public void setLinks(Map links) { + this.links = links != null ? links : new HashMap<>(); + } + + /** + * Sorting information for paginated results. + */ + public static class SortInfo { + private boolean sorted; + private boolean unsorted; + private boolean empty; + private String property; + private String direction; + + /** + * Default constructor for unsorted results. + */ + public SortInfo() { + this.sorted = false; + this.unsorted = true; + this.empty = true; + } + + /** + * Constructor for sorted results. + * + * @param property The property to sort by + * @param direction The sort direction (ASC or DESC) + */ + public SortInfo(String property, String direction) { + this.property = property; + this.direction = direction != null ? direction.toUpperCase() : "ASC"; + this.sorted = true; + this.unsorted = false; + this.empty = false; + } + + /** + * Creates ascending sort info. + * + * @param property The property to sort by + * @return SortInfo for ascending sort + */ + public static SortInfo asc(String property) { + return new SortInfo(property, "ASC"); + } + + /** + * Creates descending sort info. + * + * @param property The property to sort by + * @return SortInfo for descending sort + */ + public static SortInfo desc(String property) { + return new SortInfo(property, "DESC"); + } + + /** + * Creates unsorted info. + * + * @return SortInfo for unsorted results + */ + public static SortInfo unsorted() { + return new SortInfo(); + } + + // Getters and Setters + public boolean isSorted() { + return sorted; + } + + public void setSorted(boolean sorted) { + this.sorted = sorted; + } + + public boolean isUnsorted() { + return unsorted; + } + + public void setUnsorted(boolean unsorted) { + this.unsorted = unsorted; + } + + public boolean isEmpty() { + return empty; + } + + public void setEmpty(boolean empty) { + this.empty = empty; + } + + public String getProperty() { + return property; + } + + public void setProperty(String property) { + this.property = property; + } + + public String getDirection() { + return direction; + } + + public void setDirection(String direction) { + this.direction = direction; + } + + @Override + public String toString() { + if (unsorted) { + return "UNSORTED"; + } + return String.format("%s: %s", property, direction); + } + } + + @Override + public String toString() { + return String.format("PaginationInfo{page=%d, size=%d, totalPages=%d, totalElements=%d, first=%s, last=%s}", + page, size, totalPages, totalElements, first, last); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/pdf/PDFManager.java b/src/main/java/dev/alexzaw/rest4i/pdf/PDFManager.java new file mode 100644 index 0000000..efe78c1 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/pdf/PDFManager.java @@ -0,0 +1,350 @@ +package dev.alexzaw.rest4i.pdf; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import org.apache.pdfbox.io.MemoryUsageSetting; +import org.apache.pdfbox.multipdf.PDFMergerUtility; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.util.Matrix; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * PDF Manager for handling PDF operations including conversion, merge, rotation, and unprotection. + * + * @author alexzaw + */ +public class PDFManager { + + private static final Logger logger = Logger.getLogger(PDFManager.class.getName()); + + /** + * PDF Information container + */ + public static class PDFInfo { + private final PDDocument document; + private final int rotation; + private final boolean isLandscape; + + public PDFInfo(PDDocument doc, int rotation, boolean isLandscape) { + this.document = doc; + this.rotation = rotation; + this.isLandscape = isLandscape; + } + + public PDDocument getDocument() { return document; } + public int getRotation() { return rotation; } + public boolean isLandscape() { return isLandscape; } + } + + /** + * Converts HTML content to PDF. + * + * @param htmlContent The HTML content to convert + * @param pageSize The page size (A4, A3, etc.) + * @param orientation The page orientation (portrait/landscape) + * @return ByteArrayOutputStream containing the PDF + * @throws IOException if conversion fails + */ + public ByteArrayOutputStream convertHtmlToPdf(String htmlContent, String pageSize, String orientation) throws IOException { + try { + Document doc = Jsoup.parse(htmlContent); + doc.outputSettings() + .syntax(Document.OutputSettings.Syntax.xml) + .escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml); + + // Convert to XHTML + String xhtml = doc.html(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PdfRendererBuilder builder = new PdfRendererBuilder(); + + float[] pageDimensions = getPageSize(pageSize, orientation); + builder.useDefaultPageSize(pageDimensions[0], pageDimensions[1], PdfRendererBuilder.PageSizeUnits.MM); + builder.withHtmlContent(xhtml, null); + builder.toStream(baos); + + builder.run(); + + return baos; + } catch (Exception e) { + logger.log(Level.SEVERE, "Error converting HTML to PDF", e); + throw new IOException("Error converting HTML to PDF: " + e.getMessage(), e); + } + } + + /** + * Merges multiple PDF documents into one. + * + * @param pdfInputStreams List of PDF input streams to merge + * @return ByteArrayOutputStream containing the merged PDF + * @throws IOException if merge fails + */ + public ByteArrayOutputStream mergePdfs(List pdfInputStreams) throws IOException { + File tempDir = null; + List pageCounts = new ArrayList<>(); + + try { + tempDir = Files.createTempDirectory("pdf-merge-").toFile(); + PDFMergerUtility pdfMerger = new PDFMergerUtility(); + + for (int i = 0; i < pdfInputStreams.size(); i++) { + InputStream pdfStream = pdfInputStreams.get(i); + + // Create temp file for each input PDF + File tempInputFile = new File(tempDir, "input-" + i + ".pdf"); + Files.copy(pdfStream, tempInputFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + // Analyze and process PDF + PDFInfo pdfInfo = analyzePDF(tempInputFile); + if (pdfInfo != null) { + PDDocument doc = pdfInfo.getDocument(); + + // Add page count to the list + pageCounts.add(doc.getNumberOfPages()); + + // Auto-rotate to landscape if needed + if (!pdfInfo.isLandscape()) { + PDPage firstPage = doc.getPage(0); + firstPage.setRotation((firstPage.getRotation() + 90) % 360); + + // Save rotated document to new temp file + File rotatedFile = new File(tempDir, "rotated-" + i + ".pdf"); + doc.save(rotatedFile); + pdfMerger.addSource(rotatedFile); + } else { + pdfMerger.addSource(tempInputFile); + } + + doc.close(); + } else { + // If analysis fails, use original file and count its pages + try (PDDocument doc = PDDocument.load(tempInputFile)) { + pageCounts.add(doc.getNumberOfPages()); + } + pdfMerger.addSource(tempInputFile); + } + } + + // Set up merged PDF output + File mergedFile = new File(tempDir, "merged.pdf"); + pdfMerger.setDestinationFileName(mergedFile.getAbsolutePath()); + pdfMerger.mergeDocuments(MemoryUsageSetting.setupMainMemoryOnly()); + + // Read merged file into byte array + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Files.copy(mergedFile.toPath(), baos); + + return baos; + + } finally { + // Clean up temp files + if (tempDir != null) { + deleteDirectory(tempDir); + } + } + } + + /** + * Rotates pages in a PDF document. + * + * @param pdfInputStream The PDF input stream + * @param rotation The rotation angle (90, 180, 270, 360/0) + * @return ByteArrayOutputStream containing the rotated PDF + * @throws IOException if rotation fails + */ + public ByteArrayOutputStream rotatePdf(InputStream pdfInputStream, int rotation) throws IOException { + try (PDDocument doc = PDDocument.load(pdfInputStream)) { + // Apply rotation to all pages + for (PDPage page : doc.getPages()) { + page.setRotation(rotation); + } + + // Return rotated PDF + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + + return baos; + } + } + + /** + * Removes protection from a PDF by converting it to images and back to PDF. + * + * @param pdfInputStream The protected PDF input stream + * @return ByteArrayOutputStream containing the unprotected PDF + * @throws IOException if unprotection fails + */ + public ByteArrayOutputStream unprotectPdf(InputStream pdfInputStream) throws IOException { + try (PDDocument document = PDDocument.load(pdfInputStream)) { + PDDocument newDocument = convertToUnprotected(document); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + newDocument.save(baos); + newDocument.close(); + + return baos; + } + } + + /** + * Analyzes a PDF file and returns information about its properties. + * + * @param pdfFile The PDF file to analyze + * @return PDFInfo containing document properties + */ + private PDFInfo analyzePDF(File pdfFile) { + try { + PDDocument document = PDDocument.load(pdfFile); + PDPage firstPage = document.getPage(0); + int rotation = firstPage.getRotation(); + + // Get page dimensions + PDRectangle mediaBox = firstPage.getMediaBox(); + float width = mediaBox.getWidth(); + float height = mediaBox.getHeight(); + + // Consider rotation when determining landscape + boolean isRotated = (rotation == 90 || rotation == 270); + if (isRotated) { + float temp = width; + width = height; + height = temp; + } + + boolean isLandscape = width > height; + + return new PDFInfo(document, rotation, isLandscape); + } catch (IOException e) { + logger.log(Level.WARNING, "Error analyzing PDF file", e); + return null; + } + } + + /** + * Converts a protected PDF to unprotected by rendering pages as images. + * + * @param document The protected PDF document + * @return New unprotected PDF document + * @throws IOException if conversion fails + */ + private PDDocument convertToUnprotected(PDDocument document) throws IOException { + PDDocument newDocument = new PDDocument(); + PDFRenderer pdfRenderer = new PDFRenderer(document); + + // Pre-calculate total pages for better memory management + int totalPages = document.getNumberOfPages(); + + for (int page = 0; page < totalPages; page++) { + // Get original page dimensions + PDPage originalPage = document.getPage(page); + PDRectangle originalMediaBox = originalPage.getMediaBox(); + + BufferedImage image = pdfRenderer.renderImageWithDPI(page, 300); + + // Create new page with original dimensions + PDPage newPage = new PDPage(new PDRectangle( + originalMediaBox.getWidth(), + originalMediaBox.getHeight() + )); + newDocument.addPage(newPage); + + try (PDPageContentStream contentStream = new PDPageContentStream(newDocument, newPage)) { + PDImageXObject pdImage = LosslessFactory.createFromImage(newDocument, image); + // Scale image to fit original dimensions + contentStream.transform(new Matrix( + originalMediaBox.getWidth() / image.getWidth(), + 0, 0, + originalMediaBox.getHeight() / image.getHeight(), + 0, 0 + )); + contentStream.drawImage(pdImage, 0, 0); + } + + // Help GC by clearing the image + image.flush(); + } + + return newDocument; + } + + /** + * Gets page dimensions based on size and orientation. + * + * @param size The page size (A4, A3, etc.) + * @param orientation The orientation (portrait/landscape) + * @return Array with [width, height] in millimeters + */ + private float[] getPageSize(String size, String orientation) { + float width = 210; // Default to A4 width + float height = 297; // Default to A4 height + + if (size != null) { + switch (size.toUpperCase()) { + case "A1": + width = 594; + height = 841; + break; + case "A2": + width = 420; + height = 594; + break; + case "A3": + width = 297; + height = 420; + break; + case "A5": + width = 148; + height = 210; + break; + case "A6": + width = 105; + height = 148; + break; + case "A7": + width = 74; + height = 105; + break; + // A4 is already set as default + } + } + + return "landscape".equalsIgnoreCase(orientation) ? + new float[] { height, width } : new float[] { width, height }; + } + + /** + * Recursively deletes a directory and all its contents. + * + * @param directory The directory to delete + */ + private void deleteDirectory(File directory) { + if (directory.exists()) { + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectory(file); + } else { + file.delete(); + } + } + } + } + directory.delete(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/pool/AS400ConnectionPoolService.java b/src/main/java/dev/alexzaw/rest4i/pool/AS400ConnectionPoolService.java new file mode 100644 index 0000000..0db9c6e --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/pool/AS400ConnectionPoolService.java @@ -0,0 +1,466 @@ +package dev.alexzaw.rest4i.pool; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400ConnectionPool; +import com.ibm.as400.access.AS400JDBCConnection; +import com.ibm.as400.access.AS400JDBCDriver; +import com.ibm.as400.access.SecureAS400; +import dev.alexzaw.rest4i.session.Session; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.config.EnhancedConfigurationManager; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * AS400 Connection Pool Service that manages connection pools for IBM i systems. + * + * This service provides connection pooling using IBM's AS400ConnectionPool + * with configurable pool parameters from properties. It integrates with the + * existing Session class to maintain compatibility with RSE session authentication. + * + * Features: + * - Connection pooling with configurable parameters + * - Integration with existing Session authentication + * - Hot-reloadable configuration via EnhancedConfigurationManager + * - Thread-safe operations with concurrent access support + * - Automatic pool cleanup and resource management + * + * @author alexzaw + */ +public class AS400ConnectionPoolService { + + private static final Logger logger = Logger.getLogger(AS400ConnectionPoolService.class.getName()); + + // Singleton instance + private static volatile AS400ConnectionPoolService instance; + private static final Object INSTANCE_LOCK = new Object(); + + // Connection pools keyed by host+user combination + private final Map connectionPools = new ConcurrentHashMap<>(); + private final Map jdbcDrivers = new ConcurrentHashMap<>(); + + // Configuration manager + private final EnhancedConfigurationManager configManager; + + // Default pool configuration + private static final int DEFAULT_MAX_CONNECTIONS = 20; + private static final int DEFAULT_MAX_INACTIVITY_TIME = 300000; // 5 minutes in milliseconds + private static final int DEFAULT_MAX_LIFETIME = 1800000; // 30 minutes in milliseconds + private static final int DEFAULT_MAX_USE_COUNT = 1000; + private static final boolean DEFAULT_PRETEST_CONNECTIONS = true; + private static final boolean DEFAULT_RUN_MAINTENANCE = true; + + /** + * Private constructor for singleton pattern. + */ + private AS400ConnectionPoolService() { + this.configManager = EnhancedConfigurationManager.getInstance(); + } + + /** + * Gets the singleton instance of AS400ConnectionPoolService. + * + * @return The singleton instance + */ + public static AS400ConnectionPoolService getInstance() { + if (instance == null) { + synchronized (INSTANCE_LOCK) { + if (instance == null) { + instance = new AS400ConnectionPoolService(); + } + } + } + return instance; + } + + /** + * Gets a connection from the pool for the specified session. + * + * @param session The authenticated session + * @return An AS400 connection from the pool + * @throws Exception If unable to get a connection + */ + public AS400 getConnection(Session session) throws Exception { + if (session == null) { + throw new IllegalArgumentException("Session cannot be null"); + } + + String poolKey = createPoolKey(session.getHost(), session.getUserid()); + AS400ConnectionPool pool = getOrCreateConnectionPool(session); + + logger.fine("Getting AS400 connection from pool: " + poolKey); + return pool.getConnection(session.getHost(), session.getUserid()); + } + + /** + * Gets a JDBC connection from the pool for the specified session. + * + * @param session The authenticated session + * @return A JDBC connection from the pool + * @throws SQLException If unable to get a JDBC connection + * @throws Exception If unable to get the AS400 connection + */ + public Connection getJDBCConnection(Session session) throws Exception { + AS400 as400 = getConnection(session); + + String poolKey = createPoolKey(session.getHost(), session.getUserid()); + AS400JDBCDriver driver = getOrCreateJDBCDriver(poolKey); + + // Get JDBC properties from session settings + Properties jdbcProperties = getJDBCProperties(session); + + logger.fine("Getting JDBC connection from pool: " + poolKey); + return driver.connect(as400, jdbcProperties, null, false); + } + + /** + * Returns a connection to the pool. + * + * @param connection The AS400 connection to return + */ + public void returnConnection(AS400 connection) { + if (connection == null) { + return; + } + + try { + String poolKey = createPoolKey(connection.getSystemName(), connection.getUserId()); + AS400ConnectionPool pool = connectionPools.get(poolKey); + + if (pool != null) { + logger.fine("Returning AS400 connection to pool: " + poolKey); + pool.returnConnectionToPool(connection); + } else { + logger.warning("No pool found for connection: " + poolKey); + connection.resetAllServices(); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Error returning connection to pool", e); + try { + connection.resetAllServices(); + } catch (Exception ex) { + logger.log(Level.WARNING, "Error resetting connection services", ex); + } + } + } + + /** + * Returns a JDBC connection to the pool by closing it properly. + * + * @param connection The JDBC connection to return + */ + public void returnJDBCConnection(Connection connection) { + if (connection != null) { + try { + logger.fine("Closing JDBC connection"); + connection.close(); + } catch (SQLException e) { + logger.log(Level.WARNING, "Error closing JDBC connection", e); + } + } + } + + /** + * Gets or creates a connection pool for the specified session. + * + * @param session The authenticated session + * @return The connection pool + * @throws Exception If unable to create the pool + */ + private AS400ConnectionPool getOrCreateConnectionPool(Session session) throws Exception { + String poolKey = createPoolKey(session.getHost(), session.getUserid()); + + return connectionPools.computeIfAbsent(poolKey, key -> { + try { + logger.info("Creating new AS400 connection pool: " + key); + return createConnectionPool(session.getHost(), session.getUserid()); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to create connection pool: " + key, e); + throw new RuntimeException(e); + } + }); + } + + /** + * Creates a new connection pool with configured parameters. + * + * @param host The IBM i host + * @param userId The user ID + * @return The configured connection pool + * @throws Exception If unable to create the pool + */ + private AS400ConnectionPool createConnectionPool(String host, String userId) throws Exception { + AS400ConnectionPool pool = new AS400ConnectionPool(); + + // Configure pool parameters from configuration + int maxConnections = configManager.getInt("connection.pool.maxConnections", DEFAULT_MAX_CONNECTIONS); + int maxInactivityTime = configManager.getInt("connection.pool.maxInactivityTime", DEFAULT_MAX_INACTIVITY_TIME); + int maxLifetime = configManager.getInt("connection.pool.maxLifetime", DEFAULT_MAX_LIFETIME); + int maxUseCount = configManager.getInt("connection.pool.maxUseCount", DEFAULT_MAX_USE_COUNT); + boolean pretestConnections = configManager.getBoolean("connection.pool.pretestConnections", DEFAULT_PRETEST_CONNECTIONS); + boolean runMaintenance = configManager.getBoolean("connection.pool.runMaintenance", DEFAULT_RUN_MAINTENANCE); + + pool.setMaxConnections(maxConnections); + pool.setMaxInactivity(maxInactivityTime); + pool.setMaxLifetime(maxLifetime); + pool.setMaxUseCount(maxUseCount); + pool.setPretestConnections(pretestConnections); + pool.setRunMaintenance(runMaintenance); + + // Configure cleanup interval if specified + int cleanupInterval = configManager.getInt("connection.pool.cleanupInterval", -1); + if (cleanupInterval > 0) { + pool.setCleanupInterval(cleanupInterval); + } + + logger.info(String.format("Connection pool created for %s@%s with maxConnections=%d, maxInactivityTime=%d", + userId, host, maxConnections, maxInactivityTime)); + + return pool; + } + + /** + * Gets or creates a JDBC driver for the specified pool. + * + * @param poolKey The pool key + * @return The JDBC driver + */ + private AS400JDBCDriver getOrCreateJDBCDriver(String poolKey) { + return jdbcDrivers.computeIfAbsent(poolKey, key -> { + logger.fine("Creating new AS400 JDBC driver: " + key); + return new AS400JDBCDriver(); + }); + } + + /** + * Creates a pool key from host and user ID. + * + * @param host The host + * @param userId The user ID + * @return The pool key + */ + private String createPoolKey(String host, String userId) { + return String.format("%s@%s", userId != null ? userId.toUpperCase() : "NULL", + host != null ? host.toLowerCase() : "localhost"); + } + + /** + * Gets JDBC properties for the session, combining defaults with session-specific settings. + * + * @param session The session + * @return JDBC properties + */ + private Properties getJDBCProperties(Session session) { + Properties props = new Properties(); + + // Start with default JDBC properties from Session class + for (Map.Entry entry : Session.DEFAULT_JDBCPROPERTIES.entrySet()) { + props.setProperty(entry.getKey(), entry.getValue()); + } + + // Add configuration-based overrides + Map jdbcConfig = configManager.getPropertiesWithPrefix("jdbc"); + for (Map.Entry entry : jdbcConfig.entrySet()) { + props.setProperty(entry.getKey(), entry.getValue().toString()); + } + + // Add session-specific properties if available + try { + // Use reflection to get settings since it's private + // In a production environment, this should be done through proper Session API methods + java.lang.reflect.Field settingsField = Session.class.getDeclaredField("settings_"); + settingsField.setAccessible(true); + Object settings = settingsField.get(session); + + if (settings != null) { + java.lang.reflect.Field sqlPropsField = settings.getClass().getDeclaredField("sqlProperties"); + sqlPropsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map sqlProps = (Map) sqlPropsField.get(settings); + + if (sqlProps != null) { + for (Map.Entry entry : sqlProps.entrySet()) { + props.setProperty(entry.getKey(), entry.getValue()); + } + } + } + } catch (Exception e) { + logger.fine("Could not access session SQL properties: " + e.getMessage()); + } + + return props; + } + + /** + * Gets pool statistics for monitoring and debugging. + * + * @param host The host to get statistics for (null for all pools) + * @param userId The user ID to get statistics for (null for all pools) + * @return Pool statistics as a map + */ + public Map getPoolStatistics(String host, String userId) { + Map statistics = new ConcurrentHashMap<>(); + + if (host != null && userId != null) { + // Get statistics for specific pool + String poolKey = createPoolKey(host, userId); + AS400ConnectionPool pool = connectionPools.get(poolKey); + if (pool != null) { + statistics.put(poolKey, getPoolStatistics(pool, host, userId)); + } + } else { + // Get statistics for all pools - extract host/userId from poolKey + for (Map.Entry entry : connectionPools.entrySet()) { + String poolKey = entry.getKey(); + // Parse poolKey to extract host and userId (format: "USER@host") + String[] parts = poolKey.split("@"); + if (parts.length == 2) { + String poolUserId = parts[0]; + String poolHost = parts[1]; + statistics.put(poolKey, getPoolStatistics(entry.getValue(), poolHost, poolUserId)); + } else { + // Fallback for malformed keys - create basic statistics + Map basicStats = new ConcurrentHashMap<>(); + AS400ConnectionPool pool = entry.getValue(); + basicStats.put("maxConnections", pool.getMaxConnections()); + basicStats.put("maxInactivity", pool.getMaxInactivity()); + basicStats.put("poolKey", poolKey); + statistics.put(poolKey, basicStats); + } + } + } + + return statistics; + } + + /** + * Gets statistics for a specific pool. + * + * @param pool The connection pool + * @param host The host for pool-specific statistics + * @param userId The user ID for pool-specific statistics + * @return Pool statistics + */ + private Map getPoolStatistics(AS400ConnectionPool pool, String host, String userId) { + Map stats = new ConcurrentHashMap<>(); + + // Use available methods that require host and userId parameters + stats.put("activeConnections", pool.getActiveConnectionCount(host, userId)); + stats.put("availableConnections", pool.getAvailableConnectionCount(host, userId)); + stats.put("maxConnections", pool.getMaxConnections()); + stats.put("maxInactivity", pool.getMaxInactivity()); + stats.put("maxLifetime", pool.getMaxLifetime()); + stats.put("maxUseCount", pool.getMaxUseCount()); + stats.put("maxUseTime", pool.getMaxUseTime()); + stats.put("cleanupInterval", pool.getCleanupInterval()); + stats.put("pretestConnections", pool.isPretestConnections()); + stats.put("runMaintenance", pool.isRunMaintenance()); + + // Get system and user information + String[] systemNames = pool.getSystemNames(); + if (systemNames != null) { + stats.put("systemNames", java.util.Arrays.asList(systemNames)); + } + + String[] users = pool.getUsers(host); + if (users != null) { + stats.put("users", java.util.Arrays.asList(users)); + } + + String[] connectedUsers = pool.getConnectedUsers(host); + if (connectedUsers != null) { + stats.put("connectedUsers", java.util.Arrays.asList(connectedUsers)); + } + + return stats; + } + + /** + * Cleans up a specific connection pool. + * + * @param host The host + * @param userId The user ID + */ + public void cleanupPool(String host, String userId) { + String poolKey = createPoolKey(host, userId); + AS400ConnectionPool pool = connectionPools.remove(poolKey); + + if (pool != null) { + logger.info("Cleaning up connection pool: " + poolKey); + try { + pool.close(); + } catch (Exception e) { + logger.log(Level.WARNING, "Error closing connection pool: " + poolKey, e); + } + } + + AS400JDBCDriver driver = jdbcDrivers.remove(poolKey); + if (driver != null) { + logger.fine("Removed JDBC driver: " + poolKey); + } + } + + /** + * Shuts down all connection pools and releases resources. + */ + public void shutdown() { + logger.info("Shutting down AS400 Connection Pool Service"); + + // Close all connection pools + for (Map.Entry entry : connectionPools.entrySet()) { + try { + logger.info("Closing connection pool: " + entry.getKey()); + entry.getValue().close(); + } catch (Exception e) { + logger.log(Level.WARNING, "Error closing connection pool: " + entry.getKey(), e); + } + } + + connectionPools.clear(); + jdbcDrivers.clear(); + + logger.info("AS400 Connection Pool Service shut down completed"); + } + + /** + * Forces maintenance cleanup of idle connections across all pools. + * Note: Since cleanupConnections() is not public in AS400ConnectionPool, + * we trigger maintenance instead which performs similar cleanup operations. + */ + public void forceCleanup() { + logger.info("Forcing maintenance cleanup of idle connections"); + + for (Map.Entry entry : connectionPools.entrySet()) { + try { + // Use reflection to call the package-private cleanupConnections method + // This is a workaround since the method is not public + java.lang.reflect.Method cleanupMethod = AS400ConnectionPool.class.getDeclaredMethod("cleanupConnections"); + cleanupMethod.setAccessible(true); + cleanupMethod.invoke(entry.getValue()); + logger.fine("Cleaned up connections for pool: " + entry.getKey()); + } catch (Exception e) { + logger.log(Level.WARNING, "Error during cleanup for pool: " + entry.getKey() + ". This is expected if security manager restricts reflection.", e); + + // Fallback: try to trigger maintenance by setting run maintenance temporarily + try { + AS400ConnectionPool pool = entry.getValue(); + boolean originalMaintenance = pool.isRunMaintenance(); + if (!originalMaintenance) { + pool.setRunMaintenance(true); + // Give some time for maintenance to run + Thread.sleep(100); + pool.setRunMaintenance(originalMaintenance); + } + } catch (Exception fallbackError) { + logger.log(Level.FINE, "Fallback maintenance trigger also failed for pool: " + entry.getKey(), fallbackError); + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/session/Session.java b/src/main/java/dev/alexzaw/rest4i/session/Session.java new file mode 100644 index 0000000..72d0bf2 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/session/Session.java @@ -0,0 +1,527 @@ +package dev.alexzaw.rest4i.session; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400JDBCConnection; +import com.ibm.as400.access.AS400JDBCDriver; +import com.ibm.as400.access.AS400Message; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.CommandCall; +import com.ibm.as400.access.ProgramParameter; +import com.ibm.as400.access.SecureAS400; +import com.ibm.as400.access.ServiceProgramCall; +import dev.alexzaw.rest4i.api.CLCommandAPIImpl; +import dev.alexzaw.rest4i.api.SQLAPIImpl; +import dev.alexzaw.rest4i.api.SessionAPIImpl; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWARequestTimeoutException; +import dev.alexzaw.rest4i.exception.RestWAUnauthorizedException; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import dev.alexzaw.rest4i.util.JSONSerializer; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class Session { + + public static final Map DEFAULT_JDBCPROPERTIES; + + static { + DEFAULT_JDBCPROPERTIES = (Map)Stream.of(new String[][] { + { "access", "all" }, { "auto commit", "true" }, { "autocommit exception", "false" }, { "bidi implicit reordering", "true" }, { "bidi numeric ordering", "false" }, { "bidi string type", "5" }, { "big decimal", "true" }, { "block criteria", "2" }, { "block size", "32" }, { "character truncation", "true" }, + { "concurrent access resolution", "2" }, { "cursor hold", "true" }, { "cursor sensitivity", "asensitive" }, { "data compression", "true" }, { "data truncation", "true" }, { "database name", "" }, { "date format", "iso" }, { "date separator", "" }, { "decfloat rounding mode", "half even" }, { "decimal separator", "" }, + { "driver", "toolbox" }, { "errors", "basic" }, { "extended dynamic", "false" }, { "extended metadata", "false" }, { "full open", "false" }, { "hold input locators", "true" }, { "hold statements", "false" }, { "ignore warnings", "01003,0100C,01567" }, { "lazy close", "false" }, { "libraries", "*LIBL" }, + { "lob threshold", "32768" }, { "maximum blocked input rows", "32000" }, { "maximum precision", "31" }, { "maximum scale", "31" }, { "metadata source", "1" }, { "minimum divide scale", "0" }, { "naming", "system" }, { "numeric range error", "true" }, { "package", "" }, { "package add", "true" }, + { "package cache", "false" }, { "package ccsid", "13488" }, { "package criteria", "default" }, { "package error", "warning" }, { "package library", "QGPL" }, { "portNumber", "0" }, { "prefetch", "true" }, { "proxy server", "" }, { "qaqqinilib", "" }, { "query optimize goal", "0" }, + { "query replace truncated parameter", "" }, { "query storage limit", "-1" }, { "query timeout mechanism", "qqrytimlmt" }, { "remarks", "system" }, { "secondary URL", "" }, { "server trace", "0" }, { "sort", "hex" }, { "sort language", "" }, { "sort table", "" }, { "sort weight", "shared" }, + { "time format", "iso" }, { "time separator", ":" }, { "trace", "false" }, { "transaction isolation", "read uncommitted" }, { "translate binary", "false" }, { "translate boolean", "true" }, { "translate hex", "character" }, { "true autocommit", "false" }, { "use block update", "false" }, { "variable field compression", "all" }, + { "XA loosely coupled support", "0" } }).collect(Collectors.toMap(data -> data[0], data -> data[1])); + } + + protected static AS400JDBCDriver jdbcDriver_ = null; + + private AS400JDBCConnection dbconn_ = null; + + private String domain_ = "rseapi"; + + private long creation_ = 0L; + + private long lastused_ = 0L; + + private long useCount_ = 0L; + + private String userid_ = null; + + private String host_ = null; + + private AS400 as400_ = null; + + private String token_ = null; + + private boolean isExpired_ = false; + + private SessionAPIImpl.RSEAPI_SessionSettings settings_ = null; + + private ReentrantLock lock_ = new ReentrantLock(); + + private boolean singleUse_ = false; + + public Session(String host, String userid, char[] password) throws Exception { + this.as400_ = getAS400Connection(host, userid, password); + initializeAS400(false, this.settings_); + this.host_ = host; + this.userid_ = userid; + this.creation_ = Instant.now().getEpochSecond(); + this.lastused_ = this.creation_; + this.token_ = String.valueOf(UUID.randomUUID().toString()) + + "-" + Long.toHexString(this.creation_) + + "-" + CommonUtil.stringToHex(CommonUtil.getRemoteAddr()); + this.settings_ = new SessionAPIImpl.RSEAPI_SessionSettings(); + this.settings_.continueOnError = Boolean.valueOf(false); + this.settings_.sqlTreatWarningsAsErrors = Boolean.valueOf(false); + } + + public SessionAPIImpl.SetSessionSettingsResults setSettings(SessionAPIImpl.RSEAPI_SessionSettings newSettings) throws Exception { + SessionAPIImpl.SetSessionSettingsResults sr = null; + if (newSettings == null) + return sr; + try { + sanitize(newSettings); + if (newSettings.resetSettings.booleanValue()) + initializeAS400(true, null); + sr = applySessionSettings(newSettings); + merge(newSettings, sr); + } catch (Exception e) { + try { + initializeAS400(true, this.settings_); + } catch (Exception IGNORE) { + e.printStackTrace(); + } + throw e; + } + return sr; + } + + private void sanitize(SessionAPIImpl.RSEAPI_SessionSettings newSettings) { + if (newSettings.resetSettings == null) + newSettings.resetSettings = Boolean.valueOf(false); + if (newSettings.libraryList != null) { + newSettings.libraryList.removeIf(x -> !(x != null && CommonUtil.hasContent(x))); + List listWithoutDuplicates = (List)newSettings.libraryList.stream() + .distinct() + .collect(Collectors.toList()); + newSettings.libraryList = listWithoutDuplicates; + } + if (newSettings.clCommands != null) + newSettings.clCommands.removeIf(x -> !(x != null && CommonUtil.hasContent(x))); + if (newSettings.sqlProperties != null) { + newSettings.sqlProperties.remove("password"); + newSettings.sqlProperties.remove("prompt"); + newSettings.sqlProperties.remove("user"); + } + if (!newSettings.resetSettings.booleanValue() && this.settings_.sqlProperties != null && !this.settings_.sqlProperties.isEmpty()) { + HashMap temp = new HashMap<>(); + temp.putAll(this.settings_.sqlProperties); + if (newSettings.sqlProperties != null) + temp.putAll(newSettings.sqlProperties); + newSettings.sqlProperties = temp; + } + if (newSettings.sqlProperties != null && newSettings.sqlProperties.isEmpty()) + newSettings.sqlProperties = null; + if (!CommonUtil.hasContent(newSettings.sqlDefaultSchema)) + newSettings.sqlDefaultSchema = null; + if (newSettings.sqlTreatWarningsAsErrors == null) + newSettings.sqlTreatWarningsAsErrors = Boolean.valueOf(false); + if (newSettings.continueOnError == null) + newSettings.continueOnError = Boolean.valueOf(false); + } + + private void merge(SessionAPIImpl.RSEAPI_SessionSettings newSettings, SessionAPIImpl.SetSessionSettingsResults sr) { + if (newSettings.resetSettings.booleanValue()) { + this.settings_.libraryList = newSettings.libraryList; + this.settings_.clCommands = newSettings.clCommands; + this.settings_.envVariables = newSettings.envVariables; + if (sr == null || !sr.sqlInitializationErrors()) + this.settings_.sqlStatements = newSettings.sqlStatements; + } else { + if (this.settings_.libraryList == null) { + this.settings_.libraryList = newSettings.libraryList; + } else { + List newList = (List)newSettings.libraryList.stream() + .filter(n -> !this.settings_.libraryList.contains(n)) + .collect(Collectors.toList()); + this.settings_.libraryList.addAll(newList); + } + if (this.settings_.clCommands == null) { + this.settings_.clCommands = newSettings.clCommands; + } else { + this.settings_.clCommands.addAll(newSettings.clCommands); + } + if (this.settings_.envVariables == null) { + this.settings_.envVariables = newSettings.envVariables; + } else { + this.settings_.envVariables.putAll(newSettings.envVariables); + } + if (sr == null || !sr.sqlInitializationErrors()) + if (this.settings_.sqlStatements == null) { + this.settings_.sqlStatements = newSettings.sqlStatements; + } else { + this.settings_.sqlStatements.addAll(newSettings.sqlStatements); + } + } + this.settings_.continueOnError = newSettings.continueOnError; + this.settings_.sqlTreatWarningsAsErrors = newSettings.sqlTreatWarningsAsErrors; + if (sr == null || !sr.sqlInitializationErrors()) { + this.settings_.sqlDefaultSchema = newSettings.sqlDefaultSchema; + this.settings_.sqlProperties = newSettings.sqlProperties; + } + } + + public long getCreation() { + return this.creation_; + } + + public void setCreation(long creation) { + this.creation_ = creation; + } + + public void setSingleUse(boolean b) { + this.singleUse_ = b; + } + + public boolean getSingleUse() { + return this.singleUse_; + } + + public long getLastUsed() { + return this.lastused_; + } + + public synchronized void setLastUsed() { + if (isExpired()) + throw new RestWAUnauthorizedException("Authorization token has expired.", new Object[0]); + this.lastused_ = Instant.now().getEpochSecond(); + this.useCount_++; + } + + public long getUseCount() { + return this.useCount_; + } + + public String getDomain() { + return this.domain_; + } + + public synchronized long getExpiration() { + return this.lastused_ + GlobalProperties.getSessionMaxInactivity(); + } + + public synchronized boolean isExpired() { + if (this.isExpired_) + return true; + long now = Instant.now().getEpochSecond(); + if (now > getExpiration()) { + markExpired(); + } else if (GlobalProperties.getSessionMaxLifetime() > 0L && now - getCreation() > GlobalProperties.getSessionMaxLifetime()) { + markExpired(); + } else if (GlobalProperties.getSessionMaxUseCount() > 0L && (this.useCount_ >= GlobalProperties.getSessionMaxUseCount() || this.useCount_ < 0L)) { + markExpired(); + } + return this.isExpired_; + } + + public synchronized void markExpired() { + this.isExpired_ = true; + } + + public String getUserid() { + return this.userid_; + } + + public boolean isLocalhost() { + return !(CommonUtil.hasContent(this.host_) && !this.host_.equalsIgnoreCase("localhost")); + } + + public String getHost() { + return this.host_; + } + + public AS400 getAS400() throws Exception { + if (this.as400_ == null || !this.as400_.isConnectionAlive(2)) + initializeAS400(true, this.settings_); + return this.as400_; + } + + public AS400JDBCConnection getJDBC() throws Exception { + initializeJDBC(this.settings_, false); + return this.dbconn_; + } + + public String getToken() { + return this.token_; + } + + public void toJSON(JSONSerializer json) { + json.startObject("sessionInfo"); + json.add("userID", getUserid()); + json.add("host", getHost()); + json.add("expiration", Instant.ofEpochSecond(getExpiration()).toString()); + json.add("creation", Instant.ofEpochSecond(getCreation()).toString()); + json.add("lastUsed", Instant.ofEpochSecond(getLastUsed()).toString()); + json.add("domain", getDomain()); + json.add("expired", Boolean.valueOf(isExpired())); + json.endObject(); + if (this.settings_ == null) { + json.add("sessionSettings", (String)null); + } else { + json.startObject("sessionSettings"); + json.add("libraryList", this.settings_.libraryList); + json.add("clCommands", this.settings_.clCommands); + json.add("envVariables", this.settings_.envVariables); + json.add("sqlDefaultSchema", this.settings_.sqlDefaultSchema); + json.add("sqlTreatWarningsAsErrors", this.settings_.sqlTreatWarningsAsErrors, false); + json.add("sqlProperties", getCombinedJDBCProperties(this.settings_.sqlProperties)); + json.add("sqlStatements", this.settings_.sqlStatements); + json.endObject(); + } + } + + public void reclaimResources() { + if (this.dbconn_ != null) { + try { + this.dbconn_.close(); + } catch (Exception exception) {} + this.dbconn_ = null; + } + if (this.as400_ != null) { + try { + this.as400_.resetAllServices(); + } catch (Exception exception) {} + this.as400_ = null; + } + } + + private static AS400 getAS400Connection(String host, String userid, char[] password) throws Exception { + SecureAS400 secureAS400 = null; + AS400 as400 = null; + try { + if ((GlobalProperties.isIBMi() && ( + host.equalsIgnoreCase("localhost") || host.equals("127.0.0.1"))) || + !GlobalProperties.useSecureConnections()) { + as400 = new AS400(host, userid, password); + } else { + secureAS400 = new SecureAS400(host, userid, password); + as400 = secureAS400; + } + as400.validateSignon(); + } catch (Exception e) { + try { + if (as400 != null) + as400.resetAllServices(); + } catch (Exception exception) {} + throw e; + } + return as400; + } + + private void initializeAS400(boolean disconnect, SessionAPIImpl.RSEAPI_SessionSettings sessionSettings) throws Exception { + try { + if (disconnect) { + if (this.dbconn_ != null) { + try { + this.dbconn_.close(); + } catch (Exception exception) {} + this.dbconn_ = null; + } + this.as400_.disconnectAllServices(); + } + this.as400_.setGuiAvailable(false); + CommandCall cmd = new CommandCall(this.as400_); + cmd.setThreadSafe(false); + cmd.run("QSYS/CHGJOB INQMSGRPY(*DFT)"); + int jobCCSID = cmd.getServerJob().getCodedCharacterSetID(); + AsyncLogger.traceDebug("initializeAS400", "ccsid='%d'", new Object[] { Integer.valueOf(jobCCSID) }); + if (this.as400_.getCcsid() != jobCCSID) { + this.as400_.disconnectAllServices(); + this.as400_.setCcsid(jobCCSID); + cmd.run("QSYS/CHGJOB INQMSGRPY(*DFT)"); + } + applySessionSettings(sessionSettings); + } catch (Exception e) { + try { + this.as400_.disconnectAllServices(); + } catch (Exception exception) {} + throw e; + } + } + + private void initializeJDBC(SessionAPIImpl.RSEAPI_SessionSettings sessionSettings, boolean resetConnection) throws Exception { + if (jdbcDriver_ == null) + JDBCDriverInit(); + if (resetConnection || !this.as400_.isConnected(4) || !this.as400_.isConnectionAlive(4)) + if (this.dbconn_ != null) { + try { + this.dbconn_.close(); + } catch (Exception exception) {} + this.dbconn_ = null; + } + if (this.dbconn_ == null) { + Map temp = getCombinedJDBCProperties(null); + if (sessionSettings != null && sessionSettings.sqlProperties != null) + temp.putAll(sessionSettings.sqlProperties); + Properties jdbcProperties = CommonUtil.mapToProperties(temp); + this.dbconn_ = (AS400JDBCConnection)jdbcDriver_.connect(this.as400_, jdbcProperties, sessionSettings.sqlDefaultSchema, false); + } + } + + private static synchronized void JDBCDriverInit() throws Exception { + if (jdbcDriver_ == null) + jdbcDriver_ = new AS400JDBCDriver(); + } + + private SessionAPIImpl.SetSessionSettingsResults applySessionSettings(SessionAPIImpl.RSEAPI_SessionSettings sessionSettings) throws Exception { + if (sessionSettings == null) + return null; + List esLib = setLibl(this.as400_, sessionSettings.libraryList, sessionSettings.continueOnError.booleanValue()); + List esEV = setEnvVariables(this.as400_, sessionSettings.envVariables, sessionSettings.continueOnError.booleanValue()); + List esCL = CLCommandAPIImpl.runClCommands(this.as400_, sessionSettings.clCommands, sessionSettings.continueOnError.booleanValue()); + List esSQ = null; + List jdbcInitErrors = null; + if (sessionSettings.sqlDefaultSchema != null || sessionSettings.sqlProperties != null) { + boolean resetConnection = false; + if ((sessionSettings.sqlDefaultSchema != null && this.settings_.sqlDefaultSchema == null) || ( + sessionSettings.sqlDefaultSchema == null && this.settings_.sqlDefaultSchema != null) || ( + sessionSettings.sqlDefaultSchema != null && + this.settings_.sqlDefaultSchema != null && + !sessionSettings.sqlDefaultSchema.equals(this.settings_.sqlDefaultSchema))) + resetConnection = true; + if ((sessionSettings.sqlProperties != null && this.settings_.sqlProperties == null) || ( + sessionSettings.sqlProperties == null && this.settings_.sqlProperties != null) || + !sessionSettings.sqlProperties.equals(this.settings_.sqlProperties)) + resetConnection = true; + try { + initializeJDBC(sessionSettings, resetConnection); + } catch (SQLException e) { + jdbcInitErrors = new ArrayList<>(); + jdbcInitErrors.add(e.getMessage()); + } + if (jdbcInitErrors == null) + esSQ = SQLAPIImpl.runSQLStatements(this.dbconn_, sessionSettings.sqlStatements, sessionSettings.sqlTreatWarningsAsErrors.booleanValue(), sessionSettings.continueOnError.booleanValue()); + } + return new SessionAPIImpl.SetSessionSettingsResults(esCL, esSQ, esLib, esEV, jdbcInitErrors); + } + + public boolean inUse() { + return this.lock_.isLocked(); + } + + public void lock() { + if (this.lock_.isHeldByCurrentThread()) + return; + try { + if (GlobalProperties.getSessionMaxWaitTime() == -1L) { + this.lock_.lock(); + } else if (!this.lock_.tryLock(GlobalProperties.getSessionMaxWaitTime(), TimeUnit.SECONDS)) { + throw new RestWARequestTimeoutException("Request timed out while waiting for the session to become available.", new Object[0]); + } + } catch (InterruptedException e) { + throw new RestWAInternalServerErrorException("Request interrupted while waiting for session to become available.", new Object[0]); + } + } + + public void unlock() { + if (this.lock_.isHeldByCurrentThread()) + this.lock_.unlock(); + } + + public boolean getSQLTreatWarningsAsErrors() { + return (this.settings_ != null && this.settings_.sqlTreatWarningsAsErrors != null && this.settings_.sqlTreatWarningsAsErrors.booleanValue()); + } + + private Map getCombinedJDBCProperties(Map userSepcifiedProperties) { + Map sqlProperties = new HashMap<>(); + sqlProperties.putAll(DEFAULT_JDBCPROPERTIES); + if (userSepcifiedProperties != null && !userSepcifiedProperties.isEmpty()) + sqlProperties.putAll(userSepcifiedProperties); + return sqlProperties; + } + + public static List setLibl(AS400 sys, List libList, boolean continueOnError) throws Exception { + List es = null; + StringBuilder errorMessages = null; + if (libList == null || libList.isEmpty()) + return es; + CommandCall cmd = new CommandCall(sys); + StringBuilder sbuf = new StringBuilder(); + for (String lib : libList) { + sbuf.setLength(0); + if (!CommonUtil.hasContent(lib)) + continue; + sbuf.append("QSYS/ADDLIBLE ").append(lib.trim()).append(" *LAST"); + if (!cmd.run(sbuf.toString())) { + AS400Message[] messageList = cmd.getMessageList(); + for (int i = 0; i < messageList.length; i++) { + if (messageList[i].getType() == 15) + if (!messageList[i].getID().equalsIgnoreCase("CPF2103")) { + if (errorMessages == null) + errorMessages = new StringBuilder(); + errorMessages.append(messageList[i].getID()).append(": ").append(messageList[i].getText()); + } + } + if (errorMessages != null && errorMessages.length() > 0) { + if (continueOnError) { + if (es == null) + es = new ArrayList<>(); + es.add(new SessionAPIImpl.SetSessionSettingsResults.ErrorSource(lib, errorMessages.toString())); + errorMessages.setLength(0); + continue; + } + throw new RestWABadRequestException("CL command failed. Command is [%s]. Error message(s): %s", new Object[] { sbuf.toString(), errorMessages.toString() }); + } + } + } + return es; + } + + public static List setEnvVariables(AS400 as400, Map envVariables, boolean continueOnError) throws Exception { + List es = null; + if (envVariables == null || envVariables.isEmpty()) + return es; + ServiceProgramCall spc = CommonUtil.getServiceProgramCall(as400, "/QSYS.LIB/QP0ZCPA.SRVPGM", "putenv", 1, false, true); + CharConverter converter = new CharConverter(as400.getCcsid(), as400); + ProgramParameter[] pgmParmList = spc.getParameterList(); + Set keys = envVariables.keySet(); + for (String key : keys) { + if (!CommonUtil.hasContent(key)) + continue; + String value = envVariables.get(key); + if (value == null) + value = ""; + pgmParmList[0].setInputData(converter.stringToByteArray(String.valueOf(key) + "=" + value + "\000")); + spc.run(); + if (spc.getIntegerReturnValue() != 0) { + String errorMessage = "putenv() failed with errno " + spc.getErrno(); + if (continueOnError) { + if (es == null) + es = new ArrayList<>(); + es.add(new SessionAPIImpl.SetSessionSettingsResults.ErrorSource(key, errorMessage)); + continue; + } + throw new RestWAInternalServerErrorException("Unable to process the request due to an internal error. %s", new Object[] { errorMessage }); + } + } + return es; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/session/SessionRepository.java b/src/main/java/dev/alexzaw/rest4i/session/SessionRepository.java new file mode 100644 index 0000000..146cce8 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/session/SessionRepository.java @@ -0,0 +1,127 @@ +package dev.alexzaw.rest4i.session; + +import dev.alexzaw.rest4i.exception.RestWAServiceUnavailableException; +import dev.alexzaw.rest4i.util.AsyncLogger; +import dev.alexzaw.rest4i.util.CommonUtil; +import dev.alexzaw.rest4i.util.GlobalProperties; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public class SessionRepository { + + private static ConcurrentHashMap token2SessionTable_ = new ConcurrentHashMap<>(); + + private static ConcurrentHashMap sessionsStatsTable_ = new ConcurrentHashMap<>(); + + private static boolean repositoryDestroyed = false; + + static { + startCleanupThread(); + } + + public static synchronized Session createSession(String host, String userid, char[] password) throws Exception { + AsyncLogger.traceDebug("createSession", "in-parms: host='%s', userid='%s'", new Object[] { host, userid }); + Session session = new Session(host, userid, password); + long maxSessions = GlobalProperties.getMaxSessions(); + long maxSessionsPerUser = GlobalProperties.getMaxSessionsPerUser(); + AtomicLong count = null; + try { + if (maxSessions != -1L && getTotalSessions() >= maxSessions) + throw new RestWAServiceUnavailableException("Maximum number of sessions has been reached.", new Object[0]); + String countKey = userid.toUpperCase(); + synchronized (sessionsStatsTable_) { + count = sessionsStatsTable_.get(countKey); + if (count == null) { + count = new AtomicLong(); + sessionsStatsTable_.put(countKey, count); + } + if (maxSessionsPerUser != -1L && count.get() >= maxSessionsPerUser) + throw new RestWAServiceUnavailableException("Maximum number of sessions per user has been reached.", new Object[0]); + count.incrementAndGet(); + } + } catch (Exception e) { + session.reclaimResources(); + throw e; + } + token2SessionTable_.put(session.getToken(), session); + return session; + } + + public static synchronized void deleteSession(String sessionToken) { + AsyncLogger.traceDebug("deleteSession", "token", new Object[0]); + Session session = token2SessionTable_.remove(sessionToken); + if (session != null) { + session.reclaimResources(); + String countKey = session.getUserid().toUpperCase(); + AtomicLong count = sessionsStatsTable_.get(countKey); + if (count != null && count.longValue() > 0L) + count.decrementAndGet(); + } + } + + public static synchronized void deleteSession(Session session) { + deleteSession(session.getToken()); + } + + public static Session findSession(String sessionToken) { + return repositoryDestroyed ? null : token2SessionTable_.get(sessionToken); + } + + public static long getTotalSessions() { + return (repositoryDestroyed ? 0L : token2SessionTable_.size()); + } + + public static Map getSessionsStatisticsData() { + return sessionsStatsTable_; + } + + public static synchronized void destroy() { + repositoryDestroyed = true; + AsyncLogger.traceDebug("destroySessionRepository", "", new Object[0]); + sessionsStatsTable_.clear(); + for (Session s : token2SessionTable_.values()) { + try { + s.reclaimResources(); + } catch (Exception exception) {} + } + token2SessionTable_.clear(); + } + + public static synchronized void clearSessions(String user) { + if (user == null) + user = "*ALL"; + if (!CommonUtil.hasContent(user)) + return; + boolean isAll = user.equalsIgnoreCase("*ALL"); + for (Session s : token2SessionTable_.values()) { + if (isAll || s.getUserid().equalsIgnoreCase(user)) + s.markExpired(); + } + } + + private static boolean startCleanupThread() { + class SessionCleanup implements Runnable { + public void run() { + AsyncLogger.traceDebug("startCleanupThread", "Session cleanup thread starting.", new Object[0]); + while (!SessionRepository.repositoryDestroyed) { + long millisecsToSleep = GlobalProperties.getSessionCleanupInterval() * 1000L; + try { + Thread.sleep(millisecsToSleep); + AsyncLogger.traceDebug("startCleanupThread", "Cleanup in process.", new Object[0]); + SessionRepository.token2SessionTable_.forEach((key, value) -> { + if (value.isExpired() && !value.inUse()) + SessionRepository.deleteSession(key); + }); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + }; + SessionCleanup sessionCleanup = new SessionCleanup(); + Thread cleanupThread = new Thread(sessionCleanup); + cleanupThread.start(); + return true; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/AsyncLogger.java b/src/main/java/dev/alexzaw/rest4i/util/AsyncLogger.java new file mode 100644 index 0000000..a70fc77 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/AsyncLogger.java @@ -0,0 +1,134 @@ +package dev.alexzaw.rest4i.util; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class AsyncLogger { + + protected static final Logger LOGGER = Logger.getLogger("IBMiRSEAPI"); + + private static ArrayBlockingQueue _blockingStdMsgQueue = new ArrayBlockingQueue<>(1000); + + private static volatile boolean _stdLoggerActive = true; + + static { + startStdLoggerHandlerThread(); + } + + private static class AUDIT extends Level { + private static final long serialVersionUID = -7472873829993078361L; + + public AUDIT() { + super("AUDIT", 850); + } + } + + private static Level AUDIT = new AUDIT(); + + private static class TraceMessage { + String _classSignature; + + String _message; + + Throwable _exception; + + Object[] _args; + + Level _level; + + TraceMessage(Level level, String signature, String msg, Object[] args, Throwable ex) { + this._level = level; + this._classSignature = signature; + this._message = msg; + this._exception = ex; + this._args = args; + } + + public void print() { + StringBuilder sb = new StringBuilder(); + if (this._message == null) + this._message = ""; + if (CommonUtil.hasContent(this._classSignature)) + sb.append(this._classSignature).append(": "); + try { + sb.append(String.format(this._message, this._args)); + } catch (Exception e) { + AsyncLogger.LOGGER.log(Level.WARNING, e.getMessage()); + } + if (this._exception != null) { + AsyncLogger.LOGGER.log(this._level, sb.toString(), this._exception); + } else { + AsyncLogger.LOGGER.log(this._level, sb.toString()); + } + } + } + + private static boolean startStdLoggerHandlerThread() { + class MessageQueue implements Runnable { + public void run() { + AsyncLogger.LOGGER.info("Standard logger starting."); + while (AsyncLogger._stdLoggerActive) { + AsyncLogger.TraceMessage logData = null; + try { + logData = AsyncLogger._blockingStdMsgQueue.take(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + if (logData != null) + logData.print(); + } + AsyncLogger.LOGGER.info("Standard logger exiting."); + } + }; + MessageQueue msgQueue = new MessageQueue(); + Thread msgThread = new Thread(msgQueue); + msgThread.start(); + return true; + } + + public static void stopStdLoggerHandler() { + _stdLoggerActive = false; + LOGGER.info("Deactivating standard logger."); + } + + public static void traceAudit(String classSignature, String message, Object... args) { + queueLogMessage(AUDIT, classSignature, message, args, null); + } + + public static void traceInfo(String classSignature, String message, Object... args) { + queueLogMessage(Level.INFO, classSignature, message, args, null); + } + + public static void traceWarning(String classSignature, String message, Object... args) { + queueLogMessage(Level.WARNING, classSignature, message, args, null); + } + + public static void traceDebug(String classSignature, String message, Object... args) { + queueLogMessage(Level.FINEST, classSignature, message, args, null); + } + + public static void traceDebug(String classSignature, Throwable exception, String message, Object... args) { + queueLogMessage(Level.FINEST, classSignature, message, args, exception); + } + + public static void traceSevere(String classSignature, String message, Object... args) { + queueLogMessage(Level.SEVERE, classSignature, message, args, null); + } + + public static void traceException(String classSignature, Throwable exception, String message, Object... args) { + if (message == null) + message = exception.getMessage(); + queueLogMessage(Level.SEVERE, classSignature, message, args, exception); + } + + private static void queueLogMessage(Level level, String classSignature, String message, Object[] args, Throwable exception) { + TraceMessage tm = new TraceMessage(level, classSignature, message, args, exception); + try { + if (!_blockingStdMsgQueue.offer(tm)) + tm.print(); + } catch (Exception e) { + LOGGER.severe("Exception in queueLogMessage: " + e.getMessage()); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/CertificateStoreProcessor.java b/src/main/java/dev/alexzaw/rest4i/util/CertificateStoreProcessor.java new file mode 100644 index 0000000..b23f7e3 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/CertificateStoreProcessor.java @@ -0,0 +1,1597 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Bin4; +import com.ibm.as400.access.AS400Message; +import com.ibm.as400.access.BinaryConverter; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.IFSFile; +import com.ibm.as400.access.ProgramCall; +import com.ibm.as400.access.ProgramParameter; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; + +public class CertificateStoreProcessor { + + public static final String UNKNOWN = "UNKNOWN"; + + public static final String STORETYPE_CMS = "CMS"; + + public static final String STORETYPE_JKS = "JKS"; + + public static final String STORETYPE_PKCS12 = "PKCS12"; + + public static final String APPTYPE_SERVER = "SERVER"; + + public static final String APPTYPE_CLIENT = "CLIENT"; + + public static final String APPTYPE_SERVER_CLIENT = "SERVER_CLIENT"; + + public static final String APPTYPE_OBJECT_SIGNING = "OBJECT_SIGNING"; + + public static final String CERTTYPE_CA = "CA"; + + public static final String CERTTYPE_SERVER_CLIENT = "SERVER_CLIENT"; + + public static final String CERTTYPE_CSR = "CSR"; + + public static final String CERTTYPE_SIGNATURE_VERIFICATION = "SIGNATURE_VERIFICATION"; + + public static final String CERTTYPE_OBJECT_SIGNING = "OBJECT_SIGNING"; + + public static final String CERTFORMAT_PKCS12 = "PKCS12"; + + public static final String CERTFORMAT_DER = "DER"; + + public static final String CERTFORMAT_PEM = "PEM"; + + private static final Set certStoreTypes = new HashSet<>(Arrays.asList(new String[] { "CMS" })); + + private static final Set certTypeFilters = new HashSet<>(Arrays.asList(new String[] { "CA", "SERVER_CLIENT", "CSR" })); + + private static final Set certFormats = new HashSet<>(Arrays.asList(new String[] { "PKCS12", "DER", "PEM" })); + + private static final Map dcmSystemKeyStoreToPath = new HashMap<>(); + + protected AS400 _sys = null; + + protected String _certStoreType = null; + + protected String _certStorePath = null; + + protected char[] _certStorePassword = null; + + protected String _realCertStorePath = null; + + static { + dcmSystemKeyStoreToPath.put("*SYSTEM", "/QIBM/USERDATA/ICSS/CERT/SERVER/DEFAULT.KDB"); + dcmSystemKeyStoreToPath.put("*LOCALCA", "/QIBM/USERDATA/ICSS/CERT/CERTAUTH/DEFAULT.KDB"); + dcmSystemKeyStoreToPath.put("*OBJECTSIGNING", "/QIBM/USERDATA/ICSS/CERT/SIGNING/SGNOBJ.KDB"); + dcmSystemKeyStoreToPath.put("*SIGNATUREVERIFICATION", "/QIBM/USERDATA/ICSS/CERT/SIGNING/VFYOBJ.KDB"); + } + + private CertificateStoreProcessor(AS400 sys, String certStoreType, String certStorePath, char[] certStorePassword) { + this._sys = sys; + this._certStoreType = certStoreType.toUpperCase(); + this._certStorePath = certStorePath; + this._certStorePassword = certStorePassword; + this._realCertStorePath = dcmSystemKeyStoreToPath.getOrDefault(this._certStorePath.toUpperCase(), this._certStorePath); + } + + public static CertificateStoreProcessor getInstance(AS400 sys, String certStoreType, String certStorePath, String certStorePassword, boolean validatePassword) throws Exception { + if (!validatePassword && !CommonUtil.hasContent(certStorePassword)) + certStorePassword = "null"; + if (!CommonUtil.hasContent(certStoreType) || + !CommonUtil.hasContent(certStorePassword) || + !CommonUtil.hasContent(certStorePath)) + throw new RestWABadRequestException(); + if (!certStoreTypes.contains(certStoreType.toUpperCase())) + throw new RestWABadRequestException("Certificate store not supported or is not a valid certificate store.", new Object[0]); + if (validatePassword) { + String realPath = dcmSystemKeyStoreToPath.getOrDefault(certStorePath.toUpperCase(), certStorePath); + IFSFile ks = new IFSFile(sys, realPath); + if (ks.exists() && + !ks.isFile()) + throw new RestWABadRequestException("Certificate store not supported or is not a valid certificate store.", new Object[0]); + } + if (certStoreType.equalsIgnoreCase("CMS")) + return new CertificateStoreProcessorCMS(sys, certStoreType.toUpperCase(), certStorePath, certStorePassword.toCharArray()); + return new CertificateStoreProcessor(sys, certStoreType.toUpperCase(), certStorePath, certStorePassword.toCharArray()); + } + + public static boolean isSupportCertificateType(String type) { + return CommonUtil.hasContent(type) ? certTypeFilters.contains(type.toUpperCase()) : false; + } + + public static boolean isSupportCertificateFormat(String format) { + return CommonUtil.hasContent(format) ? certFormats.contains(format.toUpperCase()) : false; + } + + public Map listCertificates(Set typesFilter, String aliasFilter, Integer daysUntilExpirationFilter, boolean excludeExpiredFilter) throws Exception { + return null; + } + + public void changeCertificateStorePassword(String certStorePasswordNew, int daysToExpiration, boolean resetPasswordFromSystemStash) throws Exception {} + + public CertificateData exportCertificate(String certFormat, String certAlias, String certDataPassword) throws Exception { + return null; + } + + public void importCertificate(String certType, String certFormat, String certAlias, byte[] certData, String certDataPassword) throws Exception {} + + public void deleteCertificate(String certAlias) throws Exception {} + + public CertificateInfo getCertificateInfo(String certAlias) throws Exception { + return null; + } + + public void exportCertStore() throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public void importCertStore() throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public Map listAppDefinitions(String appidFilter, String apptypeFilter) throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public void associateCertificatesToAppDefinition(String appID, List certAliases) throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public void disassociateCertificatesFromAppDefinition(String appID) throws Exception {} + + public void trustCertificateForAppDefinition(String appDefinitionID, List certAliases) throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public void untrustCertificateForAppDefinition(String appDefinitionID, List certAliases) throws Exception { + throw new RestWABadRequestException("Operation not supported.", new Object[0]); + } + + public static class CertificateStoreProcessorCMS extends CertificateStoreProcessor { + private boolean objectSigningKeystore = false; + + private boolean signatureVerificationKeystore = false; + + private boolean systemKeystore = false; + + private static final AS400Bin4 intConverter = new AS400Bin4(); + + private static final String QYCUDRIVERPGMPATH = "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM"; + + private static final String YCU_OP_APPID_LIST_ASSIGNED_CERTS = "121\000"; + + private static final String YCU_OP_APPID_TRUSTED_CERTS = "124\000"; + + private static final String YCU_OP_APPID_REMOVE_CERT = "125\000"; + + private static final String YCU_OP_APPID_ASSIGN_CERT = "126\000"; + + private static final String YCU_OP_APPID_REMOVE_CATRUST = "127\000"; + + private static final String YCU_OP_APPID_ASSIGN_CATRUST = "127\000"; + + private static final String YCU_OP_CHANGE_CERT_STORE_PWD = "137\000"; + + private static final String YCU_OP_CHANGE_CERT_STORE_PWD_STASH = "140\000"; + + private static final String YCU_OP_DELETE_CERT = "152\000"; + + private static final String YCU_OP_DELETE_CERT_CSR = "153\000"; + + private static final String YCU_OP_EXPORT_CERT = "155\000"; + + private static final String YCU_OP_EXPORT_CERT_NOKEY = "157\000"; + + private static final String YCU_OP_LIST_CERT = "159\000"; + + private static final String YCU_OP_INFO_CERT = "163\000"; + + private static final String YCU_OP_INFO_CERT_CSR = "165\000"; + + private static final String YCU_OP_IMPORT_CERT_LABEL = "169\000"; + + private static final String YCU_OP_IMPORT_CERT_CA = "181\000"; + + private static final String YCU_RCENVVAR = "QIBM_RSEAPI_RC\000"; + + private static final Map dcmRCMapper = new HashMap<>(); + + public static final int GSKKM_ERR_UNKNOWN = 1; + + public static final int GSKKM_ERR_ASN = 2; + + public static final int GSKKM_ERR_ASN_INITIALIZATION = 3; + + public static final int GSKKM_ERR_ASN_PARAMETER = 4; + + public static final int GSKKM_ERR_DATABASE = 5; + + public static final int GSKKM_ERR_DATABASE_OPEN = 6; + + public static final int GSKKM_ERR_DATABASE_RE_OPEN = 7; + + public static final int GSKKM_ERR_DATABASE_CREATE = 8; + + public static final int GSKKM_ERR_DATABASE_ALREADY_EXISTS = 9; + + public static final int GSKKM_ERR_DATABASE_DELETE = 10; + + public static final int GSKKM_ERR_DATABASE_NOT_OPENED = 11; + + public static final int GSKKM_ERR_DATABASE_READ = 12; + + public static final int GSKKM_ERR_DATABASE_WRITE = 13; + + public static final int GSKKM_ERR_DATABASE_VALIDATION = 14; + + public static final int GSKKM_ERR_DATABASE_INVALID_VERSION = 15; + + public static final int GSKKM_ERR_DATABASE_INVALID_PASSWORD = 16; + + public static final int GSKKM_ERR_DATABASE_INVALID_FILE_TYPE = 17; + + public static final int GSKKM_ERR_DATABASE_CORRUPTION = 18; + + public static final int GSKKM_ERR_DATABASE_PASSWORD_CORRUPTION = 19; + + public static final int GSKKM_ERR_DATABASE_KEY_INTEGRITY = 20; + + public static final int GSKKM_ERR_DATABASE_DUPLICATE_KEY = 21; + + public static final int GSKKM_ERR_DATABASE_PASSWORD_ENCRYPTION = 22; + + public static final int GSKKM_ERR_DATABASE_LDAP = 23; + + public static final int GSKKM_ERR_CRYPTO = 24; + + public static final int GSKKM_ERR_CRYPTO_ENGINE = 25; + + public static final int GSKKM_ERR_CRYPTO_ALGORITHM = 26; + + public static final int GSKKM_ERR_CRYPTO_SIGN = 27; + + public static final int GSKKM_ERR_CRYPTO_VERIFY = 28; + + public static final int GSKKM_ERR_CRYPTO_DIGEST = 29; + + public static final int GSKKM_ERR_CRYPTO_PARAMETER = 30; + + public static final int GSKKM_ERR_CRYPTO_UNSUPPORTED_ALGORITHM = 31; + + public static final int GSKKM_ERR_CRYPTO_INPUT_GREATER_THAN_MODULUS = 32; + + public static final int GSKKM_ERR_CRYPTO_UNSUPPORTED_MODULUS_SIZE = 33; + + public static final int GSKKM_ERR_VALIDATION = 34; + + public static final int GSKKM_ERR_VALIDATION_KEY = 35; + + public static final int GSKKM_ERR_VALIDATION_DUPLICATE_EXTENSIONS = 36; + + public static final int GSKKM_ERR_VALIDATION_KEY_WRONG_VERSION = 37; + + public static final int GSKKM_ERR_VALIDATION_KEY_EXTENSIONS_REQUIRED = 38; + + public static final int GSKKM_ERR_VALIDATION_KEY_VALIDITY = 39; + + public static final int GSKKM_ERR_VALIDATION_KEY_VALIDITY_PERIOD = 40; + + public static final int GSKKM_ERR_VALIDATION_KEY_VALIDITY_PRIVATE_KEY_USAGE = 41; + + public static final int GSKKM_ERR_VALIDATION_KEY_ISSUER_NOT_FOUND = 42; + + public static final int GSKKM_ERR_VALIDATION_KEY_MISSING_REQUIRED_EXTENSIONS = 43; + + public static final int GSKKM_ERR_VALIDATION_KEY_BASIC_CONSTRAINTS = 44; + + public static final int GSKKM_ERR_VALIDATION_KEY_SIGNATURE = 45; + + public static final int GSKKM_ERR_VALIDATION_KEY_ROOT_KEY_NOT_TRUSTED = 46; + + public static final int GSKKM_ERR_VALIDATION_KEY_IS_REVOKED = 47; + + public static final int GSKKM_ERR_VALIDATION_KEY_AUTHORITY_KEY_IDENTIFIER = 48; + + public static final int GSKKM_ERR_VALIDATION_KEY_PRIVATE_KEY_USAGE_PERIOD = 49; + + public static final int GSKKM_ERR_VALIDATION_SUBJECT_ALTERNATIVE_NAME = 50; + + public static final int GSKKM_ERR_VALIDATION_ISSUER_ALTERNATIVE_NAME = 51; + + public static final int GSKKM_ERR_VALIDATION_KEY_USAGE = 52; + + public static final int GSKKM_ERR_VALIDATION_KEY_UNKNOWN_CRITICAL_EXTENSION = 53; + + public static final int GSKKM_ERR_VALIDATION_KEY_PAIR = 54; + + public static final int GSKKM_ERR_VALIDATION_CRL = 55; + + public static final int GSKKM_ERR_MUTEX = 56; + + public static final int GSKKM_ERR_PARAMETER = 57; + + public static final int GSKKM_ERR_NULL_PARAMETER = 58; + + public static final int GSKKM_ERR_NUMBER_SIZE = 59; + + public static final int GSKKM_ERR_OLD_PASSWORD = 60; + + public static final int GSKKM_ERR_NEW_PASSWORD = 61; + + public static final int GSKKM_ERR_PASSWORD_EXPIRATION_TIME = 62; + + public static final int GSKKM_ERR_THREAD = 63; + + public static final int GSKKM_ERR_THREAD_CREATE = 64; + + public static final int GSKKM_ERR_THREAD_WAIT_FOR_EXIT = 65; + + public static final int GSKKM_ERR_IO = 66; + + public static final int GSKKM_ERR_LOAD = 67; + + public static final int GSKKM_ERR_PKCS11 = 68; + + public static final int GSKKM_ERR_NOT_INITIALIZED = 69; + + public static final int GSKKM_ERR_DB_TABLE_CORRUPTED = 70; + + public static final int GSKKM_ERR_MEMORY_ALLOCATE = 71; + + public static final int GSKKM_ERR_UNSUPPORTED_OPTION = 72; + + public static final int GSKKM_ERR_GET_TIME = 73; + + public static final int GSKKM_ERR_CREATE_MUTEX = 74; + + public static final int GSKKM_ERR_CMDCAT_OPEN = 75; + + public static final int GSKKM_ERR_ERRCAT_OPEN = 76; + + public static final int GSKKM_ERR_FILENAME_NULL = 77; + + public static final int GSKKM_ERR_FILE_OPEN = 78; + + public static final int GSKKM_ERR_FILE_OPEN_TO_READ = 79; + + public static final int GSKKM_ERR_FILE_OPEN_TO_WRITE = 80; + + public static final int GSKKM_ERR_FILE_OPEN_NOT_EXIST = 81; + + public static final int GSKKM_ERR_FILE_OPEN_NOT_ALLOWED = 82; + + public static final int GSKKM_ERR_FILE_WRITE = 83; + + public static final int GSKKM_ERR_FILE_REMOVE = 84; + + public static final int GSKKM_ERR_BASE64_INVALID_DATA = 85; + + public static final int GSKKM_ERR_BASE64_INVALID_MSGTYPE = 86; + + public static final int GSKKM_ERR_BASE64_ENCODING = 87; + + public static final int GSKKM_ERR_BASE64_DECODING = 88; + + public static final int GSKKM_ERR_DN_TAG_NULL = 89; + + public static final int GSKKM_ERR_DN_CN_NULL = 90; + + public static final int GSKKM_ERR_DN_C_NULL = 91; + + public static final int GSKKM_ERR_INVALID_DB_HANDLE = 92; + + public static final int GSKKM_ERR_KEYDB_NOT_EXIST = 93; + + public static final int GSKKM_ERR_KEYPAIRDB_NOT_EXIST = 94; + + public static final int GSKKM_ERR_PWDFILE_NOT_EXIST = 95; + + public static final int GSKKM_ERR_PASSWORD_CHANGE_MATCH = 96; + + public static final int GSKKM_ERR_KEYDB_NULL = 97; + + public static final int GSKKM_ERR_REQKEYDB_NULL = 98; + + public static final int GSKKM_ERR_KEYDB_TRUSTCA_NULL = 99; + + public static final int GSKKM_ERR_REQKEY_FOR_CERT_NULL = 100; + + public static final int GSKKM_ERR_KEYDB_PRIVATE_KEY_NULL = 101; + + public static final int GSKKM_ERR_KEYDB_DEFAULT_KEY_NULL = 102; + + public static final int GSKKM_ERR_KEYREC_PRIVATE_KEY_NULL = 103; + + public static final int GSKKM_ERR_KEYREC_CERTIFICATE_NULL = 104; + + public static final int GSKKM_ERR_CRLS_NULL = 105; + + public static final int GSKKM_ERR_INVALID_KEYDB_NAME = 106; + + public static final int GSKKM_ERR_UNDEFINED_KEY_TYPE = 107; + + public static final int GSKKM_ERR_INVALID_DN_INPUT = 108; + + public static final int GSKKM_ERR_KEY_GET_BY_LABEL = 109; + + public static final int GSKKM_ERR_LABEL_LIST_CORRUPT = 110; + + public static final int GSKKM_ERR_INVALID_PKCS12_DATA = 111; + + public static final int GSKKM_ERR_PKCS12_PWD_CORRUPTION = 112; + + public static final int GSKKM_ERR_EXPORT_TYPE = 113; + + public static final int GSKKM_ERR_PBE_ALG_UNSUPPORT = 114; + + public static final int GSKKM_ERR_KYR2KDB = 115; + + public static final int GSKKM_ERR_KDB2KYR = 116; + + public static final int GSKKM_ERR_ISSUING_CERTIFICATE = 117; + + public static final int GSKKM_ERR_FIND_ISSUER_CHAIN = 118; + + public static final int GSKKM_ERR_WEBDB_DATA_BAD_FORMAT = 119; + + public static final int GSKKM_ERR_WEBDB_NOTHING_TO_WRITE = 120; + + public static final int GSKKM_ERR_EXPIRE_DAYS_TOO_LARGE = 121; + + public static final int GSKKM_ERR_PWD_TOO_SHORT = 122; + + public static final int GSKKM_ERR_PWD_NO_NUMBER = 123; + + public static final int GSKKM_ERR_PWD_NO_CONTROL_KEY = 124; + + public static final int GSKKM_ERR_NO_HDW_ALG = 125; + + public static final int GSKKM_ERR_VALIDATION_DUP_COMMON_NAME = 126; + + public static final int GSKKM_ERR_Base64Conversion = 307; + + public static final int GSKKM_ERR_StashKdbPwd = 308; + + public static final int GSKKM_ERR_RecoverKdbPwd = 309; + + public static final int GSKKM_ERR_NoTrustedDNs = 310; + + public static final int GSKKM_ERR_NoMatchingKeyRecord = 311; + + public static final int GSKKM_WEBDB_ConversionError = 312; + + public static final int GSKKM_WEBDB_PK_chgpw_and_armor = 313; + + public static final int GSKKM_WEBDB_webdb_findarmorstring = 314; + + public static final int GSKKM_WEBDB_PK_chgpw_and_dearmor = 315; + + public static final int GSKKM_ERR_ChangeObjectAuthority = 316; + + public static final int GSKKM_ERR_FileRead = 317; + + public static final int GSKKM_ERR_NotSystemState = 318; + + public static final int GSKKM_ERR_GetDNInCharacterFormat = 319; + + public static final int GSKKM_ERR_NotEnoughSpace = 321; + + public static final int GSKKM_ERR_NotEnoughSpaceForChain = 322; + + public static final int GSKKM_ERR_Parsing_Certificate_Chain = 323; + + public static final int GSKKM_ERR_Bsafe_Call = 324; + + public static final int GSKKM_ERR_CPY_OBJ = 325; + + public static final int GSKKM_ERR_SWAP_USER = 326; + + public static final int GSKKM_ERR_SWAP_BACK = 327; + + public static final int GSKKM_ERR_CHGOWN_OBJ = 328; + + public static final int GSKKM_ERR_RENAME_STASH_FILE = 329; + + public static final int GSKKM_ERR_RENEW_CERT_NOT_FOUND = 330; + + public static final int GSKKM_ERR_RENEW_CERT_ISSUER = 331; + + public static final int GSKKM_WEBDB_UnknownError = 301; + + public static final int GSKKM_WEBDB_WrongAlgorithmID1 = 302; + + public static final int GSKKM_WEBDB_ProbablyWrongPassword = 303; + + public static final int GSKKM_WEBDB_InvalidVersion = 304; + + public static final int GSKKM_WEBDB_WrongAlgorithmID2 = 305; + + public static final int GSKKM_ERR_CCA = 340; + + public static final int GSKKM_ERR_CCA_RESOLVE = 341; + + public static final int GSKKM_ERR_CCA_MATDEV = 342; + + public static final int GSKKM_ERR_CCA_ALLOC = 343; + + public static final int GSKKM_ERR_CCA_KEYID = 344; + + public static final int GSKKM_ERR_CCA_DESIGNATE = 345; + + public static final int GSKKM_ERR_CCA_INVALIDKEYSTORE = 346; + + public static final int GSKKM_ERR_CCA_CIPHER_OUTPUTTOOLARGE = 347; + + public static final int GSKKM_ERR_CCA_DEV_UNAVAIL = 348; + + public static final int GSKKM_ERR_CCA_DEV_VOFF = 349; + + public static final int GSKKM_ERR_CCA_CARD_NO_MEMORY = 352; + + public static final int GSKKM_ERR_CERT_WITH_HDWPRIVKEY_FOUND = 353; + + public static final int GSKKM_PKCS12_VERSION_UNSUP = 360; + + public static final int GSKKM_PKCS12_MULTIPLE_AUTHSAFES_UNSUP = 361; + + public static final int GSKKM_PKCS12_CONTENT_TYPE_UNSUP = 362; + + public static final int GSKKM_PKCS12_CERTTYPE_UNSUP = 363; + + public static final int GSKKM_PKCS12_BAG_TYPE_UNKNOWN = 364; + + public static final int GSKKM_PKCS12_DIGEST_ERROR = 365; + + public static final int GSKKM_PKCS12_PRIVATE_KEYPAIR_MISMATCH = 366; + + public static final int GSKKM_PKCS7_CONTENT_TYPE_UNSUP = 367; + + public static final int GSKKM_MULTDN_SLOT_OVERFLOW = 370; + + public static final int GSKKM_ERR_INDEX_NOT_AUTH = 380; + + public static final int GSKKM_ERR_INDEX_LOCKED = 381; + + public static final int GSKKM_ERR_INDEX_DAMAGED = 382; + + public static final int GSKKM_ERR_INDEX_EXISTS = 383; + + public static final int GSKKM_ERR_INDEX_NOT_FOUND = 384; + + public static final int GSKKM_ERR_INDEX_NO_ENTRY = 385; + + public static final int GSKKM_ERR_DIR_INDEX_ERROR = 390; + + public static final int GSKKM_ERR_NO_ACX_INSTALLED = 395; + + public static final int YCU_RC_SHORT_STATE = 3843; + + public static final int YCU_RC_BAD_VALIDITY = 3844; + + public static final int YCU_RC_AUTH_PROB = 3845; + + public static final int YCU_RC_BAD_KEYRING_FILENAME = 3846; + + public static final int YCU_RC_PASSWORD_NOTEQUAL = 3849; + + public static final int YCU_RC_RESETPW_NOT_ALLOWED = 3850; + + public static final int YCU_RC_COMMNAME_CA_REQUIRED = 3856; + + public static final int YCU_RC_ORGNAME_REQUIRED = 3857; + + public static final int YCU_RC_VALID_TOO_LONG = 3858; + + public static final int YCU_RC_COUNTRY_REQUIRED = 3859; + + public static final int YCU_RC_PASSWORD_REQUIRED = 3860; + + public static final int YCU_RC_MISSING_INPUT = 3861; + + public static final int YCU_RC_COMMNAME_SRV_REQUIRED = 3864; + + public static final int YCU_RC_NULL_CERT_REQ = 3865; + + public static final int YCU_RC_EXISTS_EXPORTFILE = 3891; + + public static final int YCU_RC_EXISTS_IMPORTFILE = 3892; + + public static final int YCU_RC_SOURCE_FILE_NO_EXIST = 3912; + + public static final int YCU_RC_DEST_FILE_ALREADY_EXIST = 3913; + + public static final int YCU_RC_POLFILE_OPEN_ERROR = 3925; + + public static final int YCU_RC_POLFILE_WRITE_ERROR = 3926; + + public static final int YCU_RC_KDB_FILE_ALREADY_EXIST2 = 3932; + + public static final int YCU_RC_FILE_ALREADY_EXIST = 3933; + + public static final int YCU_RC_POLFILE_READ_ERROR = 3936; + + public static final int YCU_RC_LISTKEYS_ZERO_KEYS = 3939; + + public static final int YCU_RC_CHECKEXPIRE_ZERO_KEYS = 3940; + + public static final int YCU_RC_COUNTRYNAME_REQUIRED = 3941; + + public static final int YCU_RC_FILENAME_CONFLICT_ERROR = 3943; + + public static final int YCU_RC_VALID_EXCEEDS_CA_EXPIRATION = 3944; + + public static final int YCU_RC_KEYDB_FILENAME_ERROR = 3945; + + public static final int YCU_RC_FILENAME_CONFLICT_ERROR2 = 3946; + + public static final int YCU_RC_POLFILE_CREATE_ERROR = 3947; + + public static final int YCU_RC_POLFILE_LOCK_ERROR = 3949; + + public static final int YCU_RC_RENAME_NEW_ERROR = 3952; + + public static final int YCU_RC_RENAME_EXISTING_ERROR = 3953; + + public static final int YCU_RC_CA_NO_CREATE_CLIENT = 3956; + + public static final int YCU_RC_IMPORT_FILE_REQ = 3968; + + public static final int YCU_RC_KEYRING_FILE_REQ = 3969; + + public static final int YCU_RC_KEYLABEL_REQ = 3970; + + public static final int YCU_RC_EXPORT_FILE_REQ = 3971; + + public static final int YCU_RC_ARMOR_PASSWORD_REQ = 3972; + + public static final int YCU_RC_NEWPASSWORD_REQUIRED = 3973; + + public static final int YCU_RC_ENVVAR_REQUIRED = 3974; + + public static final int YCU_RC_CERTREQ_FILE_REQ = 3975; + + public static final int YCU_RC_EXPIREDATE_REQUIRED = 3976; + + public static final int YCU_RC_CERT_NOT_FOUND3 = 3984; + + public static final int YCU_RC_LDAP_MAPPING_NOT_DEFINED = 3985; + + public static final int YCU_RC_BAD_INPUT_PARAMETER = 4000; + + public static final int YCU_RC_INSUF_INPUT_PARAMETERS = 4001; + + public static final int YCU_RC_BAD_FUNCTION_REQUEST = 4002; + + public static final int YCU_RC_NO_VAL_CERT = 4003; + + public static final int YCU_RC_PREV_REG = 4004; + + public static final int YCU_RC_FUNC_NOT_AT_THIS_TIME = 4007; + + public static final int YCU_RC_NO_APPS = 4008; + + public static final int YCU_RC_APPLICATION_ID_REQUIRED = 4009; + + public static final int YCU_RC_TRUST_REQ = 4010; + + public static final int YCU_RC_SEC_APP_RTV_CERT_USAGE = 4011; + + public static final int YCU_RC_SEC_APP_LABEL_TOO_BIG = 4012; + + public static final int YCU_RC_SEC_APP_RMV_CERT_USAGE = 4013; + + public static final int YCU_RC_SEC_APP_ADD_CA_TRUST = 4014; + + public static final int YCU_RC_SEC_APP_RMV_CA_TRUST = 4015; + + public static final int YCU_RC_SEC_APP_LST_APP_BYCA = 4016; + + public static final int YCU_RC_SEC_APP_LST_APP_BYCERT = 4017; + + public static final int YCU_RC_SEC_APP_UPD_CERT_USAGE = 4018; + + public static final int YCU_RC_SEC_APP_ADD_APP = 4020; + + public static final int YCU_RC_SEC_APP_REMOVE_APP = 4021; + + public static final int YCU_RC_SEC_APP_UPDATE_APP = 4022; + + public static final int YCU_RC_CA_IN_USE = 4023; + + public static final int YCU_RC_SEC_APP_EXISTS = 4024; + + public static final int YCU_RC_SEC_NO_LIST_CAS = 4027; + + public static final int YCU_RC_CRL_ADD_LOCATION = 4048; + + public static final int YCU_RC_CRL_UPDATE_LOCATION = 4049; + + public static final int YCU_RC_CRL_REMOVE_LOCATION = 4050; + + public static final int YCU_RC_CRL_GET_LOCATION = 4051; + + public static final int YCU_RC_CRL_NO_LOCATIONS = 4052; + + public static final int YCU_RC_CRL_LIST_LOCATIONS = 4053; + + public static final int YCU_RC_CRL_LOCATION_REQUIRED = 4054; + + public static final int YCU_RC_CRL_LDAP_SERVER_REQUIRED = 4055; + + public static final int YCU_RC_CRL_CONNECT_TYPE_REQUIRED = 4056; + + public static final int YCU_RC_CRL_PORT_NUM_REQUIRED = 4057; + + public static final int YCU_RC_CRL_LOGIN_PWD_TOO_LONG = 4058; + + public static final int YCU_RC_CRL_SERVER_LOGIN_DN_TOO_LONG = 4059; + + public static final int YCU_RC_CRYPTO_NO_DEVICES = 4064; + + public static final int YCU_RC_CRYPTO_LIST_DEVICES = 4065; + + public static final int YCU_RC_CRYPTO_RTV_RESOURCES = 4066; + + public static final int YCU_RC_CRYPTO_DEVICE_LIST_REQUIRED = 4067; + + public static final int YCU_RC_CRYPTO_DEVICE_NAME_REQUIRED = 4068; + + public static final int YCU_RC_VALIDATE_SELF_SIGNED = 4080; + + public static final int YCU_RC_VALIDATE_NOT_TRUSTED = 4081; + + public static final int YCU_RC_VALIDATE_NO_TRUSTED_CAS = 4082; + + public static final int YCU_RC_VALIDATE_NO_CERT_ASSIGNED = 4083; + + public static final int YCU_RC_YDO_OBJECT_NOT_FOUND = 4096; + + public static final int YCU_RC_YDO_SIGN_OBJECT = 4097; + + public static final int YCU_RC_YDO_VERIFY_SIGNATURE = 4098; + + public static final int YCU_RC_YDO_RETRIEVE_SIGNATURE = 4099; + + public static final int YCU_RC_YDO_BROWSE_DIRECTORY = 4100; + + public static final int YCU_RC_YDO_NO_OBJECTS = 4101; + + public static final Set dcmAppTypes = new HashSet<>(Arrays.asList(new String[] { "SERVER", "CLIENT", "OBJECT_SIGNING", "SERVER_CLIENT" })); + + private static final Map dcmAppTypeMap = new HashMap<>(); + + private static final Map dcmAppTypeStrMap = new HashMap<>(); + + private static final Map dcmStrAppTypeMap = new HashMap<>(); + + private static final Map dcmCertTypeNumMap = new HashMap<>(); + + private static final Map dcmKeyAlgorithmMap = new HashMap<>(); + + private static final Map dcmSignatureMap = new HashMap<>(); + + private static final Map dcmCertKeyStoreLocMap = new HashMap<>(); + + private static final char KEY_USAGE_EXTENSION_YES = '1'; + + private static final int KEY_USAGE_INDEX_DIGITAL_SIGNATURE = 0; + + private static final int KEY_USAGE_INDEX_NON_REPUDIATION = 1; + + private static final int KEY_USAGE_INDEX_KEY_ENCIPHERMENT = 2; + + private static final int KEY_USAGE_INDEX_DATA_ENCIPHERMENT = 3; + + private static final int KEY_USAGE_INDEX_KEY_AGREEMENT = 4; + + private static final int KEY_USAGE_INDEX_KEY_CERT_SIGN = 5; + + private static final int KEY_USAGE_INDEX_CRL_SIGN = 6; + + private static final int KEY_USAGE_INDEX_ENCHIPHER_ONLY = 7; + + private static final int KEY_USAGE_INDEX_DECIPHER_ONLY = 8; + + public static final String LIST_ALLAPPS = "0"; + + public static final String LIST_SERVERAPPS = "1"; + + public static final String LIST_CLIENTAPPS = "2"; + + public static final String LIST_BOTHAPPS = "3"; + + public static final String LIST_OBJSIGNAPPS = "4"; + + static { + dcmAppTypeMap.put("SERVER", Byte.valueOf((byte)1)); + dcmAppTypeMap.put("CLIENT", Byte.valueOf((byte)2)); + dcmAppTypeMap.put("OBJECT_SIGNING", Byte.valueOf((byte)3)); + dcmAppTypeStrMap.put(Byte.valueOf((byte)49), "SERVER"); + dcmAppTypeStrMap.put(Byte.valueOf((byte)50), "CLIENT"); + dcmAppTypeStrMap.put(Byte.valueOf((byte)51), "SERVER_CLIENT"); + dcmAppTypeStrMap.put(Byte.valueOf((byte)52), "OBJECT_SIGNING"); + dcmStrAppTypeMap.put("SERVER", "1"); + dcmStrAppTypeMap.put("CLIENT", "2"); + dcmStrAppTypeMap.put("SERVER_CLIENT", "3"); + dcmStrAppTypeMap.put("OBJECT_SIGNING", "4"); + dcmCertTypeNumMap.put(Byte.valueOf((byte)1), "CA"); + dcmCertTypeNumMap.put(Byte.valueOf((byte)2), "SERVER_CLIENT"); + dcmCertTypeNumMap.put(Byte.valueOf((byte)3), "CSR"); + dcmCertTypeNumMap.put(Byte.valueOf((byte)4), "SIGNATURE_VERIFICATION"); + dcmKeyAlgorithmMap.put(Byte.valueOf((byte)1), "RSA"); + dcmKeyAlgorithmMap.put(Byte.valueOf((byte)2), "ECDSA"); + dcmKeyAlgorithmMap.put(Byte.valueOf((byte)3), "RSA_PSS"); + dcmCertKeyStoreLocMap.put(Byte.valueOf((byte)0), "SOFTWARE"); + dcmCertKeyStoreLocMap.put(Byte.valueOf((byte)1), "HARDWARE"); + dcmCertKeyStoreLocMap.put(Byte.valueOf((byte)2), "SOFTWARE_HARDWARE_ENCRYPTED"); + dcmSignatureMap.put(Integer.valueOf(3), "RSA_SHA1"); + dcmSignatureMap.put(Integer.valueOf(7), "RSA_SHA224"); + dcmSignatureMap.put(Integer.valueOf(4), "RSA_SHA256"); + dcmSignatureMap.put(Integer.valueOf(5), "RSA_SHA384"); + dcmSignatureMap.put(Integer.valueOf(6), "RSA_SHA512"); + dcmSignatureMap.put(Integer.valueOf(8), "ECDSA_SHA1"); + dcmSignatureMap.put(Integer.valueOf(9), "ECDSA_SHA224"); + dcmSignatureMap.put(Integer.valueOf(10), "ECDSA_SHA256"); + dcmSignatureMap.put(Integer.valueOf(11), "ECDSA_SHA384"); + dcmSignatureMap.put(Integer.valueOf(12), "ECDSA_SHA512"); + dcmSignatureMap.put(Integer.valueOf(13), "RSA_PSS_SHA1"); + dcmSignatureMap.put(Integer.valueOf(14), "RSA_PSS_SHA224"); + dcmSignatureMap.put(Integer.valueOf(15), "RSA_PSS_SHA256"); + dcmSignatureMap.put(Integer.valueOf(16), "RSA_PSS_SHA384"); + dcmSignatureMap.put(Integer.valueOf(17), "RSA_PSS_SHA512"); + dcmRCMapper.put(Integer.valueOf(1), new RestWAException.ErrorInfo(500, "Unknown error occurred while using internal DCM SPI.")); + dcmRCMapper.put(Integer.valueOf(2), new RestWAException.ErrorInfo(Integer.valueOf(2), 400, "Certificate data is not valid or is not in the correct format.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4), new RestWAException.ErrorInfo(Integer.valueOf(4), 400, "Certificate data is not valid or is not in the correct format.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(8), new RestWAException.ErrorInfo(Integer.valueOf(8), 500, "Certificate store failed to create.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(9), new RestWAException.ErrorInfo(Integer.valueOf(9), 400, "Certificate store exists.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(14), new RestWAException.ErrorInfo(Integer.valueOf(14), 500, "Certificate store not supported or is not a valid certificate store.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(15), new RestWAException.ErrorInfo(Integer.valueOf(15), 400, "Certificate store not supported or is not a valid certificate store.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(16), new RestWAException.ErrorInfo(Integer.valueOf(16), 401, "Certificate store password not valid. If this is an attempt to change the password, the new password must not match existing password.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(17), new RestWAException.ErrorInfo(Integer.valueOf(17), 400, "Certificate store type not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(21), new RestWAException.ErrorInfo(Integer.valueOf(21), 400, "Duplicate alias in certificate store.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(26), new RestWAException.ErrorInfo(Integer.valueOf(26), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(28), new RestWAException.ErrorInfo(Integer.valueOf(28), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(29), new RestWAException.ErrorInfo(Integer.valueOf(29), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(30), new RestWAException.ErrorInfo(Integer.valueOf(30), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(31), new RestWAException.ErrorInfo(Integer.valueOf(31), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(32), new RestWAException.ErrorInfo(Integer.valueOf(32), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(33), new RestWAException.ErrorInfo(Integer.valueOf(33), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(34), new RestWAException.ErrorInfo(Integer.valueOf(34), 400, "Certificate validation failed. %s", new Object[] { "" })); + dcmRCMapper.put(Integer.valueOf(35), new RestWAException.ErrorInfo(Integer.valueOf(35), 400, "Certificate validation failed. %s", new Object[] { " (KEY)" })); + dcmRCMapper.put(Integer.valueOf(36), new RestWAException.ErrorInfo(Integer.valueOf(36), 400, "Certificate validation failed. %s", new Object[] { " (DUPLICATE_EXTENSIONS)" })); + dcmRCMapper.put(Integer.valueOf(37), new RestWAException.ErrorInfo(Integer.valueOf(37), 400, "Certificate validation failed. %s", new Object[] { " (VERSION)" })); + dcmRCMapper.put(Integer.valueOf(38), new RestWAException.ErrorInfo(Integer.valueOf(38), 400, "Certificate validation failed. %s", new Object[] { " (EXTENSIONS_REQUIRED)" })); + dcmRCMapper.put(Integer.valueOf(39), new RestWAException.ErrorInfo(Integer.valueOf(39), 400, "Certificate validation failed. %s", new Object[] { " (KEY_VALIDITY)" })); + dcmRCMapper.put(Integer.valueOf(40), new RestWAException.ErrorInfo(Integer.valueOf(40), 400, "Certificate validation failed. %s", new Object[] { " (VALIDITY_PERIOD)" })); + dcmRCMapper.put(Integer.valueOf(41), new RestWAException.ErrorInfo(Integer.valueOf(41), 400, "Certificate validation failed. %s", new Object[] { " (PRIVATE_KEY_USAGE)" })); + dcmRCMapper.put(Integer.valueOf(42), new RestWAException.ErrorInfo(Integer.valueOf(42), 400, "Certificate validation failed. %s", new Object[] { " (ISSUER_NOT_FOUND)" })); + dcmRCMapper.put(Integer.valueOf(43), new RestWAException.ErrorInfo(Integer.valueOf(43), 400, "Certificate validation failed. %s", new Object[] { " (MISSING_REQUIRED_EXTENSIONS)" })); + dcmRCMapper.put(Integer.valueOf(44), new RestWAException.ErrorInfo(Integer.valueOf(44), 400, "Certificate validation failed. %s", new Object[] { " (BASIC_CONSTRAINTS)" })); + dcmRCMapper.put(Integer.valueOf(45), new RestWAException.ErrorInfo(Integer.valueOf(45), 400, "Certificate validation failed. %s", new Object[] { " (SIGNATURE)" })); + dcmRCMapper.put(Integer.valueOf(46), new RestWAException.ErrorInfo(Integer.valueOf(46), 400, "Certificate validation failed. %s", new Object[] { " (ROOT_KEY_NOT_TRUSTED)" })); + dcmRCMapper.put(Integer.valueOf(47), new RestWAException.ErrorInfo(Integer.valueOf(47), 400, "Certificate validation failed. %s", new Object[] { " (IS_REVOKED)" })); + dcmRCMapper.put(Integer.valueOf(48), new RestWAException.ErrorInfo(Integer.valueOf(48), 400, "Certificate validation failed. %s", new Object[] { " (KEY_IDENTIFIER)" })); + dcmRCMapper.put(Integer.valueOf(49), new RestWAException.ErrorInfo(Integer.valueOf(49), 400, "Certificate validation failed. %s", new Object[] { " (USAGE_PERIOD)" })); + dcmRCMapper.put(Integer.valueOf(50), new RestWAException.ErrorInfo(Integer.valueOf(50), 400, "Certificate validation failed. %s", new Object[] { " (SUBJECT_ALTERNATIVE_NAME)" })); + dcmRCMapper.put(Integer.valueOf(51), new RestWAException.ErrorInfo(Integer.valueOf(51), 400, "Certificate validation failed. %s", new Object[] { " (ISSUER_ALTERNATIVE_NAME)" })); + dcmRCMapper.put(Integer.valueOf(52), new RestWAException.ErrorInfo(Integer.valueOf(52), 400, "Certificate validation failed. %s", new Object[] { " (USAGE)" })); + dcmRCMapper.put(Integer.valueOf(53), new RestWAException.ErrorInfo(Integer.valueOf(53), 400, "Certificate validation failed. %s", new Object[] { " (UNKNOWN_CRITICAL_EXTENSION)" })); + dcmRCMapper.put(Integer.valueOf(54), new RestWAException.ErrorInfo(Integer.valueOf(54), 400, "Certificate validation failed. %s", new Object[] { " (PAIR)" })); + dcmRCMapper.put(Integer.valueOf(55), new RestWAException.ErrorInfo(Integer.valueOf(55), 400, "Certificate validation failed. %s", new Object[] { " (CRL)" })); + dcmRCMapper.put(Integer.valueOf(60), new RestWAException.ErrorInfo(Integer.valueOf(60), 400, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(61), new RestWAException.ErrorInfo(Integer.valueOf(61), 400, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(71), new RestWAException.ErrorInfo(Integer.valueOf(71), 507, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(78), new RestWAException.ErrorInfo(Integer.valueOf(78), 500, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(79), new RestWAException.ErrorInfo(Integer.valueOf(79), 500, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(80), new RestWAException.ErrorInfo(Integer.valueOf(80), 400, "Error occurred when attempting to open file.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(85), new RestWAException.ErrorInfo(Integer.valueOf(85), 400, "Certificate data is not valid or is not in the correct format.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(86), new RestWAException.ErrorInfo(Integer.valueOf(86), 400, "Certificate data is not valid or is not in the correct format.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(88), new RestWAException.ErrorInfo(Integer.valueOf(88), 400, "Certificate data is not valid or is not in the correct format.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(93), new RestWAException.ErrorInfo(Integer.valueOf(93), 400, "Certificate store not found or inaccessible.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(95), new RestWAException.ErrorInfo(Integer.valueOf(95), 400, "Stash file does not exist.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(96), new RestWAException.ErrorInfo(Integer.valueOf(96), 400, "Certificate store password not valid. If this is an attempt to change the password, the new password must not match existing password.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(106), new RestWAException.ErrorInfo(Integer.valueOf(106), 400, "Keystore name is not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(103), new RestWAException.ErrorInfo(Integer.valueOf(103), 400, "One or more certificates does not have a private key", new Object[0])); + dcmRCMapper.put(Integer.valueOf(109), new RestWAException.ErrorInfo(Integer.valueOf(109), 404, "Certificate not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(111), new RestWAException.ErrorInfo(Integer.valueOf(111), 400, "Certificate data not valid or not in the correct format. %s", new Object[] { "" })); + dcmRCMapper.put(Integer.valueOf(112), new RestWAException.ErrorInfo(Integer.valueOf(112), 400, "PKCS12 certificate password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(113), new RestWAException.ErrorInfo(Integer.valueOf(113), 500, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(114), new RestWAException.ErrorInfo(Integer.valueOf(114), 400, "Algorithm is not support or is not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(119), new RestWAException.ErrorInfo(Integer.valueOf(119), 400, "Certificate store not supported or is not a valid certificate store.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(323), new RestWAException.ErrorInfo(Integer.valueOf(323), 400, "Certificate validation failed. %s", new Object[] { " (CERT_CHAIN)" })); + dcmRCMapper.put(Integer.valueOf(302), new RestWAException.ErrorInfo(Integer.valueOf(302), 400, "Algorithm is not support or is not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(303), new RestWAException.ErrorInfo(Integer.valueOf(303), 400, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(304), new RestWAException.ErrorInfo(Integer.valueOf(304), 400, "PKCS12 certificate password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(305), new RestWAException.ErrorInfo(Integer.valueOf(305), 400, "Algorithm is not support or is not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(340), new RestWAException.ErrorInfo(Integer.valueOf(340), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { "" })); + dcmRCMapper.put(Integer.valueOf(341), new RestWAException.ErrorInfo(Integer.valueOf(341), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " (CCA_RESOLVE)" })); + dcmRCMapper.put(Integer.valueOf(342), new RestWAException.ErrorInfo(Integer.valueOf(342), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " (CCA_MATDEV)" })); + dcmRCMapper.put(Integer.valueOf(343), new RestWAException.ErrorInfo(Integer.valueOf(343), 507, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_ALLOC" })); + dcmRCMapper.put(Integer.valueOf(344), new RestWAException.ErrorInfo(Integer.valueOf(344), 400, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_KEYID" })); + dcmRCMapper.put(Integer.valueOf(346), new RestWAException.ErrorInfo(Integer.valueOf(346), 400, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_INVALID_KEYSTORE" })); + dcmRCMapper.put(Integer.valueOf(347), new RestWAException.ErrorInfo(Integer.valueOf(347), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_OUTPUT_TOO_LARGE" })); + dcmRCMapper.put(Integer.valueOf(348), new RestWAException.ErrorInfo(Integer.valueOf(348), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_DEV_UNAVAILABLE" })); + dcmRCMapper.put(Integer.valueOf(349), new RestWAException.ErrorInfo(Integer.valueOf(349), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_DEV_VOFF" })); + dcmRCMapper.put(Integer.valueOf(352), new RestWAException.ErrorInfo(Integer.valueOf(352), 500, "Common Cryptographic Architecture Interface error. %s", new Object[] { " CCA_CARD_NO_MEMORY" })); + dcmRCMapper.put(Integer.valueOf(360), new RestWAException.ErrorInfo(Integer.valueOf(360), 400, "Certificate not supported. %s", new Object[] { " (VERSION)" })); + dcmRCMapper.put(Integer.valueOf(361), new RestWAException.ErrorInfo(Integer.valueOf(361), 400, "Certificate not supported. %s", new Object[] { " (MULTIPLE_AUTHSAFES)" })); + dcmRCMapper.put(Integer.valueOf(362), new RestWAException.ErrorInfo(Integer.valueOf(362), 400, "Certificate not supported. %s", new Object[] { " (CONTENT_TYPE)" })); + dcmRCMapper.put(Integer.valueOf(363), new RestWAException.ErrorInfo(Integer.valueOf(363), 400, "Certificate not supported. %s", new Object[] { " (CERT_TYPE)" })); + dcmRCMapper.put(Integer.valueOf(364), new RestWAException.ErrorInfo(Integer.valueOf(364), 400, "Certificate not supported. %s", new Object[] { " (BAG_TYPE)" })); + dcmRCMapper.put(Integer.valueOf(365), new RestWAException.ErrorInfo(Integer.valueOf(365), 400, "PKCS12 certificate password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(366), new RestWAException.ErrorInfo(Integer.valueOf(366), 400, "Certificate not supported. %s", new Object[] { " (PRIVATE_KEYPAIR_MISMATCH)" })); + dcmRCMapper.put(Integer.valueOf(367), new RestWAException.ErrorInfo(Integer.valueOf(367), 400, "Certificate not supported. %s", new Object[] { " (CONTENT_TYPE)" })); + dcmRCMapper.put(Integer.valueOf(370), new RestWAException.ErrorInfo(Integer.valueOf(370), 400, "Certificate not supported. %s", new Object[] { " (SLOT_OVERFLOW)" })); + dcmRCMapper.put(Integer.valueOf(380), new RestWAException.ErrorInfo(Integer.valueOf(380), 403, "Stash file error. %s", new Object[] { "" })); + dcmRCMapper.put(Integer.valueOf(381), new RestWAException.ErrorInfo(Integer.valueOf(381), 403, "Stash file error. %s", new Object[] { " STASH_LOCKED" })); + dcmRCMapper.put(Integer.valueOf(382), new RestWAException.ErrorInfo(Integer.valueOf(382), 500, "Stash file error. %s", new Object[] { " STASH_DAMAGED" })); + dcmRCMapper.put(Integer.valueOf(383), new RestWAException.ErrorInfo(Integer.valueOf(383), 500, "Stash file error. %s", new Object[] { " STASH_EXISTS" })); + dcmRCMapper.put(Integer.valueOf(384), new RestWAException.ErrorInfo(Integer.valueOf(384), 400, "Stash file error. %s", new Object[] { " STASH_NOT_FOUND" })); + dcmRCMapper.put(Integer.valueOf(385), new RestWAException.ErrorInfo(Integer.valueOf(385), 400, "Stash file error. %s", new Object[] { " STASH_NO_ENTRY" })); + dcmRCMapper.put(Integer.valueOf(390), new RestWAException.ErrorInfo(Integer.valueOf(390), 500, "Stash file error. %s", new Object[] { " STASH_DIR_ERROR" })); + dcmRCMapper.put(Integer.valueOf(122), new RestWAException.ErrorInfo(Integer.valueOf(122), 400, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(123), new RestWAException.ErrorInfo(Integer.valueOf(123), 400, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(125), new RestWAException.ErrorInfo(Integer.valueOf(125), 400, "Algorithm is not support or is not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3845), new RestWAException.ErrorInfo(Integer.valueOf(3845), 403, "Not authorized to perform operation.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3846), new RestWAException.ErrorInfo(Integer.valueOf(3846), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3849), new RestWAException.ErrorInfo(Integer.valueOf(3849), 403, "Password not valid.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3850), new RestWAException.ErrorInfo(Integer.valueOf(3850), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3858), new RestWAException.ErrorInfo(Integer.valueOf(3858), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3864), new RestWAException.ErrorInfo(Integer.valueOf(3864), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3912), new RestWAException.ErrorInfo(Integer.valueOf(3912), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3943), new RestWAException.ErrorInfo(Integer.valueOf(3943), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3944), new RestWAException.ErrorInfo(Integer.valueOf(3944), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3945), new RestWAException.ErrorInfo(Integer.valueOf(3945), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(3984), new RestWAException.ErrorInfo(Integer.valueOf(3984), 400, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4007), new RestWAException.ErrorInfo(Integer.valueOf(4007), 403, "Operation cannot be completed at this time.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4011), new RestWAException.ErrorInfo(Integer.valueOf(4011), 404, "Certificate not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4012), new RestWAException.ErrorInfo(Integer.valueOf(4012), 404, "Certificate not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4013), new RestWAException.ErrorInfo(Integer.valueOf(4013), 400, "Application definition not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4014), new RestWAException.ErrorInfo(Integer.valueOf(4014), 404, "Application definition not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4015), new RestWAException.ErrorInfo(Integer.valueOf(4015), 404, "Application definition not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4018), new RestWAException.ErrorInfo(Integer.valueOf(4018), 400, "Application definition not found.", new Object[0])); + dcmRCMapper.put(Integer.valueOf(4023), new RestWAException.ErrorInfo(Integer.valueOf(4023), 403, "Requested operation failed because the certificate is being used.", new Object[0])); + } + + private CertificateStoreProcessorCMS(AS400 sys, String certStoreType, String certStorePath, char[] pwd) { + super(sys, certStoreType, certStorePath, pwd); + this.systemKeystore = !(!certStorePath.equalsIgnoreCase("*SYSTEM") && + !"/QIBM/USERDATA/ICSS/CERT/SERVER/DEFAULT.KDB".equalsIgnoreCase(certStorePath)); + this.objectSigningKeystore = !(!certStorePath.equalsIgnoreCase("*OBJECTSIGNING") && + !"/QIBM/USERDATA/ICSS/CERT/SIGNING/SGNOBJ.KDB".equalsIgnoreCase(certStorePath)); + this.signatureVerificationKeystore = !(!certStorePath.equalsIgnoreCase("*SIGNATUREVERIFICATION") && + !"/QIBM/USERDATA/ICSS/CERT/SIGNING/VFYOBJ.KDB".equalsIgnoreCase(certStorePath)); + } + + private static RestWAException.ErrorInfo callQYCUDriver(ProgramCall pc, boolean generateExceptionOnError) throws Exception { + RestWAException.ErrorInfo errorinfo = null; + if (!pc.run()) { + AS400Message[] msgs = pc.getMessageList(); + StringBuilder sb = new StringBuilder(); + if (msgs != null) + for (int i = 0; i < msgs.length; i++) + sb.append(msgs[i].getID()).append(":").append(msgs[i].getText()).append(" "); + System.out.println(sb.toString()); + throw new RestWAInternalServerErrorException("Unknown error occurred while using internal DCM SPI.", new Object[0]); + } + ProgramParameter[] parmList = pc.getParameterList(); + byte[] bytes = parmList[2].getOutputData(); + int rc = intConverter.toInt(bytes, 0); + if (rc != 0) { + errorinfo = dcmRCMapper.getOrDefault(Integer.valueOf(rc), new RestWAException.ErrorInfo(Integer.valueOf(rc), 500, "Unknown error occurred while using internal DCM SPI.", new Object[0])); + if (generateExceptionOnError) + errorinfo.generateException(); + } + return errorinfo; + } + + public Map listCertificates(Set typesFilter, String aliasFilter, Integer daysUntilExpirationFilter, boolean excludeExpiredFilter) throws Exception { + int recordsReturned, recordLength; + String typeFilter = "ALL_KEYS"; + if (typesFilter != null && typesFilter.size() == 1) + if (typesFilter.contains("CA")) + typeFilter = "VERIFICATIONCERTS"; + int initialReceiverVariableSize = 32768; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + CharConverter charConverterUTF8 = new CharConverter(1208, this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 9, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("159\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + pgmParmList[5].setOutputDataLength(32); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + pgmParmList[8].setInputData(charConverter.stringToByteArray(String.valueOf(typeFilter) + "\000")); + while (true) { + RestWAException.ErrorInfo errorinfo = callQYCUDriver(pc, false); + if (errorinfo != null) { + if (errorinfo.getRC() == 4003) + return null; + if (errorinfo.getRC() != 4000) + errorinfo.generateException(); + } + byte[] arrayOfByte = pgmParmList[5].getOutputData(); + int totalRecords = intConverter.toInt(arrayOfByte, 0); + recordsReturned = intConverter.toInt(arrayOfByte, 4); + recordLength = intConverter.toInt(arrayOfByte, 8); + int totalLength = intConverter.toInt(arrayOfByte, 12); + String informationComplete = charConverter.byteArrayToString(arrayOfByte, 16, 1); + if (totalLength > initialReceiverVariableSize) { + initialReceiverVariableSize = totalLength; + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + continue; + } + break; + } + Pattern p = CommonUtil.getGenericNamePattern(aliasFilter); + byte[] outBuf = pgmParmList[3].getOutputData(); + int offset = 0; + Map certMap = null; + if (recordsReturned > 0) + certMap = new TreeMap<>(CommonUtil.StringComparator); + for (int i = 0; i < recordsReturned; i++) { + boolean certificateTrusted = (outBuf[offset + 4] == 1); + String keyStorageLocation = dcmCertKeyStoreLocMap.getOrDefault(Byte.valueOf(outBuf[offset + 5]), "UNKNOWN"); + String certificateType = dcmCertTypeNumMap.getOrDefault(Byte.valueOf(outBuf[offset + 6]), "UNKNOWN"); + if (this.objectSigningKeystore && outBuf[offset + 6] == 2) + certificateType = "OBJECT_SIGNING"; + String keyAlgorithm = dcmKeyAlgorithmMap.getOrDefault(Byte.valueOf(outBuf[offset + 7]), "UNKNOWN"); + String certificateCommonName = charConverterUTF8.byteArrayToString(outBuf, offset + 8, 64).replace("\000", ""); + String certificateLabel = charConverterUTF8.byteArrayToString(outBuf, offset + 72, 256).replace("\000", ""); + int daysBeforeCertificateExpires = intConverter.toInt(outBuf, offset + 328) + 1; + int keySize = intConverter.toInt(outBuf, offset + 332); + String signatureAlgorithm = dcmSignatureMap.getOrDefault(Integer.valueOf(intConverter.toInt(outBuf, offset + 336)), "UNKNOWN"); + boolean defaultCertificate = (outBuf[offset + 340] == 1); + boolean hasPrivateKey = (outBuf[offset + 341] == 1); + if ((p == null || p.matcher(certificateLabel).matches()) && ( + typesFilter == null || typesFilter.contains("ALL_KEYS") || typesFilter.contains(certificateType) || ( + hasPrivateKey && typesFilter.contains("SERVER_CLIENT"))) && ( + daysUntilExpirationFilter == null || ( + !certificateType.equalsIgnoreCase("CSR") && daysUntilExpirationFilter.intValue() > -1 && daysBeforeCertificateExpires <= daysUntilExpirationFilter.intValue())) && ( + !excludeExpiredFilter || certificateType.equalsIgnoreCase("CSR") || daysBeforeCertificateExpires > 0)) { + CertificateStoreProcessor.CertificateInfo cerInfo = new CertificateStoreProcessor.CertificateInfo(certificateLabel, certificateCommonName, certificateType, daysBeforeCertificateExpires, + keyAlgorithm, keySize, keyStorageLocation, hasPrivateKey, signatureAlgorithm, certificateTrusted, defaultCertificate); + certMap.put(certificateLabel, cerInfo); + } + offset += recordLength; + } + return certMap; + } + + public void changeCertificateStorePassword(String certStorePasswordNew, int daysToExpiration, boolean resetPasswordFromSystemStash) throws Exception { + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", resetPasswordFromSystemStash ? 8 : 9, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray(resetPasswordFromSystemStash ? "140\000" : "137\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + int parmIndex = 3; + if (!resetPasswordFromSystemStash) + pgmParmList[++parmIndex].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + pgmParmList[++parmIndex].setInputData(charConverter.stringToByteArray(String.valueOf(certStorePasswordNew) + "\000")); + pgmParmList[++parmIndex].setInputData(charConverter.stringToByteArray(String.valueOf(certStorePasswordNew) + "\000")); + String pwdExpiration = (daysToExpiration > -1) ? Integer.toString(daysToExpiration) : "0"; + pgmParmList[++parmIndex].setInputData(charConverter.stringToByteArray(String.valueOf(pwdExpiration) + "\000")); + pgmParmList[++parmIndex].setInputData(charConverter.stringToByteArray("1\000")); + callQYCUDriver(pc, true); + } + + public CertificateStoreProcessor.CertificateData exportCertificate(String certFormat, String certAlias, String certDataPassword) throws Exception { + Map certs = listCertificates((Set)null, certAlias, (Integer)null, false); + if (certs == null || certs.isEmpty()) + throw new RestWANotFoundException("Certificate not found.", new Object[0]); + CertificateStoreProcessor.CertificateInfo theCert = certs.get(certAlias); + if (theCert.type.equalsIgnoreCase("CSR") || ( + theCert.type.equalsIgnoreCase("CA") && certFormat.equalsIgnoreCase("PKCS12") && !theCert.hasPrivateKey)) + throw new RestWABadRequestException("The export of certificate in the requested format is not supported.", new Object[0]); + String filePath = CommonUtil.generateTempFilePath(this._sys, "RSEAPI_DCM_CERT_EXPORT", null, false); + int numParams = 9; + String op = "155\000"; + if (!certFormat.equalsIgnoreCase("PKCS12")) { + numParams = 8; + op = "157\000"; + } + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", numParams, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray(op)); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(certAlias) + "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(filePath) + "\000")); + String format = certFormat.equalsIgnoreCase("PEM") ? "BASE64\000" : "BIN\000"; + if (numParams == 9) { + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(certDataPassword) + "\000")); + pgmParmList[8].setInputData(charConverter.stringToByteArray(String.valueOf(certDataPassword) + "\000")); + } else { + pgmParmList[7].setInputData(charConverter.stringToByteArray(format)); + } + try { + callQYCUDriver(pc, true); + } catch (Exception e) { + CommonUtil.deleteFile(this._sys, filePath); + throw e; + } + byte[] certBytes = certFormat.equalsIgnoreCase("PEM") ? + CommonUtil.readFromFileText(this._sys, filePath, true) : CommonUtil.readFromFile(this._sys, filePath, true); + return new CertificateStoreProcessor.CertificateData(certFormat.toUpperCase(), certBytes); + } + + public void importCertificate(String certType, String certFormat, String certAlias, byte[] certData, String certDataPassword) throws Exception { + if ((certType.equalsIgnoreCase("CA") && certFormat.equalsIgnoreCase("PKCS12")) || ( + certType.equalsIgnoreCase("SERVER_CLIENT") && !certFormat.equalsIgnoreCase("PKCS12"))) + throw new RestWABadRequestException("The import of certificate in the specified format is not supported.", new Object[0]); + String filePath = null; + try { + filePath = CommonUtil.generateTempFilePath(this._sys, "RSEAPI_DCM_CERT_IMPORT", null, true); + CommonUtil.writeToFile(this._sys, filePath, certData); + int numParams = 8; + String op = "169\000"; + if (certType.equalsIgnoreCase("CA")) + op = "181\000"; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", numParams, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray(op)); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + if (certType.equalsIgnoreCase("CA")) { + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(certAlias) + "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(filePath) + "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(certFormat.equalsIgnoreCase("DER") ? "0\000" : "1\000")); + } else { + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(filePath) + "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(certDataPassword) + "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(certAlias) + "\000")); + } + callQYCUDriver(pc, true); + } finally { + try { + if (filePath != null) + CommonUtil.deleteFile(this._sys, filePath); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + public void deleteCertificate(String certAlias) throws Exception { + Map certs = listCertificates((Set)null, certAlias, (Integer)null, false); + if (certs == null || certs.isEmpty()) + throw new RestWANotFoundException("Certificate not found.", new Object[0]); + String certType = ((CertificateStoreProcessor.CertificateInfo)certs.get(certAlias)).type; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 6, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray(certType.equalsIgnoreCase("CSR") ? "153\000" : "152\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(certAlias) + "\000")); + callQYCUDriver(pc, true); + } + + public CertificateStoreProcessor.CertificateInfo getCertificateInfo(String certAlias) throws Exception { + Map certs = listCertificates((Set)null, certAlias, (Integer)null, false); + if (certs == null || certs.isEmpty()) + throw new RestWANotFoundException("Certificate not found.", new Object[0]); + String certType = ((CertificateStoreProcessor.CertificateInfo)certs.get(certAlias)).type; + int initialReceiverVariableSize = 32768; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + CharConverter charConverterUTF8 = new CharConverter(1208, this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 8, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray(certType.equalsIgnoreCase("CSR") ? "165\000" : "163\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(this._realCertStorePath) + "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(new String(this._certStorePassword)) + "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(certAlias) + "\000")); + while (true) { + callQYCUDriver(pc, true); + byte[] arrayOfByte = pgmParmList[3].getOutputData(); + int bytesReturned = intConverter.toInt(arrayOfByte, 0); + int bytesAvailable = intConverter.toInt(arrayOfByte, 4); + if (bytesAvailable > bytesReturned) { + initialReceiverVariableSize = bytesAvailable; + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + continue; + } + break; + } + byte[] outBuf = pgmParmList[3].getOutputData(); + int keySize = intConverter.toInt(outBuf, 8); + boolean hasPrivateKey = (outBuf[12] == 1); + boolean trusted = (outBuf[13] == 1); + boolean hasCertificate = (outBuf[14] == 1); + String keyStorageLocation = dcmCertKeyStoreLocMap.getOrDefault(Byte.valueOf(outBuf[15]), "UNKNOWN"); + String privateKeyLabel = charConverterUTF8.byteArrayToString(outBuf, 16, 64).replace("\000", ""); + String keyAlgorithm = dcmKeyAlgorithmMap.getOrDefault(Byte.valueOf(outBuf[80]), "UNKNOWN"); + String signatureAlgorithm = dcmSignatureMap.getOrDefault(Integer.valueOf(intConverter.toInt(outBuf, 92)), "UNKNOWN"); + String subjectCN = charConverterUTF8.byteArrayToString(outBuf, 96, 256).replace("\000", ""); + String subjectOU = charConverterUTF8.byteArrayToString(outBuf, 352, 256).replace("\000", ""); + String subjectO = charConverterUTF8.byteArrayToString(outBuf, 608, 256).replace("\000", ""); + String subjectL = charConverterUTF8.byteArrayToString(outBuf, 864, 128).replace("\000", ""); + String subjectSP = charConverterUTF8.byteArrayToString(outBuf, 992, 128).replace("\000", ""); + String subjectZip = charConverterUTF8.byteArrayToString(outBuf, 1120, 16).replace("\000", ""); + String subjectC = charConverterUTF8.byteArrayToString(outBuf, 1136, 3).replace("\000", ""); + String issuerCN = charConverterUTF8.byteArrayToString(outBuf, 1139, 256).replace("\000", ""); + String issuerOU = charConverterUTF8.byteArrayToString(outBuf, 1395, 256).replace("\000", ""); + String issuerO = charConverterUTF8.byteArrayToString(outBuf, 1651, 256).replace("\000", ""); + String issuerL = charConverterUTF8.byteArrayToString(outBuf, 1907, 128).replace("\000", ""); + String issuerSP = charConverterUTF8.byteArrayToString(outBuf, 2035, 128).replace("\000", ""); + String issuerZip = charConverterUTF8.byteArrayToString(outBuf, 2163, 16).replace("\000", ""); + String issuerC = charConverterUTF8.byteArrayToString(outBuf, 2179, 3).replace("\000", ""); + String serialNumber = charConverterUTF8.byteArrayToString(outBuf, 2182, 64).replace("\000", ""); + String effectiveDate = adjustYCUDriverTimeStamp(charConverterUTF8.byteArrayToString(outBuf, 2246, 24).replace("\000", "")); + String expirationDate = adjustYCUDriverTimeStamp(charConverterUTF8.byteArrayToString(outBuf, 2270, 24).replace("\000", "")); + boolean hasExtensions = (outBuf[2294] == 1); + String keyUsageExtensions = charConverterUTF8.byteArrayToString(outBuf, 2295, 16).replace("\000", ""); + int cryptoDevDescCount = intConverter.toInt(outBuf, 2312); + int sanCount = intConverter.toInt(outBuf, 2320); + int sanOffset = intConverter.toInt(outBuf, 2324); + String subjectDN = CommonUtil.generateDistinquishedName(new String[] { + "CN", subjectCN, "OU", subjectOU, "O", subjectO, "L", subjectL, "SP", subjectSP, + "PC", subjectZip, "C", subjectC }); + String issuerDN = CommonUtil.generateDistinquishedName(new String[] { + "CN", issuerCN, "OU", issuerOU, "O", issuerO, "L", issuerL, "SP", issuerSP, + "PC", issuerZip, "C", issuerC }); + List sanList = null; + if (sanCount > 0) { + sanList = new ArrayList<>(); + for (int i = 0; i < sanCount; i++) { + int sanLen = intConverter.toInt(outBuf, sanOffset + 4); + String san = charConverterUTF8.byteArrayToString(outBuf, sanOffset + 8, sanLen); + sanList.add(san); + sanOffset += 264; + } + } + List extensionsList = null; + if (hasExtensions) { + extensionsList = new ArrayList<>(); + if (keyUsageExtensions.charAt(0) == '1') + extensionsList.add("DIGITAL_SIGNATURE"); + if (keyUsageExtensions.charAt(1) == '1') + extensionsList.add("NONREPUDIATION"); + if (keyUsageExtensions.charAt(2) == '1') + extensionsList.add("KEY_ENCIPHERMENT"); + if (keyUsageExtensions.charAt(3) == '1') + extensionsList.add("DATA_ENCIPHERMENT"); + if (keyUsageExtensions.charAt(4) == '1') + extensionsList.add("KEY_AGREEMENT"); + if (keyUsageExtensions.charAt(5) == '1') + extensionsList.add("CERTIFICATE_SIGNING"); + if (keyUsageExtensions.charAt(6) == '1') + extensionsList.add("CRL_SIGNING"); + if (keyUsageExtensions.charAt(7) == '1') + extensionsList.add("ENCIPHER_ONLY"); + if (keyUsageExtensions.charAt(8) == '1') + extensionsList.add("DECIPHER_ONLY"); + } + CertificateStoreProcessor.CertificateInfo ci = new CertificateStoreProcessor.CertificateInfo(certAlias, certType, keyAlgorithm, keySize, keyStorageLocation, hasPrivateKey, + signatureAlgorithm, trusted, subjectCN, subjectOU, subjectO, subjectL, subjectSP, + subjectZip, subjectC, issuerCN, issuerOU, issuerO, issuerL, issuerSP, + issuerZip, issuerC, subjectDN, issuerDN, serialNumber, effectiveDate, expirationDate, + extensionsList, sanList); + return ci; + } + + public void exportCertStore() throws Exception {} + + public void importCertStore() throws Exception {} + + public Map listAppDefinitions(String appidFilter, String apptypeFilter) throws Exception { + int recordsReturned, recordLength, initialReceiverVariableSize = 32768; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + CharConverter charConverterUTF8 = new CharConverter(1208, this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 7, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("121\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + pgmParmList[5].setOutputDataLength(32); + pgmParmList[6].setInputData(charConverter.stringToByteArray(String.valueOf(dcmStrAppTypeMap.getOrDefault(apptypeFilter, "0")) + "\000")); + while (true) { + RestWAException.ErrorInfo errorinfo = callQYCUDriver(pc, false); + if (errorinfo != null) { + if (errorinfo.getRC() == 4008) + return null; + if (errorinfo.getRC() != 4000) + errorinfo.generateException(); + } + byte[] arrayOfByte = pgmParmList[5].getOutputData(); + int totalRecords = intConverter.toInt(arrayOfByte, 0); + recordsReturned = intConverter.toInt(arrayOfByte, 4); + recordLength = intConverter.toInt(arrayOfByte, 8); + int totalLength = intConverter.toInt(arrayOfByte, 12); + String informationComplete = charConverter.byteArrayToString(arrayOfByte, 16, 1); + if (totalLength > initialReceiverVariableSize) { + initialReceiverVariableSize = totalLength; + pgmParmList[3].setOutputDataLength(initialReceiverVariableSize); + pgmParmList[4].setInputData(BinaryConverter.intToByteArray(initialReceiverVariableSize)); + continue; + } + break; + } + byte[] outBuf = pgmParmList[3].getOutputData(); + int offset = 0; + Map appidMap = null; + Pattern appidFilterPattern = null; + if (recordsReturned > 0) { + appidMap = new TreeMap<>(CommonUtil.StringComparator); + appidFilterPattern = CommonUtil.getGenericNamePattern(appidFilter); + } + for (int i = 0; i < recordsReturned; i++) { + String appdefid = charConverterUTF8.byteArrayToString(outBuf, offset + 0, 100).replace("\000", "").trim(); + if (appidFilterPattern == null || appidFilterPattern.matcher(appdefid).matches()) { + String apptype = dcmAppTypeStrMap.getOrDefault(Byte.valueOf(outBuf[offset + 100]), "UNKNOWN"); + String descr = charConverter.byteArrayToString(outBuf, offset + 101, 330).replace("\000", "").trim(); + boolean hasCerts = (outBuf[offset + 431] != 48); + List certs = null; + if (hasCerts) { + certs = new ArrayList<>(); + int tempOffset = offset + 432; + for (int j = 0; j < 4; j++) { + String cert = charConverterUTF8.byteArrayToString(outBuf, tempOffset, 256).replace("\000", "").trim(); + if (CommonUtil.hasContent(cert)) + certs.add(cert); + tempOffset += 256; + } + } + CertificateStoreProcessor.AppDefinition appidInfo = new CertificateStoreProcessor.AppDefinition(appdefid, apptype, descr, certs, null); + appidMap.put(appdefid, appidInfo); + } + offset += recordLength; + } + return appidMap; + } + + public void associateCertificatesToAppDefinition(String appID, List certAliases) throws Exception { + Map appdefs = listAppDefinitions(appID, "*"); + if (appdefs.isEmpty()) + throw new RestWANotFoundException("Application definition not found.", new Object[0]); + CertificateStoreProcessor.AppDefinition appdef = appdefs.get(appID); + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 9, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("126\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(certAliases.get(0)) + "\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray((certAliases.size() > 1) ? (String.valueOf(certAliases.get(1)) + "\000") : "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray((certAliases.size() > 2) ? (String.valueOf(certAliases.get(2)) + "\000") : "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray((certAliases.size() > 3) ? (String.valueOf(certAliases.get(3)) + "\000") : "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(appID) + "\000")); + pgmParmList[8].setInputData(charConverter.stringToByteArray((new StringBuilder()).append(dcmAppTypeMap.get(appdef.appType)).append("\000").toString())); + callQYCUDriver(pc, true); + } + + public void disassociateCertificatesFromAppDefinition(String appID) throws Exception { + Map appdefs = listAppDefinitions(appID, "*"); + if (appdefs.isEmpty()) + throw new RestWANotFoundException("Application definition not found.", new Object[0]); + CertificateStoreProcessor.AppDefinition appdef = appdefs.get(appID); + List certAliases = appdef.certList; + if (certAliases == null || certAliases.isEmpty()) + return; + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 9, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("125\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray(String.valueOf(certAliases.get(0)) + "\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray((certAliases.size() > 1) ? (String.valueOf(certAliases.get(1)) + "\000") : "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray((certAliases.size() > 2) ? (String.valueOf(certAliases.get(2)) + "\000") : "\000")); + pgmParmList[6].setInputData(charConverter.stringToByteArray((certAliases.size() > 3) ? (String.valueOf(certAliases.get(3)) + "\000") : "\000")); + pgmParmList[7].setInputData(charConverter.stringToByteArray(String.valueOf(appID) + "\000")); + pgmParmList[8].setInputData(charConverter.stringToByteArray((new StringBuilder()).append(dcmAppTypeMap.get(appdef.appType)).append("\000").toString())); + callQYCUDriver(pc, true); + } + + public void trustCertificateForAppDefinition(String appDefinitionID, List certAliases) throws Exception { + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 6, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("127\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray("TRUST\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray(String.valueOf(certAliases.get(0)) + "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(appDefinitionID) + "\000")); + callQYCUDriver(pc, true); + } + + public void untrustCertificateForAppDefinition(String appDefinitionID, List certAliases) throws Exception { + CharConverter charConverter = new CharConverter(this._sys.getCcsid(), this._sys); + ProgramCall pc = CommonUtil.getProgramCall(this._sys, "/QSYS.LIB/QICSS.LIB/QYCUDRIVER.PGM", 6, false); + ProgramParameter[] pgmParmList = pc.getParameterList(); + pgmParmList[0].setInputData(charConverter.stringToByteArray("127\000")); + pgmParmList[1].setInputData(charConverter.stringToByteArray("QIBM_RSEAPI_RC\000")); + pgmParmList[2].setOutputDataLength(4); + pgmParmList[3].setInputData(charConverter.stringToByteArray("NOTRUST\000")); + pgmParmList[4].setInputData(charConverter.stringToByteArray(String.valueOf(certAliases.get(0)) + "\000")); + pgmParmList[5].setInputData(charConverter.stringToByteArray(String.valueOf(appDefinitionID) + "\000")); + callQYCUDriver(pc, true); + } + + private static String adjustYCUDriverTimeStamp(String s) { + if (s.length() != 24) + return s; + return String.valueOf(s.substring(0, s.length() - 2)) + ":" + s.substring(s.length() - 2); + } + } + + public static class CertificateInfo { + public String alias; + + public String commonName; + + public String type; + + public int daysBeforeExpiration; + + public String keyAlgorithm; + + public int keySize; + + public String keyStorageLocation; + + public boolean hasPrivateKey; + + public String signatureAlgorithm; + + public boolean trusted; + + public boolean defaultCertificate; + + public String subjectCN; + + public String subjectOU; + + public String subjectO; + + public String subjectL; + + public String subjectSP; + + public String subjectZip; + + public String subjectC; + + public String issuerCN; + + public String issuerOU; + + public String issuerO; + + public String issuerL; + + public String issuerSP; + + public String issuerZip; + + public String issuerC; + + public String subjectDN; + + public String issuerDN; + + public String serialNumber; + + public String effectiveDate; + + public String expirationDate; + + public List keyUsageList; + + public List sanList; + + public CertificateInfo(String alias, String type, String keyAlgorithm, int keySize, String keyStorageLocation, boolean hasPrivateKey, String signatureAlgorithm, boolean trusted, String subjectCN, String subjectOU, String subjectO, String subjectL, String subjectSP, String subjectZip, String subjectC, String issuerCN, String issuerOU, String issuerO, String issuerL, String issuerSP, String issuerZip, String issuerC, String subjectDN, String issuerDN, String serialNumber, String effectiveDate, String expirationDate, List extensions, List sanList) { + this.alias = alias; + this.type = type; + this.keyAlgorithm = keyAlgorithm; + this.keySize = keySize; + this.keyStorageLocation = keyStorageLocation; + this.hasPrivateKey = hasPrivateKey; + this.signatureAlgorithm = signatureAlgorithm; + this.trusted = trusted; + this.subjectCN = subjectCN; + this.subjectOU = subjectOU; + this.subjectO = subjectO; + this.subjectL = subjectL; + this.subjectSP = subjectSP; + this.subjectZip = subjectZip; + this.subjectC = subjectC; + this.issuerCN = issuerCN; + this.issuerOU = issuerOU; + this.issuerO = issuerO; + this.issuerL = issuerL; + this.issuerSP = issuerSP; + this.issuerZip = issuerZip; + this.issuerC = issuerC; + this.subjectDN = subjectDN; + this.issuerDN = issuerDN; + this.serialNumber = serialNumber; + this.effectiveDate = effectiveDate; + this.expirationDate = expirationDate; + this.keyUsageList = extensions; + this.sanList = sanList; + } + + public CertificateInfo(String alias, String commonName, String type, int daysBeforeExpiration, String keyAlgorithm, int keySize, String keyStorageLocation, boolean hasPrivateKey, String signatureAlgorithm, boolean trusted, boolean defaultCertificate) { + this.alias = alias; + this.commonName = commonName; + this.type = type; + this.daysBeforeExpiration = daysBeforeExpiration; + this.keyAlgorithm = keyAlgorithm; + this.keySize = keySize; + this.keyStorageLocation = keyStorageLocation; + this.hasPrivateKey = hasPrivateKey; + this.signatureAlgorithm = signatureAlgorithm; + this.trusted = trusted; + this.defaultCertificate = defaultCertificate; + } + } + + public static class CertificateData { + public byte[] certData; + + public String format; + + public CertificateData(String format, byte[] certData) { + this.format = format; + this.certData = certData; + } + } + + public static class AppDefinition { + public String appid; + + public String appType; + + public String description; + + public List certList; + + public List certListTrustedCA; + + public AppDefinition(String appid, String appType, String description, List certList, List trustedCAList) { + this.appid = appid; + this.appType = appType; + this.description = description; + this.certList = certList; + this.certListTrustedCA = trustedCAList; + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/ChecksumUtil.java b/src/main/java/dev/alexzaw/rest4i/util/ChecksumUtil.java new file mode 100644 index 0000000..846eea9 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/ChecksumUtil.java @@ -0,0 +1,63 @@ +package dev.alexzaw.rest4i.util; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class ChecksumUtil { + + public static String getMessageDigest(String content) throws Exception { + byte[] byteArray = content.getBytes(); + InputStream byteStream = new ByteArrayInputStream(byteArray); + return getMessageDigest(byteStream); + } + + public static String getMessageDigest(InputStream byteStream) throws Exception { + String digest = null; + MessageDigest md = null; + try { + md = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw e; + } + DigestInputStream ds = new DigestInputStream(byteStream, md); + BufferedInputStream bs = new BufferedInputStream(ds); + byte[] buffer = new byte[4096]; + int bytesRead = -1; + do { + try { + bytesRead = bs.read(buffer); + } catch (IOException e) { + throw e; + } + } while (bytesRead >= 0); + try { + bs.close(); + } catch (IOException e) { + throw e; + } + byte[] digestArray = md.digest(); + digest = toHexString(digestArray); + return digest; + } + + private static String toHexString(byte[] bytes) { + StringBuilder builder = new StringBuilder(2 * bytes.length); + byte b; + int i; + byte[] arrayOfByte; + for (i = (arrayOfByte = bytes).length, b = 0; b < i; ) { + byte b1 = arrayOfByte[b]; + String s = Integer.toHexString(0xFF & b1); + if (b1 >= 0 && b1 < 16) + builder.append('0'); + builder.append(s); + b++; + } + return builder.toString(); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/CommonConstants.java b/src/main/java/dev/alexzaw/rest4i/util/CommonConstants.java new file mode 100644 index 0000000..5dc4f77 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/CommonConstants.java @@ -0,0 +1,142 @@ +package dev.alexzaw.rest4i.util; + +public interface CommonConstants { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + + public static final String WWWAUTHENTICATE_HEADER = "WWW-Authenticate"; + + public static final String path = "path"; + + public static final String operand = "operand"; + + public static final String command = "command"; + + public static final String Scan = "Scan"; + + public static final String hostEncoding = "hostEncoding"; + + public static final String binary = "binary"; + + public static final String Convert = "Convert"; + + public static final String Etag = "ETag"; + + public static final String recordRangeTag = "record-range"; + + public static final String content = "content"; + + public static final String IfMatch = "If-Match"; + + public static final String newName = "newName"; + + public static final String destination = "destination"; + + public static final String filename = "filename"; + + public static final String text = "text"; + + public static final String invocation = "invocation"; + + public static final String mbr = "mbr"; + + public static final String records = "records"; + + public static final String status = "status"; + + public static final String filter = "filter"; + + public static final String prefix = "prefix"; + + public static final String owner = "owner"; + + public static final String jobName = "jobName"; + + public static final String Basic = "Basic"; + + public static final String Bearer = "Bearer"; + + public static final String port = "port"; + + public static final String userid = "userID"; + + public static final String expiration = "expiration"; + + public static final String creation = "creation"; + + public static final String lastused = "lastUsed"; + + public static final String domain = "domain"; + + public static final String utf8 = "UTF-8"; + + public static final String PrettyOutput = "prettyOutput"; + + public static final String NumOfResults = "number-of-results"; + + public static final String objectLibrary = "objectLibrary"; + + public static final String objectType = "objectType"; + + public static final String objectSubtype = "objectSubtype"; + + public static final String objectName = "objectName"; + + public static final String attrList = "attrList"; + + public static final String TIMESTAMP = "timestamp"; + + public static final String TITLE = "title"; + + public static final String STATUS = "status"; + + public static final String DETAIL = "detail"; + + public static final String TYPE = "type"; + + public static final String ERROR = "error"; + + public static final String INSTANCE = "instance"; + + public static final String DETAILS = "details"; + + public static final String libList = "liblist"; + + public static final String encoding = "encoding"; + + public static final String default_encoding = "default.encoding"; + + public static final String RSEAPI_VERSION_PATH = "/api/v1"; + + public static final String SESSIONCOOKIENAME = "apimlAuthenticationToken"; + + public static final String expired = "expired"; + + public static final String host = "host"; + + public static final String ENVVARS = "envvars"; + + public static final String verbose = "verbose"; + + public static final String ignoreerrors = "ignoreerrors"; + + public static final String memberName = "memberName"; + + public static final String memberType = "memberType"; + + public static final String MAXJOBLOGRECORDS = "maxjoblogrecords"; + + public static final String JOBLOGFILTER = "joblogfilter"; + + public static final String HOST = "host"; + + public static final String METHOD = "method"; + + public static final String library = "library"; + + public static final String commandname = "commandname"; + + public static final String ignorecase = "ignorecase"; + + public static final String user = "user"; +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/CommonUtil.java b/src/main/java/dev/alexzaw/rest4i/util/CommonUtil.java new file mode 100644 index 0000000..37870fc --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/CommonUtil.java @@ -0,0 +1,456 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.ErrorCodeParameter; +import com.ibm.as400.access.IFSFile; +import com.ibm.as400.access.IFSFileInputStream; +import com.ibm.as400.access.IFSFileOutputStream; +import com.ibm.as400.access.IFSFileReader; +import com.ibm.as400.access.IFSTextFileOutputStream; +import com.ibm.as400.access.ProgramCall; +import com.ibm.as400.access.ProgramParameter; +import com.ibm.as400.access.QSYSObjectPathName; +import com.ibm.as400.access.ServiceProgramCall; +import com.ibm.as400.access.User; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.Reader; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Pattern; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.servlet.http.HttpServletRequest; +import javax.xml.bind.DatatypeConverter; + +public class CommonUtil { + + public static final String TEMPDIR = "/QIBM/UserData/OS/AdminInst/admin5/wlp/usr/servers/admin5/QiasTemp"; + + public static final ThreadLocal _serviceContext = new ThreadLocal<>(); + + public static String bytesToString(byte[] rawBytes) { + StringBuilder sb = new StringBuilder(); + if (rawBytes != null) + for (int i = 0; i < rawBytes.length; i++) + sb.append(Integer.toString((rawBytes[i] & 0xFF) + 256, 16).substring(1)); + return sb.toString(); + } + + public static String stringToHex(String s) { + StringBuilder sb = new StringBuilder(); + char[] ch = s.toCharArray(); + for (int i = 0; i < ch.length; i++) + sb.append(Integer.toHexString(ch[i])); + return sb.toString(); + } + + public static String hexToString(String hex) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hex.length() - 1; i += 2) { + String tempInHex = hex.substring(i, i + 2); + sb.append((char)Integer.parseInt(tempInHex, 16)); + } + return sb.toString(); + } + + public static boolean isValid(String property, long value, long min, long max, boolean genException) { + if (value < min || (max != -1L && value > max)) { + if (genException) + throw new RestWABadRequestException("The value for property '%s' is not valid.", new Object[] { property }); + return false; + } + return true; + } + + public static long getNumericProperty(String propName, String s, long min, long max, long defaultValue) { + long returnValue = defaultValue; + boolean usingDefault = false; + if (hasContent(s)) + try { + returnValue = Long.parseLong(s); + } catch (Exception e) { + returnValue = defaultValue; + usingDefault = true; + } + if ((returnValue != defaultValue && returnValue < min) || ( + max != -1L && returnValue > max) || + returnValue > Long.MAX_VALUE) { + returnValue = defaultValue; + usingDefault = true; + } + if (usingDefault && propName != null) + AsyncLogger.traceWarning("", "The value for property '%s' is not valid. Using default value of %s. ", new Object[] { propName, Long.toString(defaultValue) }); + return returnValue; + } + + public static int getLength(String s) { + return (s == null) ? 0 : s.length(); + } + + public static String toBase64(byte[] data) { + if (data == null || data.length == 0) + return ""; + return DatatypeConverter.printBase64Binary(data); + } + + public static byte[] fromBase64(String data) { + if (data == null) + return new byte[0]; + return DatatypeConverter.parseBase64Binary(data); + } + + public static String toDate(Date sqlDate) { + return sqlDate.toString(); + } + + public static String toTime(Time sqlTime) { + ZoneOffset zoneOffSet = ZoneId.systemDefault().getRules().getOffset(Instant.now()); + return String.valueOf(sqlTime.toString()) + zoneOffSet.toString(); + } + + public static String toTimestamp(Timestamp sqlTimestamp) { + LocalDateTime localDateTime = sqlTimestamp.toLocalDateTime(); + ZoneOffset zoneOffSet = ZoneId.systemDefault().getRules().getOffset(localDateTime); + OffsetDateTime offsetDateTime = localDateTime.atOffset(zoneOffSet); + String offsetDateTime_string = offsetDateTime.format(DateTimeFormatter.ISO_DATE_TIME); + String zoneOffSet_string = zoneOffSet.toString(); + if (zoneOffSet_string.length() > 6) { + int indx = offsetDateTime_string.lastIndexOf(":"); + offsetDateTime_string = offsetDateTime_string.substring(0, indx); + } + return offsetDateTime_string; + } + + public static String getRemoteUser() { + String remoteUser = null; + HttpServletRequest req = _serviceContext.get(); + if (req != null) + remoteUser = req.getRemoteUser(); + return remoteUser; + } + + public static String getRemoteAddr() { + String remoteAddr = null; + HttpServletRequest req = _serviceContext.get(); + if (req != null) + remoteAddr = req.getRemoteAddr(); + return remoteAddr; + } + + public static int getServerPort() { + int port = -1; + HttpServletRequest req = _serviceContext.get(); + if (req != null) + port = req.getServerPort(); + return port; + } + + public static boolean hasContent(String string) { + return (string != null && string.trim().length() > 0); + } + + public static boolean hasContent(char[] ca) { + return (ca != null && ca.length > 0); + } + + public static boolean hasContent(List strArray) { + if (strArray == null) + return false; + for (String s : strArray) { + if (hasContent(s)) + return true; + } + return false; + } + + public static String getFilenameFromPath(String path) { + int lastSlash = path.lastIndexOf('/'); + return path.substring(lastSlash + 1); + } + + public static String trimLeft(String s) { + if (s == null || s.length() == 0) + return ""; + int startIndex = 0; + int len = s.length(); + for (startIndex = 0; startIndex < len; startIndex++) { + char c = s.charAt(startIndex); + if (c != ' ') + break; + } + return s.substring(startIndex); + } + + public static String trimBoth(String s) { + String ls = trimLeft(s); + return trimRight(ls); + } + + public static String trimRight(String s) { + if (s == null || s.length() == 0) + return ""; + int endIndex = 0; + int len = s.length(); + for (endIndex = len - 1; endIndex > -1; endIndex--) { + char c = s.charAt(endIndex); + if (c != ' ') + break; + } + return s.substring(0, endIndex + 1); + } + + public static String normalizePath(String path) { + if (!hasContent(path)) + throw new RestWABadRequestException("Path name not set.", new Object[0]); + if (detectReflectedXSS(path)) + throw new RestWABadRequestException("Reflected cross site scripting vulnerabilities detected in input data.", new Object[0]); + StringBuilder sb = new StringBuilder(); + if (!path.startsWith("/")) + sb.append("/"); + sb.append(path); + while (sb.length() > 1 && sb.charAt(1) == '/') + sb.deleteCharAt(1); + while (sb.length() > 0 && sb.charAt(sb.length() - 1) == '/') + sb.setLength(sb.length() - 1); + if (sb.length() == 0) + sb.append('/'); + return sb.toString(); + } + + public static Properties mapToProperties(Map map) { + Properties p = new Properties(); + if (map != null) { + Set> set = map.entrySet(); + for (Map.Entry entry : set) { + if (hasContent(entry.getKey()) && hasContent(entry.getValue())) + p.put(((String)entry.getKey()).trim(), ((String)entry.getValue()).trim()); + } + } + return p; + } + + public static boolean detectReflectedXSS(String input) { + if (!hasContent(input)) + return false; + String lc = input.toLowerCase(); + if ((lc.contains("") || lc.contains(" 0 && containsErrorCodeParam) { + ErrorCodeParameter errorCodeParameter = new ErrorCodeParameter(); + errorCodeParameter.setParameterType(2); + parameterList[numOfParams - 1] = (ProgramParameter)errorCodeParameter; + numOfParams--; + } + for (int i = 0; i < numOfParams; i++) { + pgmParameter = new ProgramParameter(); + pgmParameter.setParameterType(2); + parameterList[i] = pgmParameter; + } + ServiceProgramCall spc = new ServiceProgramCall(as400); + spc.setProgram(srvpgmPath, parameterList); + spc.setProcedureName(procedure); + spc.setThreadSafe(true); + if (intReturnValue) + spc.setReturnValueFormat(1); + return spc; + } + + public static ProgramCall getProgramCall(AS400 as400, String pgmPath, int numOfParams, boolean containsErrorCodeParam) throws Exception { + ProgramParameter[] parameterList = new ProgramParameter[numOfParams]; + ProgramParameter pgmParameter = null; + if (numOfParams > 0 && containsErrorCodeParam) { + ErrorCodeParameter errorCodeParameter = new ErrorCodeParameter(); + errorCodeParameter.setParameterType(2); + parameterList[numOfParams - 1] = (ProgramParameter)errorCodeParameter; + numOfParams--; + } + for (int i = 0; i < numOfParams; i++) { + pgmParameter = new ProgramParameter(); + pgmParameter.setParameterType(2); + parameterList[i] = pgmParameter; + } + ProgramCall pc = new ProgramCall(as400); + pc.setProgram(pgmPath, parameterList); + pc.setThreadSafe(true); + return pc; + } + + public static Comparator StringComparator = new Comparator() { + public int compare(String o1, String o2) { + if (o1.equals(o2)) + return 0; + String o1Lower = o1.toLowerCase(); + String o2Lower = o2.toLowerCase(); + if (o1Lower.equals(o2Lower)) + return o1Lower.equals(o1) ? -1 : 1; + return o1Lower.compareTo(o2Lower); + } + }; + + public static Pattern getGenericNamePattern(String s) { + Pattern p = null; + try { + if (s != null) { + String regExpr = ""; + if (s.endsWith("*")) { + s = s.substring(0, s.length() - 1); + regExpr = "(.*)"; + } + if (!s.equals("*") && !s.isEmpty()) + p = Pattern.compile(String.valueOf(Pattern.quote(s)) + regExpr, 2); + } + } catch (Exception exception) {} + return p; + } + + public static String generateTempFilePath(AS400 sys, String fn_prefix, String fn_suffix, boolean createFile) throws Exception { + String suffix = hasContent(fn_suffix) ? ("." + fn_suffix) : ""; + String filePath = "/QIBM/UserData/OS/AdminInst/admin5/wlp/usr/servers/admin5/QiasTemp" + File.separator + fn_prefix + "_" + Thread.currentThread().getId() + suffix; + IFSFile ifsfile = new IFSFile(sys, filePath); + deleteFile(ifsfile); + if (createFile) { + if (!ifsfile.createNewFile()) { + AsyncLogger.traceSevere("generateTempFilePath", "Unable to create file '%s'.", new Object[] { ifsfile.getPath() }); + throw new RestWAInternalServerErrorException(); + } + ifsfile.setCCSID(819); + } + return filePath; + } + + public static byte[] readFromFile(AS400 sys, String filePath, boolean deleteAfterRead) throws Exception { + IFSFileInputStream is = null; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + try { + is = new IFSFileInputStream(sys, filePath, -4); + int dataRead = 0; + while ((dataRead = is.read(buffer)) > 0) + baos.write(buffer, 0, dataRead); + } finally { + try { + if (is != null) + is.close(); + } catch (Exception exception) {} + if (deleteAfterRead) + deleteFile(sys, filePath); + } + return baos.toByteArray(); + } + + public static byte[] readFromFileText(AS400 sys, String filePath, boolean deleteAfterRead) throws Exception { + BufferedReader is = null; + StringBuilder sb = new StringBuilder(); + try { + IFSFile ifsfile = new IFSFile(sys, filePath); + is = new BufferedReader((Reader)new IFSFileReader(ifsfile, ifsfile.getCCSID(), -4)); + String l; + while ((l = is.readLine()) != null) + sb.append(l).append("\n"); + } finally { + try { + if (is != null) + is.close(); + } catch (Exception exception) {} + if (deleteAfterRead) + deleteFile(sys, filePath); + } + return sb.toString().getBytes(); + } + + public static void writeToFile(AS400 sys, String filePath, byte[] data) throws Exception { + IFSFileOutputStream os = new IFSFileOutputStream(sys, filePath, 819); + try { + os.write(data); + os.flush(); + } finally { + try { + if (os != null) + os.close(); + } catch (Exception exception) {} + } + } + + public static void writeToFileText(AS400 sys, String filePath, String data) throws Exception { + IFSTextFileOutputStream os = new IFSTextFileOutputStream(sys, filePath, 819); + try { + os.write(data); + os.flush(); + } finally { + try { + if (os != null) + os.close(); + } catch (Exception exception) {} + } + } + + public static void deleteFile(IFSFile ifsfile) throws Exception { + if (ifsfile.exists() && !ifsfile.delete()) { + AsyncLogger.traceSevere("deleteFile", "Unable to delete file '%s'.", new Object[] { ifsfile.getPath() }); + throw new RestWAInternalServerErrorException(); + } + } + + public static void deleteFile(AS400 sys, String filePath) throws Exception { + deleteFile(new IFSFile(sys, filePath)); + } + + public static String generateDistinquishedName(String... args) throws Exception { + int size = (args != null) ? args.length : 0; + int attrcount = size / 2; + if (attrcount == 0) + return null; + List rdnList = new ArrayList<>(); + int j = 0; + for (int i = 0; i < attrcount; i++) { + if (hasContent(args[j]) && hasContent(args[j + 1])) { + Rdn rdn = new Rdn(args[j], args[j + 1]); + rdnList.add(0, rdn); + } + j += 2; + } + if (!rdnList.isEmpty()) { + LdapName ldapname = new LdapName(rdnList); + return ldapname.toString(); + } + return null; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/GlobalProperties.java b/src/main/java/dev/alexzaw/rest4i/util/GlobalProperties.java new file mode 100644 index 0000000..2bfa9ff --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/GlobalProperties.java @@ -0,0 +1,403 @@ +package dev.alexzaw.rest4i.util; + +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import java.io.File; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class GlobalProperties { + + private static final boolean IS_IBMI = System.getProperty("os.name").equalsIgnoreCase("OS/400"); + + private static final String RSEAPI_USERATA = "/QIBM/UserData/OS/RSEAPI"; + + private static final String RSEAPI_PROPERTY_PATH = "/QIBM/UserData/OS/RSEAPI/rseapi.properties"; + + public static final String RSEAPI_VERSION = "1.0.7"; + + private static final String RSEAPI_BASEPATH = "rseapi"; + + private static final String RSEAPI_ADMINUSERS = "com.ibm.rseapi.adminusers"; + + private static final String RSEAPI_INCLUDEUSERS = "com.ibm.rseapi.includeusers"; + + private static final String RSEAPI_EXCLUDEUSERS = "com.ibm.rseapi.excludeusers"; + + private static final String RSEAPI_SESSION_MAXUSECOUNT = "com.ibm.rseapi.maxsessionusecount"; + + private static final String RSEAPI_SESSION_MAXLIFETIME = "com.ibm.rseapi.maxsessionlifetime"; + + private static final String RSEAPI_SESSION_MAXINACTIVITY = "com.ibm.rseapi.maxsessioninactivity"; + + private static final String RSEAPI_SESSION_MAXWAITTIME = "com.ibm.rseapi.maxsessionwaittime"; + + private static final String RSEAPI_SESSION_MAXPERUSER = "com.ibm.rseapi.maxsessionsperuser"; + + private static final String RSEAPI_SESSION_MAX = "com.ibm.rseapi.maxsessions"; + + private static final String RSEAPI_SESSION_CLEANUP_INTERVAL = "com.ibm.rseapi.sessioncleanupinterval"; + + private static final String RSEAPI_MAX_FILE_SIZE = "com.ibm.rseapi.maxfilesize"; + + public static final long NO_MAX = -1L; + + public static final int DEFAULT_SESSION_CLEANUP_INTERVAL = 300; + + public static final long DEFAULT_SESSION_MAXUSECOUNT = -1L; + + public static final long DEFAULT_SESSION_MAXLIFETIME = -1L; + + public static final long DEFAULT_SESSION_MAXINACTIVITY = 7200L; + + public static final long DEFAULT_SESSION_MAXWAITTIME = 300L; + + public static final long DEFAULT_SESSION_MAXPERUSER = 20L; + + public static final long DEFAULT_SESSION_MAX = 100L; + + public static final long DEFAULT_MAX_FILESIZE = 3072000L; + + public static final long MIN_FILESIZE = 0L; + + public static final long MAX_FILESIZE = 15360000L; + + public static final long MIN_SESSION_MAXUSECOUNT = 1L; + + public static final long MAX_SESSION_MAXUSECOUNT = -1L; + + public static final long MIN_SESSION_MAXLIFETIME = 30L; + + public static final long MAX_SESSION_MAXLIFETIME = -1L; + + public static final long MIN_SESSION_MAXINACTIVITY = 30L; + + public static final long MAX_SESSION_MAXINACTIVITY = 7200L; + + public static final long MIN_SESSION_CLEANUP_INTERVAL = 30L; + + public static final long MAX_SESSION_CLEANUP_INTERVAL = 900L; + + public static final long MIN_SESSION_PERUSER = 1L; + + public static final long MAX_SESSION_PERUSER = -1L; + + public static final long MIN_SESSION_MAX = 0L; + + public static final long MAX_SESSION_MAX = -1L; + + public static final long MIN_SESSION_WAITTIME = 0L; + + public static final long MAX_SESSION_WAITTIME = -1L; + + private static Set _adminUsersSet = new HashSet<>(); + + private static List _adminUsers = new ArrayList<>(); + + private static Set _includeUsersSet = new HashSet<>(); + + private static List _includeUsers = new ArrayList<>(); + + private static Set _excludeUsersSet = new HashSet<>(); + + private static List _excludeUsers = new ArrayList<>(); + + private static long _sessionMaxLifetime = -1L; + + private static long _sessionMaxUseCount = -1L; + + private static long _sessionMaxInactivity = 7200L; + + private static long _sessionMaxWaitTime = 300L; + + private static long _sessionCleanupInterval = 300L; + + private static long _maxSessionsPerUser = 20L; + + private static long _maxSessions = 100L; + + private static long _maxFileSize = 3072000L; + + private static boolean _useSecureConnections = true; + + private static String _hostName = "localhost"; + + private static String _os_name = System.getProperty("os.name"); + + private static String _os_version = System.getProperty("os.version"); + + private static String _java_version = System.getProperty("java.version"); + + static { + try { + _hostName = InetAddress.getLocalHost().getCanonicalHostName(); + } catch (UnknownHostException e) { + AsyncLogger.traceWarning("GlobalProperties", "Unable to get canonical host name. " + e.getMessage(), new Object[0]); + try { + _hostName = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException IGNORE) { + _hostName = null; + } + } + updatePropertiesFromJVMProperties(); + updatePropertiesFromFile(); + StringBuilder sb = new StringBuilder(); + sb.append("[").append("com.ibm.rseapi.adminusers").append("=").append(_adminUsers).append("] "); + sb.append("[").append("com.ibm.rseapi.includeusers").append("=").append(_includeUsers).append("] "); + sb.append("[").append("com.ibm.rseapi.excludeusers").append("=").append(_excludeUsers).append("] "); + sb.append("[").append("com.ibm.rseapi.maxfilesize").append("=").append(_maxFileSize).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessions").append("=").append(_maxSessions).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessionsperuser").append("=").append(_maxSessionsPerUser).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessioninactivity").append("=").append(_sessionMaxInactivity).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessionlifetime").append("=").append(_sessionMaxLifetime).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessionusecount").append("=").append(_sessionMaxUseCount).append("] "); + sb.append("[").append("com.ibm.rseapi.maxsessionwaittime").append("=").append(_sessionMaxWaitTime).append("] "); + sb.append("[").append("com.ibm.rseapi.sessioncleanupinterval").append("=").append(_sessionCleanupInterval).append("] "); + AsyncLogger.traceDebug("GlobalProperties", sb.toString(), new Object[0]); + } + + public static String getRSEAPIVersion() { + return "1.0.7"; + } + + public static String getRSEAPIBasePath() { + return "rseapi"; + } + + public static long getSessionCleanupInterval() { + return _sessionCleanupInterval; + } + + public static void setSessionCleanupInterval(long l) { + _sessionCleanupInterval = l; + } + + public static long getSessionMaxInactivity() { + return _sessionMaxInactivity; + } + + public static void setSessionMaxInactivity(long l) { + _sessionMaxInactivity = l; + } + + public static long getSessionMaxLifetime() { + return _sessionMaxLifetime; + } + + public static void setSessionMaxLifetime(long l) { + _sessionMaxLifetime = l; + } + + public static void setSessionMaxUseCount(long l) { + _sessionMaxUseCount = l; + } + + public static long getSessionMaxUseCount() { + return _sessionMaxUseCount; + } + + public static long getMaxSessionsPerUser() { + return _maxSessionsPerUser; + } + + public static void setMaxSessionsPerUser(long l) { + _maxSessionsPerUser = l; + } + + public static long getMaxSessions() { + return _maxSessions; + } + + public static void setMaxSessions(long l) { + _maxSessions = l; + } + + public static long getMaxFileSize() { + return _maxFileSize; + } + + public static void setMaxFileSize(long l) { + _maxFileSize = l; + } + + public static long getSessionMaxWaitTime() { + return _sessionMaxWaitTime; + } + + public static void setSessionMaxWaitTime(long l) { + _sessionMaxWaitTime = l; + } + + public static boolean useSecureConnections() { + return _useSecureConnections; + } + + public static boolean isIBMi() { + return IS_IBMI; + } + + public static String getHostName() { + return _hostName; + } + + public static String getOSName() { + return _os_name; + } + + public static String getOSVersion() { + return _os_version; + } + + public static String getJavaVersion() { + return _java_version; + } + + public static List getAdminUsers() { + return _adminUsers; + } + + private static void setAdminUserids(String adminUsers) { + List userList = null; + if (!CommonUtil.hasContent(adminUsers)) { + userList = new ArrayList<>(); + } else { + userList = Arrays.asList(adminUsers.split(",")); + } + setAdminUserids(userList); + } + + public static void setAdminUserids(List adminUsers) { + if (adminUsers == null) + return; + _adminUsersSet.clear(); + _adminUsers = null; + for (String u : adminUsers) { + if (CommonUtil.hasContent(u)) + _adminUsersSet.add(u.trim().toUpperCase()); + } + _adminUsers = new ArrayList<>(_adminUsersSet); + Collections.sort(_adminUsers); + } + + public static boolean isAdminUserid(String u) { + return _adminUsersSet.contains(u.toUpperCase()); + } + + public static List getIncludeUsers() { + return _includeUsers; + } + + private static void setIncludeUserids(String includeUsers) { + List userList = null; + if (!CommonUtil.hasContent(includeUsers)) { + userList = new ArrayList<>(); + } else { + userList = Arrays.asList(includeUsers.split(",")); + } + setIncludeUserids(userList); + } + + public static void setIncludeUserids(List includeUsers) { + if (includeUsers == null) + return; + _includeUsersSet.clear(); + _includeUsers = null; + for (String u : includeUsers) { + if (CommonUtil.hasContent(u)) + _includeUsersSet.add(u.trim().toUpperCase()); + } + _includeUsers = new ArrayList<>(_includeUsersSet); + Collections.sort(_includeUsers); + } + + public static boolean isIncludeUserid(String u) { + return !(!_includeUsersSet.isEmpty() && !_includeUsersSet.contains(u.toUpperCase())); + } + + public static List getExcludeUsers() { + return _excludeUsers; + } + + private static void setExcludeUserids(String excludeUsers) { + List userList = null; + if (!CommonUtil.hasContent(excludeUsers)) { + userList = new ArrayList<>(); + } else { + userList = Arrays.asList(excludeUsers.split(",")); + } + setExcludeUserids(userList); + } + + public static void setExcludeUserids(List excludeUsers) { + if (excludeUsers == null) + return; + _excludeUsersSet.clear(); + _excludeUsers = null; + for (String u : excludeUsers) { + if (CommonUtil.hasContent(u)) + _excludeUsersSet.add(u.trim().toUpperCase()); + } + _excludeUsers = new ArrayList<>(_excludeUsersSet); + Collections.sort(_excludeUsers); + } + + public static boolean isExcludeUserid(String u) { + return _excludeUsersSet.contains(u.toUpperCase()); + } + + public static void save() { + if (!IS_IBMI) + throw new RestWABadRequestException("Save of properties failed. %s", new Object[] { "" }); + File pf = new File("/QIBM/UserData/OS/RSEAPI/rseapi.properties"); + try { + Exception exception2, exception1 = null; + } catch (Exception e) { + AsyncLogger.traceException("save", e, null, new Object[0]); + throw new RestWAInternalServerErrorException("Save of properties failed. %s", new Object[] { e.getMessage() }); + } + } + + private static void updatePropertiesFromJVMProperties() { + String value = System.getProperty("com.ibm.rseapi.adminusers"); + setAdminUserids(value); + value = System.getProperty("com.ibm.rseapi.includeusers"); + setIncludeUserids(value); + value = System.getProperty("com.ibm.rseapi.excludeusers"); + setExcludeUserids(value); + value = System.getProperty("com.ibm.rseapi.sessioncleanupinterval"); + _sessionCleanupInterval = CommonUtil.getNumericProperty("com.ibm.rseapi.sessioncleanupinterval", value, 30L, 7200L, _sessionCleanupInterval); + value = System.getProperty("com.ibm.rseapi.maxsessioninactivity"); + _sessionMaxInactivity = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessioninactivity", value, 30L, 7200L, _sessionMaxInactivity); + value = System.getProperty("com.ibm.rseapi.maxsessionlifetime"); + _sessionMaxLifetime = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessionlifetime", value, 30L, -1L, _sessionMaxLifetime); + value = System.getProperty("com.ibm.rseapi.maxsessionusecount"); + _sessionMaxUseCount = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessionusecount", value, 1L, -1L, _sessionMaxUseCount); + value = System.getProperty("com.ibm.rseapi.maxsessionsperuser"); + _maxSessionsPerUser = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessionsperuser", value, 1L, -1L, _maxSessionsPerUser); + value = System.getProperty("com.ibm.rseapi.maxsessions"); + _maxSessions = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessions", value, 0L, -1L, _maxSessions); + value = System.getProperty("com.ibm.rseapi.maxfilesize"); + _maxFileSize = CommonUtil.getNumericProperty("com.ibm.rseapi.maxfilesize", value, 0L, 15360000L, _maxFileSize); + value = System.getProperty("com.ibm.rseapi.maxsessionwaittime"); + _sessionMaxWaitTime = CommonUtil.getNumericProperty("com.ibm.rseapi.maxsessionwaittime", value, 0L, -1L, _sessionMaxWaitTime); + } + + private static void updatePropertiesFromFile() { + if (!IS_IBMI) + return; + File pf = new File("/QIBM/UserData/OS/RSEAPI/rseapi.properties"); + if (!pf.exists() || !pf.canRead()) + return; + try { + Exception exception2, exception1 = null; + } catch (Exception e) { + AsyncLogger.traceException("GlobalProperties", e, null, new Object[0]); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/JSONSerializer.java b/src/main/java/dev/alexzaw/rest4i/util/JSONSerializer.java new file mode 100644 index 0000000..fa2dddf --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/JSONSerializer.java @@ -0,0 +1,175 @@ +package dev.alexzaw.rest4i.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class JSONSerializer { + + private static final String[] REPLACEMENT_CHARS = new String[128]; + + static { + for (int i = 0; i <= 31; i++) { + REPLACEMENT_CHARS[i] = String.format("\\u%04x", new Object[] { Integer.valueOf(i) }); + } + REPLACEMENT_CHARS[34] = "\\\""; + REPLACEMENT_CHARS[92] = "\\\\"; + REPLACEMENT_CHARS[9] = "\\t"; + REPLACEMENT_CHARS[8] = "\\b"; + REPLACEMENT_CHARS[10] = "\\n"; + REPLACEMENT_CHARS[13] = "\\r"; + REPLACEMENT_CHARS[12] = "\\f"; + } + + private StringBuilder _sb = null; + + public JSONSerializer() { + this(100); + } + + public JSONSerializer(int initialSize) { + this._sb = new StringBuilder(initialSize); + } + + public JSONSerializer startObject() { + this._sb.append("{"); + return this; + } + + public JSONSerializer startObject(String identifier) { + this._sb.append("\"").append(escapeJSON(identifier)).append("\": {"); + return this; + } + + public JSONSerializer endObject() { + if (this._sb.charAt(this._sb.length() - 1) == ',') + this._sb.setLength(this._sb.length() - 1); + this._sb.append("},"); + return this; + } + + public JSONSerializer startArray() { + this._sb.append("["); + return this; + } + + public JSONSerializer startArray(String identifier) { + this._sb.append("\"").append(escapeJSON(identifier)).append("\": ["); + return this; + } + + public JSONSerializer endArray() { + if (this._sb.charAt(this._sb.length() - 1) == ',') + this._sb.setLength(this._sb.length() - 1); + this._sb.append("],"); + return this; + } + + public JSONSerializer add(String name, String value) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + if (value != null) { + this._sb.append("\"").append(escapeJSON(value)).append("\","); + } else { + this._sb.append("null,"); + } + return this; + } + + public JSONSerializer add(String name, Long value) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + if (value != null) { + this._sb.append(value.longValue()).append(","); + } else { + this._sb.append("null,"); + } + return this; + } + + public JSONSerializer add(String name, Integer value) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + if (value != null) { + this._sb.append(value.intValue()).append(","); + } else { + this._sb.append("null,"); + } + return this; + } + + public JSONSerializer add(String name, Boolean value) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + if (value != null) { + this._sb.append(value.toString()).append(","); + } else { + this._sb.append("null,"); + } + return this; + } + + public JSONSerializer add(String name, Boolean value, boolean defaultValueIfNull) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + value = Boolean.valueOf((value != null) ? value.booleanValue() : defaultValueIfNull); + this._sb.append(value.toString()).append(","); + return this; + } + + public JSONSerializer add(String name, List values) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + this._sb.append("["); + if (values != null && !values.isEmpty()) { + for (String v : values) + this._sb.append("\"").append(escapeJSON(v)).append("\","); + this._sb.setLength(this._sb.length() - 1); + } + this._sb.append("],"); + return this; + } + + public JSONSerializer add(String name, Map map) { + this._sb.append("\"").append(escapeJSON(name)).append("\": "); + this._sb.append("{"); + if (map != null && !map.isEmpty()) { + List mapKeys = new ArrayList<>(map.keySet()); + Collections.sort(mapKeys); + for (String k : mapKeys) { + this._sb.append("\"").append(escapeJSON(k)).append("\": "); + this._sb.append("\"").append(escapeJSON(map.get(k))).append("\","); + } + this._sb.setLength(this._sb.length() - 1); + } + this._sb.append("},"); + return this; + } + + public String toString() { + if (this._sb.charAt(this._sb.length() - 1) == ',') + this._sb.setLength(this._sb.length() - 1); + return this._sb.toString(); + } + + public static String escapeJSON(String raw) { + if (raw == null || raw.length() == 0) + return ""; + return escapeJSON(new StringBuilder(raw.length()), raw).toString(); + } + + private static StringBuilder escapeJSON(StringBuilder sb, String raw) { + if (raw == null || raw.length() == 0) + return sb; + int len = raw.length(); + String replacement = ""; + int beginning = 0; + for (int i = 0; i < len; i++) { + char charAt = raw.charAt(i); + if ((charAt >= '€' || (replacement = REPLACEMENT_CHARS[charAt]) != null) && charAt < '€') { + if (i > beginning) + sb.append(raw.substring(beginning, i)); + sb.append(replacement); + beginning = i + 1; + } + } + if (beginning < len) + sb.append(raw.substring(beginning)); + return sb; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/JsonUtils.java b/src/main/java/dev/alexzaw/rest4i/util/JsonUtils.java new file mode 100644 index 0000000..5ec71e0 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/JsonUtils.java @@ -0,0 +1,443 @@ +package dev.alexzaw.rest4i.util; + +import java.io.*; +import java.util.*; +import java.util.regex.Pattern; + +/** + * JSON utility class for parsing and manipulating JSON data without external dependencies. + * + * This implementation provides basic JSON parsing capabilities using only standard + * Java libraries, suitable for use in environments where external JSON libraries + * may not be available. + * + * @author alexzaw + */ +public class JsonUtils { + + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(\\.\\d+)?([eE][+-]?\\d+)?"); + + /** + * Parses JSON from an InputStream into a Map. + * + * @param inputStream The input stream containing JSON data + * @return A Map representation of the JSON data + * @throws IOException If reading from the stream fails + * @throws IllegalArgumentException If the JSON is malformed + */ + public static Map parseJsonToMap(InputStream inputStream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return parseJsonToMap(sb.toString()); + } + } + + /** + * Parses a JSON string into a Map. + * + * @param jsonString The JSON string to parse + * @return A Map representation of the JSON data + * @throws IllegalArgumentException If the JSON is malformed + */ + public static Map parseJsonToMap(String jsonString) { + if (jsonString == null || jsonString.trim().isEmpty()) { + return new HashMap<>(); + } + + JsonParser parser = new JsonParser(jsonString.trim()); + Object result = parser.parseValue(); + + if (result instanceof Map) { + @SuppressWarnings("unchecked") + Map mapResult = (Map) result; + return mapResult; + } else { + throw new IllegalArgumentException("JSON does not represent an object"); + } + } + + /** + * Converts a Map to a JSON string. + * + * @param map The map to convert + * @return JSON string representation + */ + public static String mapToJsonString(Map map) { + if (map == null) { + return "null"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("{"); + + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + sb.append("\"").append(escapeJsonString(entry.getKey())).append("\":"); + sb.append(valueToJsonString(entry.getValue())); + first = false; + } + + sb.append("}"); + return sb.toString(); + } + + /** + * Converts any value to its JSON string representation. + * + * @param value The value to convert + * @return JSON string representation of the value + */ + public static String valueToJsonString(Object value) { + if (value == null) { + return "null"; + } else if (value instanceof String) { + return "\"" + escapeJsonString((String) value) + "\""; + } else if (value instanceof Number) { + return value.toString(); + } else if (value instanceof Boolean) { + return value.toString(); + } else if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) value; + return mapToJsonString(map); + } else if (value instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) value; + return listToJsonString(list); + } else { + return "\"" + escapeJsonString(value.toString()) + "\""; + } + } + + /** + * Converts a List to a JSON string. + * + * @param list The list to convert + * @return JSON string representation + */ + public static String listToJsonString(List list) { + if (list == null) { + return "null"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("["); + + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(","); + } + sb.append(valueToJsonString(item)); + first = false; + } + + sb.append("]"); + return sb.toString(); + } + + /** + * Escapes special characters in a JSON string. + * + * @param str The string to escape + * @return The escaped string + */ + private static String escapeJsonString(String str) { + if (str == null) { + return ""; + } + + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Simple JSON parser implementation. + */ + private static class JsonParser { + private final String json; + private int index; + + public JsonParser(String json) { + this.json = json; + this.index = 0; + } + + public Object parseValue() { + skipWhitespace(); + + if (index >= json.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + + char ch = json.charAt(index); + + switch (ch) { + case '{': + return parseObject(); + case '[': + return parseArray(); + case '"': + return parseString(); + case 't': + case 'f': + return parseBoolean(); + case 'n': + return parseNull(); + default: + if (ch == '-' || Character.isDigit(ch)) { + return parseNumber(); + } + throw new IllegalArgumentException("Unexpected character: " + ch); + } + } + + private Map parseObject() { + Map map = new HashMap<>(); + + expect('{'); + skipWhitespace(); + + if (peek() == '}') { + index++; // consume '}' + return map; + } + + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + Object value = parseValue(); + + map.put(key, value); + + skipWhitespace(); + char ch = peek(); + if (ch == '}') { + index++; // consume '}' + break; + } else if (ch == ',') { + index++; // consume ',' + } else { + throw new IllegalArgumentException("Expected ',' or '}', got: " + ch); + } + } + + return map; + } + + private List parseArray() { + List list = new ArrayList<>(); + + expect('['); + skipWhitespace(); + + if (peek() == ']') { + index++; // consume ']' + return list; + } + + while (true) { + skipWhitespace(); + Object value = parseValue(); + list.add(value); + + skipWhitespace(); + char ch = peek(); + if (ch == ']') { + index++; // consume ']' + break; + } else if (ch == ',') { + index++; // consume ',' + } else { + throw new IllegalArgumentException("Expected ',' or ']', got: " + ch); + } + } + + return list; + } + + private String parseString() { + expect('"'); + + StringBuilder sb = new StringBuilder(); + + while (index < json.length()) { + char ch = json.charAt(index); + + if (ch == '"') { + index++; // consume closing quote + return sb.toString(); + } else if (ch == '\\') { + index++; // consume backslash + if (index >= json.length()) { + throw new IllegalArgumentException("Unexpected end of JSON in string escape"); + } + + char escaped = json.charAt(index); + switch (escaped) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + default: + throw new IllegalArgumentException("Invalid escape sequence: \\" + escaped); + } + index++; + } else { + sb.append(ch); + index++; + } + } + + throw new IllegalArgumentException("Unterminated string"); + } + + private Number parseNumber() { + int start = index; + + if (peek() == '-') { + index++; + } + + if (!Character.isDigit(peek())) { + throw new IllegalArgumentException("Invalid number format"); + } + + // Parse integer part + if (peek() == '0') { + index++; + } else { + while (index < json.length() && Character.isDigit(peek())) { + index++; + } + } + + boolean isDouble = false; + + // Parse decimal part + if (index < json.length() && peek() == '.') { + isDouble = true; + index++; + + if (index >= json.length() || !Character.isDigit(peek())) { + throw new IllegalArgumentException("Invalid number format"); + } + + while (index < json.length() && Character.isDigit(peek())) { + index++; + } + } + + // Parse exponent part + if (index < json.length() && (peek() == 'e' || peek() == 'E')) { + isDouble = true; + index++; + + if (index < json.length() && (peek() == '+' || peek() == '-')) { + index++; + } + + if (index >= json.length() || !Character.isDigit(peek())) { + throw new IllegalArgumentException("Invalid number format"); + } + + while (index < json.length() && Character.isDigit(peek())) { + index++; + } + } + + String numberStr = json.substring(start, index); + + try { + if (isDouble) { + return Double.parseDouble(numberStr); + } else { + long longValue = Long.parseLong(numberStr); + if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) { + return (int) longValue; + } else { + return longValue; + } + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid number format: " + numberStr); + } + } + + private Boolean parseBoolean() { + if (json.substring(index).startsWith("true")) { + index += 4; + return Boolean.TRUE; + } else if (json.substring(index).startsWith("false")) { + index += 5; + return Boolean.FALSE; + } else { + throw new IllegalArgumentException("Invalid boolean value"); + } + } + + private Object parseNull() { + if (json.substring(index).startsWith("null")) { + index += 4; + return null; + } else { + throw new IllegalArgumentException("Invalid null value"); + } + } + + private void expect(char expected) { + if (index >= json.length() || json.charAt(index) != expected) { + throw new IllegalArgumentException("Expected '" + expected + "'"); + } + index++; + } + + private char peek() { + if (index >= json.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + return json.charAt(index); + } + + private void skipWhitespace() { + while (index < json.length() && Character.isWhitespace(json.charAt(index))) { + index++; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/util/ResponseHelper.java b/src/main/java/dev/alexzaw/rest4i/util/ResponseHelper.java new file mode 100644 index 0000000..c3b088c --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/ResponseHelper.java @@ -0,0 +1,405 @@ +package dev.alexzaw.rest4i.util; + +import dev.alexzaw.rest4i.model.ApiResponse; +import dev.alexzaw.rest4i.model.ErrorInfo; +import dev.alexzaw.rest4i.model.PaginationInfo; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.Map; + +/** + * Response helper utility for creating standardized REST responses using the ApiResponse wrapper. + * + * This utility provides convenient methods for creating consistent HTTP responses + * across all REST4i integration services while maintaining compatibility with + * existing IBM i RSE API patterns. + * + * @author alexzaw + */ +public class ResponseHelper { + + /** + * Creates a successful response with data and HTTP 200 status. + * + * @param The type of data + * @param data The response data + * @return JAX-RS Response with HTTP 200 and JSON content + */ + public static Response ok(T data) { + return Response.ok(ApiResponse.success(data)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a successful response with data and message. + * + * @param The type of data + * @param data The response data + * @param message Success message + * @return JAX-RS Response with HTTP 200 and JSON content + */ + public static Response ok(T data, String message) { + return Response.ok(ApiResponse.success(data, message)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a successful response with just a message (no data). + * + * @param message Success message + * @return JAX-RS Response with HTTP 200 and JSON content + */ + public static Response ok(String message) { + return Response.ok(ApiResponse.success(message)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a successful paginated response. + * + * @param The type of data + * @param data The response data + * @param pagination Pagination information + * @return JAX-RS Response with HTTP 200 and JSON content + */ + public static Response ok(T data, PaginationInfo pagination) { + return Response.ok(ApiResponse.success(data, pagination)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a successful response with custom metadata. + * + * @param The type of data + * @param data The response data + * @param metadata Custom metadata to include + * @return JAX-RS Response with HTTP 200 and JSON content + */ + public static Response ok(T data, Map metadata) { + ApiResponse response = ApiResponse.success(data); + response.setMetadata(metadata); + return Response.ok(response) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a created response (HTTP 201) with location header. + * + * @param The type of data + * @param data The created resource data + * @param location The location of the created resource + * @return JAX-RS Response with HTTP 201 and JSON content + */ + public static Response created(T data, URI location) { + return Response.created(location) + .entity(ApiResponse.success(data, "Resource created successfully")) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a created response (HTTP 201) with location header and message. + * + * @param The type of data + * @param data The created resource data + * @param location The location of the created resource + * @param message Custom creation message + * @return JAX-RS Response with HTTP 201 and JSON content + */ + public static Response created(T data, URI location, String message) { + return Response.created(location) + .entity(ApiResponse.success(data, message)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates an accepted response (HTTP 202) for async operations. + * + * @param message Processing message + * @return JAX-RS Response with HTTP 202 and JSON content + */ + public static Response accepted(String message) { + return Response.accepted(ApiResponse.success(message)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a no content response (HTTP 204). + * + * @return JAX-RS Response with HTTP 204 + */ + public static Response noContent() { + return Response.noContent().build(); + } + + /** + * Creates a bad request response (HTTP 400). + * + * @param message Error message + * @return JAX-RS Response with HTTP 400 and JSON content + */ + public static Response badRequest(String message) { + ErrorInfo error = new ErrorInfo("BAD_REQUEST", message, 400); + return Response.status(Response.Status.BAD_REQUEST) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a bad request response with validation errors. + * + * @param message Main error message + * @param validationErrors Specific validation errors + * @return JAX-RS Response with HTTP 400 and JSON content + */ + public static Response badRequest(String message, java.util.List validationErrors) { + ErrorInfo error = ErrorInfo.validation(message, validationErrors); + return Response.status(Response.Status.BAD_REQUEST) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates an unauthorized response (HTTP 401). + * + * @param message Error message + * @return JAX-RS Response with HTTP 401 and JSON content + */ + public static Response unauthorized(String message) { + ErrorInfo error = ErrorInfo.authentication(message); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a forbidden response (HTTP 403). + * + * @param message Error message + * @return JAX-RS Response with HTTP 403 and JSON content + */ + public static Response forbidden(String message) { + ErrorInfo error = ErrorInfo.authorization(message); + return Response.status(Response.Status.FORBIDDEN) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a not found response (HTTP 404). + * + * @param message Error message + * @return JAX-RS Response with HTTP 404 and JSON content + */ + public static Response notFound(String message) { + ErrorInfo error = ErrorInfo.notFound(message); + return Response.status(Response.Status.NOT_FOUND) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a conflict response (HTTP 409). + * + * @param message Error message + * @return JAX-RS Response with HTTP 409 and JSON content + */ + public static Response conflict(String message) { + ErrorInfo error = ErrorInfo.conflict(message); + return Response.status(Response.Status.CONFLICT) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a request timeout response (HTTP 408). + * + * @param message Error message + * @return JAX-RS Response with HTTP 408 and JSON content + */ + public static Response requestTimeout(String message) { + ErrorInfo error = ErrorInfo.timeout(message); + return Response.status(408) // Request Timeout + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates an internal server error response (HTTP 500). + * + * @param message Error message + * @return JAX-RS Response with HTTP 500 and JSON content + */ + public static Response serverError(String message) { + ErrorInfo error = ErrorInfo.serverError(message); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a response from an exception. + * + * @param exception The exception + * @return JAX-RS Response with appropriate HTTP status and JSON content + */ + public static Response fromException(Exception exception) { + ErrorInfo error = ErrorInfo.fromException(exception); + Response.Status status = getStatusFromError(error); + + return Response.status(status) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a response from an exception with additional context. + * + * @param exception The exception + * @param request The HTTP request for context + * @return JAX-RS Response with appropriate HTTP status and JSON content + */ + public static Response fromException(Exception exception, HttpServletRequest request) { + ErrorInfo error = ErrorInfo.fromException(exception); + + // Add request context + if (request != null) { + error.setPath(request.getRequestURI()); + error.setMethod(request.getMethod()); + } + + Response.Status status = getStatusFromError(error); + + return Response.status(status) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a custom error response. + * + * @param httpStatus HTTP status code + * @param errorCode Error code + * @param message Error message + * @return JAX-RS Response with specified status and JSON content + */ + public static Response error(int httpStatus, String errorCode, String message) { + ErrorInfo error = new ErrorInfo(errorCode, message, httpStatus); + return Response.status(httpStatus) + .entity(ApiResponse.error(error)) + .type(MediaType.APPLICATION_JSON + "; charset=UTF-8") + .build(); + } + + /** + * Creates a response with custom content type. + * + * @param The type of data + * @param data The response data + * @param contentType The content type + * @return JAX-RS Response with specified content type + */ + public static Response custom(T data, String contentType) { + if (data instanceof ApiResponse) { + return Response.ok(data).type(contentType).build(); + } else { + return Response.ok(ApiResponse.success(data)).type(contentType).build(); + } + } + + /** + * Creates a response with custom status and content type. + * + * @param The type of data + * @param status HTTP status + * @param data The response data + * @param contentType The content type + * @return JAX-RS Response with specified status and content type + */ + public static Response custom(Response.Status status, T data, String contentType) { + if (data instanceof ApiResponse) { + return Response.status(status).entity(data).type(contentType).build(); + } else { + return Response.status(status).entity(ApiResponse.success(data)).type(contentType).build(); + } + } + + /** + * Creates a response for file download. + * + * @param data The file data + * @param fileName The file name + * @param contentType The content type + * @return JAX-RS Response configured for file download + */ + public static Response download(Object data, String fileName, String contentType) { + return Response.ok(data) + .type(contentType) + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .header("Content-Transfer-Encoding", "binary") + .build(); + } + + /** + * Gets the appropriate HTTP status from an ErrorInfo object. + * + * @param error The error information + * @return The corresponding HTTP status + */ + private static Response.Status getStatusFromError(ErrorInfo error) { + int httpStatus = error.getHttpStatus(); + + switch (httpStatus) { + case 400: return Response.Status.BAD_REQUEST; + case 401: return Response.Status.UNAUTHORIZED; + case 403: return Response.Status.FORBIDDEN; + case 404: return Response.Status.NOT_FOUND; + case 409: return Response.Status.CONFLICT; + case 500: return Response.Status.INTERNAL_SERVER_ERROR; + default: return Response.Status.INTERNAL_SERVER_ERROR; + } + } + + /** + * Adds CORS headers to a response builder. + * + * @param responseBuilder The response builder + * @return The response builder with CORS headers + */ + public static Response.ResponseBuilder withCors(Response.ResponseBuilder responseBuilder) { + return responseBuilder + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token") + .header("Access-Control-Max-Age", "86400"); + } + + /** + * Creates an OPTIONS response for CORS preflight requests. + * + * @return JAX-RS Response for CORS preflight + */ + public static Response corsOptions() { + return withCors(Response.ok()).build(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/alexzaw/rest4i/util/SystemObject.java b/src/main/java/dev/alexzaw/rest4i/util/SystemObject.java new file mode 100644 index 0000000..27a5753 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/SystemObject.java @@ -0,0 +1,475 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Bin4; +import com.ibm.as400.access.AS400Bin8; +import com.ibm.as400.access.AS400UnsignedBin4; +import com.ibm.as400.access.CharConverter; +import com.ibm.as400.access.IFSFile; +import com.ibm.as400.access.ProgramParameter; +import com.ibm.as400.access.QSYSObjectPathName; +import com.ibm.as400.access.ServiceProgramCall; +import dev.alexzaw.rest4i.api.CLCommandAPIImpl; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class SystemObject { + + protected AS400 _system = null; + + protected IFSFile _ifsFile = null; + + protected String _ifsPath = null; + + protected String _ifsPathOriginal = null; + + protected QSYSObjectPathName _qsysPath = null; + + protected boolean _iASPQSYSObject = false; + + protected boolean _isQSYSPFMember = false; + + protected long _size = -1L; + + protected String _description = ""; + + protected int _ccsid = -1; + + protected boolean _getAttrCalled = false; + + protected UNIXErrno _unixErrno = null; + + public SystemObject(AS400 sys, String path) { + path = CommonUtil.normalizePath(path); + initObject(sys, new IFSFile(sys, path), path); + } + + public SystemObject(IFSFile ifsFile) { + initObject(ifsFile.getSystem(), ifsFile, ifsFile.getPath()); + } + + protected void initObject(AS400 sys, IFSFile ifsFile, String path) { + this._ifsFile = ifsFile; + this._ifsPathOriginal = path; + this._ifsPath = ifsFile.getCanonicalPath(); + this._system = sys; + if (isQSYSObject(sys, path)) { + String tempIFSPath = this._ifsPath; + if (tempIFSPath.equalsIgnoreCase("/QSYS.LIB")) + tempIFSPath = String.valueOf(tempIFSPath) + "/QSYS.LIB"; + this._qsysPath = getQSYSObjectPathName(tempIFSPath); + this._isQSYSPFMember = isQSYSPFMBR(this._qsysPath); + } + try { + int errno = doQp0lGetAttr(this._system, this, false); + if (errno == 0) + this._getAttrCalled = true; + } catch (Exception exception) {} + } + + public static boolean isQSYSObject(AS400 system, String path) { + boolean isQSYSObject = false; + path = CommonUtil.normalizePath(path); + String tempPath = path.toUpperCase(); + if (tempPath.startsWith("/QSYS.LIB/") || tempPath.equals("/QSYS.LIB")) { + isQSYSObject = true; + } else { + String mountPoint = path; + int endOfFirstPart = tempPath.indexOf("/QSYS.LIB", 1); + if (endOfFirstPart == -1) { + isQSYSObject = false; + } else { + mountPoint = path.substring(0, endOfFirstPart); + IFSFile tempIFSFile = new IFSFile(system, mountPoint); + try { + if (tempIFSFile.getASP() > 32) + isQSYSObject = true; + } catch (Exception e) { + AsyncLogger.traceDebug("initObject", e, "getASP failed. path='%s'", new Object[] { path }); + } + } + } + return isQSYSObject; + } + + public int getCCSID() throws Exception { + if (this._getAttrCalled) + return this._ccsid; + return this._ifsFile.getCCSID(); + } + + public long getSize() throws Exception { + if (this._getAttrCalled) + return this._size; + return this._ifsFile.length(); + } + + public String getPath() { + return this._ifsPath; + } + + public QSYSObjectPathName getQSYSPath() { + return this._qsysPath; + } + + public IFSFile getIFSFile() { + return this._ifsFile; + } + + public boolean delete() { + try { + return this._ifsFile.delete(); + } catch (IOException IGNORE) { + AsyncLogger.traceDebug("delete", IGNORE, "path='%s'", new Object[] { this._ifsPath }); + return false; + } + } + + public boolean exists() throws Exception { + if (this._getAttrCalled) + return true; + return this._ifsFile.exists(); + } + + public static QSYSObjectPathName getQSYSObjectPathName(String path) { + QSYSObjectPathName qsysPath = null; + if (CommonUtil.hasContent(path)) + try { + qsysPath = new QSYSObjectPathName(path); + } catch (Exception IGNORE) { + qsysPath = null; + } + return qsysPath; + } + + public boolean isFile() throws Exception { + return this._ifsFile.isFile(); + } + + public boolean isDirectory() throws Exception { + return this._ifsFile.isDirectory(); + } + + public boolean isQSYSObject() { + return (this._qsysPath != null); + } + + public boolean isQSYSPF() throws Exception { + if (!isQSYSFile()) + return false; + String st = getSubtype(); + return (st != null && st.equals("PF")); + } + + public boolean isQSYSFile() { + return (isQSYSObject() && this._qsysPath.getObjectType().equalsIgnoreCase("FILE")); + } + + public boolean isQSYSLIB() { + return (isQSYSObject() && this._qsysPath.getObjectType().equalsIgnoreCase("LIB")); + } + + public static boolean isQSYSPFMBR(QSYSObjectPathName qsysPath) { + return (qsysPath != null && qsysPath.getObjectType().equalsIgnoreCase("MBR")); + } + + public boolean isQSYSPFMBR() { + return this._isQSYSPFMember; + } + + public static QSYSObjectPathName getQSYSPhysicalFilePath(QSYSObjectPathName qsysPath) { + if (!isQSYSPFMBR(qsysPath)) + return null; + StringBuilder sb = new StringBuilder(); + if (CommonUtil.hasContent(qsysPath.getAspName())) + sb.append("/").append(qsysPath.getAspName()); + sb.append("/QSYS.LIB"); + if (!qsysPath.getLibraryName().equalsIgnoreCase("QSYS")) + sb.append("/").append(qsysPath.getLibraryName()).append(".LIB"); + sb.append("/").append(qsysPath.getObjectName()).append(".FILE"); + return new QSYSObjectPathName(sb.toString()); + } + + public String getSubtype() throws Exception { + return this._ifsFile.getSubtype(); + } + + public String getOwnerName() throws Exception { + return this._ifsFile.getOwnerName(); + } + + public long getLastModified() throws Exception { + return this._ifsFile.lastModified() / 1000L; + } + + public String getDescription() throws Exception { + if (this._getAttrCalled) { + int errno = doQp0lGetAttr(this._system, this, true); + if (errno == 0) + return this._description; + } + return null; + } + + public SystemObjectInfo getInfo(boolean fullDetails) throws Exception { + try { + if (fullDetails) + return new SystemObjectInfo(getCCSID(), getOwnerName(), + getLastModified(), this._ifsPath, getSize(), + isDirectory(), getSubtype(), -1, -1, getDescription()); + return new SystemObjectInfo(this._ifsPath, isDirectory(), getSubtype(), getDescription()); + } catch (Exception e) { + AsyncLogger.traceDebug("getSystemObjectInfo", e, "path='%s'", new Object[] { this._ifsPath }); + return new SystemObjectInfo(this._ifsPath, fullDetails); + } + } + + public static class SystemObjectInfo { + public boolean inAccessible = false; + + public boolean fullDetails = false; + + public int ccsid; + + public String owner; + + public String description; + + public long lastModified; + + public String path; + + public long size; + + public boolean isDir; + + public String subType; + + public int recordLength; + + public int numberOfRecords; + + public SystemObjectInfo(int ccsid, String owner, long lastModified, String path, long size, boolean isDir, String subType, int numberOfRecords, int recordLength, String description) { + this.fullDetails = true; + this.ccsid = ccsid; + this.owner = owner; + this.lastModified = lastModified; + this.path = path; + this.size = size; + this.isDir = isDir; + this.subType = subType; + this.numberOfRecords = numberOfRecords; + this.recordLength = recordLength; + this.description = description; + } + + public SystemObjectInfo(String path, boolean isDir, String subType, String description) { + this.path = path; + this.isDir = isDir; + this.subType = subType; + this.description = description; + } + + public SystemObjectInfo(String path, boolean fullDetails) { + this.fullDetails = fullDetails; + this.inAccessible = true; + this.path = path; + } + + public void toJSON(JSONSerializer json) { + json.startObject(); + json.add("path", this.path); + json.add("description", this.inAccessible ? null : this.description); + json.add("isDir", this.inAccessible ? null : Boolean.valueOf(this.isDir)); + json.add("subType", this.inAccessible ? null : this.subType); + if (this.fullDetails) { + json.add("owner", this.inAccessible ? null : this.owner); + json.add("ccsid", this.inAccessible ? null : Integer.valueOf(this.ccsid)); + json.add("lastModified", this.inAccessible ? null : Long.valueOf(this.lastModified)); + json.add("size", this.inAccessible ? null : Long.valueOf(this.size)); + json.add("recordLength", this.inAccessible ? null : Integer.valueOf(this.recordLength)); + json.add("numberOfRecords", this.inAccessible ? null : Integer.valueOf(this.numberOfRecords)); + } + json.endObject(); + } + } + + public List listDir(boolean includeHidden, String pattern, String subtypeFilter) throws Exception { + if (!exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { this._ifsPath }); + if (!isDirectory()) + throw new RestWABadRequestException("Object referenced by the path '%s' is not a directory.", new Object[] { this._ifsPath }); + List list = new ArrayList<>(); + List nondirlist = new ArrayList<>(); + int patternType = includeHidden ? 1 : 0; + this._ifsFile.setPatternMatching(patternType); + IFSFile[] ifsfiles = null; + if (pattern != null) { + ifsfiles = this._ifsFile.listFiles(null, pattern); + } else { + ifsfiles = this._ifsFile.listFiles(); + } + SystemObject sysObj = null; + byte b; + int i; + IFSFile[] arrayOfIFSFile1; + for (i = (arrayOfIFSFile1 = ifsfiles).length, b = 0; b < i; ) { + IFSFile f = arrayOfIFSFile1[b]; + sysObj = new SystemObject(f); + if (subtypeFilter == null || subtypeFilter.equalsIgnoreCase("*ALL") || ( + sysObj.getSubtype() != null && sysObj.getSubtype().equals(subtypeFilter))) + if (sysObj.isDirectory()) { + list.add(sysObj); + } else { + nondirlist.add(sysObj); + } + b++; + } + list = sortList(list); + list.addAll(sortList(nondirlist)); + return list; + } + + public void mkdir() throws Exception { + String command = String.format("QSYS/MKDIR DIR('%s') DTAAUT(*EXCLUDE) OBJAUT(*NONE) CRTOBJAUD(*SYSVAL)CRTOBJSCAN(*PARENT) RSTDRNMUNL(*NO)", new Object[] { this._ifsPath }); + CLCommandAPIImpl.runClCommand(this._system, command); + } + + private static Comparator _sysObjectComparator = new Comparator() { + public int compare(SystemObject o1, SystemObject o2) { + String o1Lower = o1.getName().toLowerCase(); + String o2Lower = o2.getName().toLowerCase(); + if (o1Lower.equals(o2Lower)) + return o1Lower.equals(o1.getName()) ? -1 : 1; + return o1Lower.compareTo(o2Lower); + } + }; + + private static final byte ebcdicDelim = 97; + + private static final byte nullDelim = 0; + + public static List sortList(List list) { + if (list.isEmpty()) + return list; + Collections.sort(list, _sysObjectComparator); + return list; + } + + public String getName() { + return this._ifsFile.getName(); + } + + private static final byte[] hexZeros = new byte[50]; + + private static final AS400Bin4 intConverter = new AS400Bin4(); + + private static final AS400UnsignedBin4 uintConverter = new AS400UnsignedBin4(); + + private static final AS400Bin8 longConverter = new AS400Bin8(); + + public static final int QP0L_ATTR_DATA_SIZE_64 = 14; + + public static final int QP0L_ATTR_CCSID = 27; + + public static final int QP0L_ATTR_TEXT = 48; + + private static int doQp0lGetAttr(AS400 sys, SystemObject sysObject, boolean getDescription) throws Exception { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + ServiceProgramCall spc = CommonUtil.getServiceProgramCall(sys, "/QSYS.LIB/QP0LLIB2.SRVPGM", "Qp0lGetAttr", 7, false, true); + CharConverter charConverter = new CharConverter(sys.getCcsid(), sys); + ProgramParameter[] pgmParmList = spc.getParameterList(); + byte[] pathBytes = null; + byte delim = 0; + if (sysObject.isQSYSObject() && CommonUtil.isPathProblematic(sysObject.getQSYSPath())) { + QSYSObjectPathName qsysPath = sysObject.getQSYSPath(); + String asp = qsysPath.getAspName(); + String lib = qsysPath.getLibraryName(); + String obj = qsysPath.getObjectName(); + String mbr = qsysPath.getMemberName(); + if (!asp.isEmpty()) { + bo.write(delim); + if (asp.charAt(0) == '/') { + bo.write(charConverter.stringToByteArray(asp.substring(1))); + } else { + bo.write(charConverter.stringToByteArray(asp)); + } + } + if (!lib.isEmpty()) { + bo.write(delim); + bo.write(charConverter.stringToByteArray(String.valueOf(lib) + ".LIB")); + } + if (!obj.isEmpty()) { + bo.write(delim); + bo.write(charConverter.stringToByteArray(String.valueOf(obj) + "." + qsysPath.getObjectType())); + } + if (!mbr.isEmpty()) { + bo.write(delim); + bo.write(charConverter.stringToByteArray(String.valueOf(mbr) + ".MBR")); + } + pathBytes = bo.toByteArray(); + } else { + delim = 97; + pathBytes = charConverter.stringToByteArray(sysObject.getPath()); + } + bo.reset(); + bo.write(hexZeros, 0, 16); + bo.write(intConverter.toBytes(pathBytes.length)); + bo.write(delim); + bo.write(new byte[11]); + bo.write(pathBytes); + pgmParmList[0].setInputData(bo.toByteArray()); + int numAttrs = getDescription ? 3 : 2; + bo.reset(); + bo.write(intConverter.toBytes(numAttrs)); + bo.write(intConverter.toBytes(14)); + bo.write(intConverter.toBytes(27)); + if (getDescription) + bo.write(intConverter.toBytes(48)); + pgmParmList[1].setInputData(bo.toByteArray()); + pgmParmList[2].setOutputDataLength(200); + pgmParmList[3].setParameterType(1); + pgmParmList[3].setInputData(intConverter.toBytes(200)); + pgmParmList[4].setOutputDataLength(4); + pgmParmList[5].setOutputDataLength(4); + pgmParmList[6].setParameterType(1); + pgmParmList[6].setInputData(intConverter.toBytes(1)); + spc.run(); + int errno = 0; + if (spc.getIntegerReturnValue() != 0) + errno = spc.getErrno(); + sysObject._size = -1L; + sysObject._description = ""; + sysObject._ccsid = -1; + if (errno != 0) { + AsyncLogger.traceDebug("doQp0lGetAttr", "errno='%d'", new Object[] { Integer.valueOf(errno) }); + return errno; + } + byte[] outBuf = pgmParmList[2].getOutputData(); + int offset = 0; + long nextOffset = -1L; + while (nextOffset != 0L) { + nextOffset = uintConverter.toLong(outBuf, offset + 0); + long attrID = uintConverter.toLong(outBuf, offset + 4); + long attrDataSize = uintConverter.toLong(outBuf, offset + 8); + if (attrDataSize > 0L) + if (attrID == 14L) { + sysObject._size = longConverter.toLong(outBuf, offset + 16); + } else if (attrID == 27L) { + sysObject._ccsid = intConverter.toInt(outBuf, offset + 16); + } else if (attrID == 48L) { + sysObject._description = charConverter.byteArrayToString(outBuf, offset + 16, (int)attrDataSize); + sysObject._description = sysObject._description.replace('\0', ' '); + sysObject._description = CommonUtil.trimRight(sysObject._description); + } + offset = (int)nextOffset; + } + return 0; + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/SystemObjectFactory.java b/src/main/java/dev/alexzaw/rest4i/util/SystemObjectFactory.java new file mode 100644 index 0000000..c2440cb --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/SystemObjectFactory.java @@ -0,0 +1,13 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; + +public final class SystemObjectFactory { + + public static SystemObject getSystemObject(AS400 system, String path) { + path = CommonUtil.normalizePath(path); + if (SystemObject.isQSYSObject(system, path)) + return new SystemObjectQSYS(system, path); + return new SystemObject(system, path); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/SystemObjectQSYS.java b/src/main/java/dev/alexzaw/rest4i/util/SystemObjectQSYS.java new file mode 100644 index 0000000..e1b87ec --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/SystemObjectQSYS.java @@ -0,0 +1,403 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Exception; +import com.ibm.as400.access.AS400FileRecordDescription; +import com.ibm.as400.access.ExtendedIOException; +import com.ibm.as400.access.FieldDescription; +import com.ibm.as400.access.IFSFile; +import com.ibm.as400.access.MemberDescription; +import com.ibm.as400.access.MemberList; +import com.ibm.as400.access.ObjectDescription; +import com.ibm.as400.access.ObjectDoesNotExistException; +import com.ibm.as400.access.ObjectList; +import com.ibm.as400.access.QSYSObjectPathName; +import com.ibm.as400.access.RecordFormat; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; +import java.io.File; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; + +public class SystemObjectQSYS extends SystemObject { + + private MemberDescription _mbrDesc = null; + + private ObjectDescription _objDesc = null; + + public SystemObjectQSYS(AS400 sys, String path) { + super(sys, path); + } + + public SystemObjectQSYS(AS400 system, MemberDescription mbrDesc) { + super(system, mbrDesc.getPath()); + this._mbrDesc = mbrDesc; + } + + public SystemObjectQSYS(AS400 system, ObjectDescription objDesc) { + super(system, objDesc.getPath()); + this._objDesc = objDesc; + } + + public int getCCSID() throws Exception { + int ccsid = -1; + if (this._getAttrCalled) + ccsid = this._ccsid; + return ccsid; + } + + public long getSize() throws Exception { + long size = -1L; + try { + if (this._getAttrCalled) + size = this._size; + if (size == -1L) + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + int dataSpaceSize = ((Integer)this._mbrDesc.getValue(14)).intValue(); + int dataSpaceMultiplier = ((Integer)this._mbrDesc.getValue(24)).intValue(); + size = (dataSpaceMultiplier * dataSpaceSize); + } else { + ensureObjectDescriptionSet(this._qsysPath); + size = ((Long)this._objDesc.getValue(701)).longValue(); + } + } catch (Exception e) { + AsyncLogger.traceDebug("getSize", e, "path='%s'", new Object[] { this._ifsPath }); + } + return size; + } + + public boolean delete() { + return super.delete(); + } + + public boolean exists() throws Exception { + if (this._ifsPath.equalsIgnoreCase("/QSYS.LIB")) + return true; + if (this._ifsPath.equalsIgnoreCase("/QSYS.LIB/QSYS.LIB")) + return false; + if (this._getAttrCalled) + return true; + if (this._qsysPath == null) + return false; + try { + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + this._mbrDesc.getValue(37); + return true; + } + ensureObjectDescriptionSet(this._qsysPath); + return this._objDesc.exists(); + } catch (ObjectDoesNotExistException e) { + return false; + } catch (ExtendedIOException e) { + int rc = e.getReturnCode(); + AsyncLogger.traceDebug("exists() - qsys", "%s [path=%s] [rc=%d]", new Object[] { e.getMessage(), this._ifsPath, Integer.valueOf(rc) }); + if (rc == 2 || rc == 3) + return false; + } catch (AS400Exception e) { + AsyncLogger.traceDebug("getSystemObjectInfo", (Throwable)e, "path='%s'", new Object[] { this._ifsPath }); + String errorMsg = e.getMessage(); + if (errorMsg != null) + if (errorMsg.indexOf("CPD3C31") != -1 || + errorMsg.indexOf("CPF954F") != -1 || + errorMsg.indexOf("CPF9551") != -1 || + errorMsg.indexOf("CPF959E") != -1 || + errorMsg.indexOf("CPF98A1") != -1 || + errorMsg.indexOf("CPF980F") != -1 || + errorMsg.indexOf("CPF9801") != -1 || + errorMsg.indexOf("CPF9810") != -1 || + errorMsg.indexOf("CPF9811") != -1 || + errorMsg.indexOf("CPF9812") != -1 || + errorMsg.indexOf("CPF9814") != -1 || + errorMsg.indexOf("CPF9815") != -1) + return false; + } + return true; + } + + public boolean isFile() throws Exception { + return (exists() && isQSYSPFMBR()); + } + + public boolean isDirectory() throws Exception { + return (exists() && (isQSYSPF() || isQSYSLIB())); + } + + public int getRecordLength() throws Exception { + int recordLength = -1; + if (!isQSYSFile() && !isQSYSPFMBR()) + return -1; + if (isQSYSFile()) { + String st = getSubtype(); + if (st == null || ( + !st.equalsIgnoreCase("PF") && !st.equalsIgnoreCase("PF-SRC") && !st.equalsIgnoreCase("PF-DTA"))) + return -1; + } + AS400FileRecordDescription af = new AS400FileRecordDescription(this._system, this._ifsPath); + RecordFormat[] rf = af.retrieveRecordFormat(); + FieldDescription[] fd = rf[0].getFieldDescriptions(); + recordLength = 0; + for (int i = 0; i < fd.length; i++) + recordLength += fd[i].getLength(); + return recordLength; + } + + private int getNumberOfRecords() { + int numberOfRecords = -1; + try { + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + numberOfRecords = ((Integer)this._mbrDesc.getValue(13)).intValue(); + } + } catch (Exception e) { + AsyncLogger.traceDebug("getNumberOfRecords", e, "path='%s'", new Object[] { this._ifsPath }); + numberOfRecords = -1; + } + return numberOfRecords; + } + + public String getSubtype() throws Exception { + String subtype = null; + try { + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + subtype = (String)this._mbrDesc.getValue(5); + } else { + ensureObjectDescriptionSet(this._qsysPath); + subtype = this._objDesc.getValueAsString(202); + } + } catch (Exception e) { + AsyncLogger.traceDebug("getSubtype", e, "path='%s'", new Object[] { this._ifsPath }); + subtype = null; + } + return subtype; + } + + public String getOwnerName() throws Exception { + String owner = null; + try { + ensureObjectDescriptionSet(this._qsysPath); + owner = this._objDesc.getValueAsString(302); + } catch (Exception e) { + AsyncLogger.traceDebug("getSubtype", e, "path='%s'", new Object[] { this._ifsPath }); + owner = null; + } + return owner; + } + + public long getLastModified() throws Exception { + long lastModified = 0L; + Date date = null; + try { + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + date = (Date)this._mbrDesc.getValue(17); + if (date == null) + date = (Date)this._mbrDesc.getValue(6); + } else { + ensureObjectDescriptionSet(this._qsysPath); + date = (Date)this._objDesc.getValue(305); + if (date == null) + date = (Date)this._objDesc.getValue(304); + } + if (date != null) + lastModified = date.getTime() / 1000L; + } catch (Exception e) { + AsyncLogger.traceDebug("getLastModified", e, "path='%s'", new Object[] { this._ifsPath }); + lastModified = 0L; + } + return lastModified; + } + + public String getDescription() throws Exception { + String descr = null; + try { + descr = super.getDescription(); + if (descr != null) + return descr; + if (isQSYSPFMBR()) { + ensureMemberDescriptionSet(this._qsysPath); + descr = (String)this._mbrDesc.getValue(8); + } else { + ensureObjectDescriptionSet(this._qsysPath); + descr = this._objDesc.getValueAsString(203); + } + } catch (Exception e) { + AsyncLogger.traceDebug("getDescription", e, "path='%s'", new Object[] { this._ifsPath }); + descr = null; + } + return descr; + } + + public String getQSYSPFSubtype() { + String subtype = "PF"; + try { + subtype = (new IFSFile(this._system, this._ifsPath)).isSourcePhysicalFile() ? (String.valueOf(subtype) + "-SRC") : (String.valueOf(subtype) + "-DTA"); + } catch (Exception e) { + AsyncLogger.traceDebug("getQSYSPFSubtype", e, "path='%s'", new Object[] { this._ifsPath }); + subtype = "PF"; + } + return subtype; + } + + public String getName() { + if (this._isQSYSPFMember) + return this._qsysPath.getMemberName(); + if (CommonUtil.hasContent(this._qsysPath.getObjectName())) + return String.valueOf(this._qsysPath.getObjectName()) + "." + this._qsysPath.getObjectType(); + return String.valueOf(this._qsysPath.getLibraryName()) + ".LIB"; + } + + public List listDir(boolean includeHidden, String pattern, String subtypeFilter) throws Exception { + if (!exists()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { this._ifsPath }); + if (!isDirectory()) + throw new RestWABadRequestException("Object referenced by the path '%s' is not a directory.", new Object[] { this._ifsPath }); + List list = new ArrayList<>(); + SystemObjectQSYS qsysObj = null; + if (isQSYSPF()) { + MemberList memberList = new MemberList(this._system, this._qsysPath); + memberList.addAttribute(8); + memberList.addAttribute(5); + memberList.load(); + MemberDescription[] mbrDescList = memberList.getMemberDescriptions(); + if (pattern != null) { + if (pattern.toUpperCase().endsWith(".MBR")) { + pattern = pattern.substring(0, pattern.length() - 4); + } else if (pattern.endsWith(".*")) { + pattern = pattern.substring(0, pattern.length() - 2); + } + if (pattern.equals("*") || pattern.isEmpty()) + pattern = null; + } + Pattern p = null; + if (pattern != null) { + String regExpr = ""; + if (pattern.endsWith("*")) { + pattern = pattern.substring(0, pattern.length() - 1); + regExpr = "(.*)"; + } + p = Pattern.compile(String.valueOf(Pattern.quote(pattern)) + regExpr, 2); + } + byte b; + int i; + MemberDescription[] arrayOfMemberDescription1; + for (i = (arrayOfMemberDescription1 = mbrDescList).length, b = 0; b < i; ) { + MemberDescription mbrDesc = arrayOfMemberDescription1[b]; + String fileName = (new File(mbrDesc.getPath())).getName(); + if (p == null || p.matcher(fileName).matches()) { + qsysObj = new SystemObjectQSYS(this._system, mbrDesc); + if (subtypeFilter == null || subtypeFilter.equalsIgnoreCase("*ALL") || ( + qsysObj.getSubtype() != null && qsysObj.getSubtype().equalsIgnoreCase(subtypeFilter))) + list.add(qsysObj); + } + b++; + } + list = sortList(list); + } else { + List nondirlist = new ArrayList<>(); + ObjectList objList = null; + String lib = this._qsysPath.getLibraryName(); + if (this._qsysPath.getObjectType().equals("LIB") && this._qsysPath.getLibraryName().equalsIgnoreCase("QSYS")) + lib = this._qsysPath.getObjectName(); + String suffix = null; + if (pattern != null) { + int idx = pattern.indexOf('.'); + if (idx != -1) { + suffix = "*" + pattern.substring(idx + 1); + pattern = pattern.substring(0, idx); + } + } + if (pattern == null || pattern.equals("*")) + pattern = "*ALL"; + if (subtypeFilter == null) + subtypeFilter = (suffix == null) ? "*ALL" : suffix; + objList = new ObjectList(this._system, lib, pattern, subtypeFilter); + ObjectDescription[] objects = objList.getObjects(-1, 0); + SystemObject sysObj = null; + byte b; + int i; + ObjectDescription[] arrayOfObjectDescription1; + for (i = (arrayOfObjectDescription1 = objects).length, b = 0; b < i; ) { + ObjectDescription objDesc = arrayOfObjectDescription1[b]; + sysObj = new SystemObjectQSYS(this._system, objDesc); + if (sysObj.isDirectory()) { + list.add(sysObj); + } else { + nondirlist.add(sysObj); + } + b++; + } + list = sortList(list); + list.addAll(sortList(nondirlist)); + } + return list; + } + + public SystemObject.SystemObjectInfo getInfo(boolean fullDetails) throws Exception { + try { + if (fullDetails || !isQSYSPFMBR()) { + QSYSObjectPathName objdescPath = isQSYSPFMBR() ? getQSYSPhysicalFilePath(this._qsysPath) : this._qsysPath; + ensureObjectDescriptionSet(objdescPath); + } + if (isQSYSPFMBR()) + ensureMemberDescriptionSet(this._qsysPath); + String subtype = getSubtype(); + if (subtype == null) + return new SystemObject.SystemObjectInfo(this._ifsPath, fullDetails); + if (!isQSYSPFMBR() && subtype.equalsIgnoreCase("PF")) + subtype = getQSYSPFSubtype(); + if (!fullDetails) + return new SystemObject.SystemObjectInfo(this._ifsPath, isDirectory(), subtype, getDescription()); + int numberOfRecords = -1; + if (isQSYSPFMBR()) + numberOfRecords = getNumberOfRecords(); + return new SystemObject.SystemObjectInfo(getCCSID(), getOwnerName(), + getLastModified(), this._ifsPath, getSize(), isDirectory(), + subtype, numberOfRecords, getRecordLength(), getDescription()); + } catch (ObjectDoesNotExistException e) { + return null; + } catch (ExtendedIOException e) { + AsyncLogger.traceDebug("getSystemObjectInfo", (Throwable)e, "path='%s'", new Object[] { this._ifsPath }); + int rc = e.getReturnCode(); + AsyncLogger.traceDebug("getSystemObjectInfo", "%s [path=%s] [rc=%d]", new Object[] { e.getMessage(), this._ifsPath, Integer.valueOf(rc) }); + if (rc == 2 || rc == 3) + return null; + return new SystemObject.SystemObjectInfo(this._ifsPath, fullDetails); + } catch (AS400Exception e) { + AsyncLogger.traceDebug("getSystemObjectInfo", (Throwable)e, "path='%s'", new Object[] { this._ifsPath }); + String errorMsg = e.getMessage(); + if (errorMsg != null) + if (errorMsg.indexOf("CPF954F") != -1 || + errorMsg.indexOf("CPF9551") != -1 || + errorMsg.indexOf("CPF959E") != -1 || + errorMsg.indexOf("CPF98A1") != -1 || + errorMsg.indexOf("CPF980F") != -1 || + errorMsg.indexOf("CPF9801") != -1 || + errorMsg.indexOf("CPF9810") != -1 || + errorMsg.indexOf("CPF9811") != -1 || + errorMsg.indexOf("CPF9812") != -1 || + errorMsg.indexOf("CPF9814") != -1 || + errorMsg.indexOf("CPF9815") != -1) + return null; + return new SystemObject.SystemObjectInfo(this._ifsPath, fullDetails); + } + } + + private void ensureMemberDescriptionSet(QSYSObjectPathName qsysPath) throws Exception { + if (this._mbrDesc == null) { + this._mbrDesc = new MemberDescription(this._system, qsysPath); + this._mbrDesc.refresh(); + } + } + + private void ensureObjectDescriptionSet(QSYSObjectPathName qsysPath) throws Exception { + if (this._objDesc == null) { + this._objDesc = new ObjectDescription(this._system, qsysPath); + this._objDesc.refresh(); + } + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/UNIXErrno.java b/src/main/java/dev/alexzaw/rest4i/util/UNIXErrno.java new file mode 100644 index 0000000..7186f4c --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/UNIXErrno.java @@ -0,0 +1,78 @@ +package dev.alexzaw.rest4i.util; + +import com.ibm.as400.access.AS400; +import com.ibm.as400.access.AS400Message; +import com.ibm.as400.access.MessageFile; +import dev.alexzaw.rest4i.exception.RestWABadRequestException; +import dev.alexzaw.rest4i.exception.RestWAForbiddenException; +import dev.alexzaw.rest4i.exception.RestWAInternalServerErrorException; +import dev.alexzaw.rest4i.exception.RestWANotFoundException; + +public class UNIXErrno { + + public static final int EINVAL = 3021; + + public static final int ENOENT = 3025; + + public static final int EPERM = 3027; + + public static final int EBUSY = 3029; + + public static final int EACCES = 3401; + + public static final int ENOTDIR = 3403; + + public static final int EOPNOTSUPP = 3440; + + public static final int ENOENT1 = 3465; + + public static final int EISDIR = 3471; + + public static final int ENOTEMPTY = 3488; + + public static final int EBADDIR = 3494; + + public static final int EROOBJ = 3500; + + public static final int ENOTAVAIL = 3535; + + int errno_; + + public UNIXErrno(int errno) { + this.errno_ = errno; + } + + private String getMessage(AS400 system) { + String theMessage = ""; + try { + MessageFile messageFile = new MessageFile(system); + messageFile.setPath("%LIBL%/QCPFMSG.MSGF"); + AS400Message as400msg = messageFile.getMessage("CPE" + this.errno_); + theMessage = as400msg.getText(); + } catch (Exception exception) {} + return String.valueOf(theMessage) + "(CPE" + this.errno_ + ")"; + } + + public boolean isNotFoundError() { + return !(this.errno_ != 3025 && this.errno_ != 3535 && this.errno_ != 3465 && this.errno_ != 3494); + } + + public boolean isPermissionError() { + return !(this.errno_ != 3027 && this.errno_ != 3401); + } + + public boolean isInvalidError() { + return !(this.errno_ != 3021 && this.errno_ != 3403 && this.errno_ != 3440 && this.errno_ != 3471 && this.errno_ != 3488 && this.errno_ != 3500); + } + + public void generateException(AS400 system, String path) { + String message = getMessage(system); + if (isInvalidError()) + throw new RestWABadRequestException("Operation on object referenced by path '%s' failed. %s", new Object[] { path, message }); + if (isNotFoundError()) + throw new RestWANotFoundException("Object referenced by the path '%s' is not found or inaccessible.", new Object[] { path }); + if (isPermissionError()) + throw new RestWAForbiddenException("Operation on object referenced by path '%s' failed. %s", new Object[] { path, message }); + throw new RestWAInternalServerErrorException("Operation on object referenced by path '%s' failed. %s", new Object[] { path, message }); + } +} diff --git a/src/main/java/dev/alexzaw/rest4i/util/ValidationUtils.java b/src/main/java/dev/alexzaw/rest4i/util/ValidationUtils.java new file mode 100644 index 0000000..a6c7469 --- /dev/null +++ b/src/main/java/dev/alexzaw/rest4i/util/ValidationUtils.java @@ -0,0 +1,497 @@ +package dev.alexzaw.rest4i.util; + +import dev.alexzaw.rest4i.model.ErrorInfo.ValidationError; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Validation utilities for input validation across REST4i integration services. + * + * Provides comprehensive validation methods for common data types, formats, + * and business rules while maintaining compatibility with IBM i constraints. + * + * @author alexzaw + */ +public class ValidationUtils { + + // Common regex patterns + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + ); + + private static final Pattern PHONE_PATTERN = Pattern.compile( + "^[\\+]?[1-9]?[0-9]{7,15}$" + ); + + private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile( + "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" + ); + + private static final Pattern HOSTNAME_PATTERN = Pattern.compile( + "^(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$" + ); + + // IBM i specific patterns + private static final Pattern IBMI_OBJECT_NAME_PATTERN = Pattern.compile( + "^[A-Za-z][A-Za-z0-9_@#$]{0,9}$" + ); + + private static final Pattern IBMI_LIBRARY_NAME_PATTERN = Pattern.compile( + "^[A-Za-z][A-Za-z0-9_@#$]{0,9}$" + ); + + private static final Pattern IBMI_USER_ID_PATTERN = Pattern.compile( + "^[A-Za-z][A-Za-z0-9_]{0,9}$" + ); + + // SQL validation patterns + private static final Pattern[] DANGEROUS_SQL_PATTERNS = { + Pattern.compile("(?i)\\b(DROP|DELETE|TRUNCATE|ALTER|CREATE)\\s+", Pattern.CASE_INSENSITIVE), + Pattern.compile("(?i)\\b(EXEC|EXECUTE|SP_|XP_)\\s*\\(", Pattern.CASE_INSENSITIVE), + Pattern.compile("(?i)\\b(UNION|INFORMATION_SCHEMA|SYSOBJECTS|SYSCOLUMNS)\\b", Pattern.CASE_INSENSITIVE), + Pattern.compile("(?i)(--|\\/\\*|\\*\\/|;\\s*$)", Pattern.CASE_INSENSITIVE) + }; + + /** + * Validation result container. + */ + public static class ValidationResult { + private boolean valid; + private List errors; + + public ValidationResult() { + this.valid = true; + this.errors = new ArrayList<>(); + } + + public ValidationResult(boolean valid) { + this(); + this.valid = valid; + } + + public void addError(String field, String message) { + this.valid = false; + this.errors.add(new ValidationError(field, message)); + } + + public void addError(String field, String message, Object rejectedValue) { + this.valid = false; + this.errors.add(new ValidationError(field, message, rejectedValue)); + } + + public void addError(ValidationError error) { + this.valid = false; + this.errors.add(error); + } + + public boolean isValid() { + return valid && errors.isEmpty(); + } + + public List getErrors() { + return errors; + } + + public String getFirstErrorMessage() { + return errors.isEmpty() ? null : errors.get(0).getMessage(); + } + } + + /** + * Validates that a string is not null or empty. + * + * @param value The string to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateRequired(String value, String fieldName) { + ValidationResult result = new ValidationResult(); + if (value == null || value.trim().isEmpty()) { + result.addError(fieldName, fieldName + " is required"); + } + return result; + } + + /** + * Validates that an object is not null. + * + * @param value The object to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateRequired(Object value, String fieldName) { + ValidationResult result = new ValidationResult(); + if (value == null) { + result.addError(fieldName, fieldName + " is required"); + } + return result; + } + + /** + * Validates string length constraints. + * + * @param value The string to validate + * @param fieldName The field name for error messages + * @param minLength Minimum length (inclusive) + * @param maxLength Maximum length (inclusive) + * @return ValidationResult + */ + public static ValidationResult validateLength(String value, String fieldName, int minLength, int maxLength) { + ValidationResult result = new ValidationResult(); + + if (value != null) { + int length = value.length(); + if (length < minLength) { + result.addError(fieldName, String.format("%s must be at least %d characters long", fieldName, minLength), value); + } else if (length > maxLength) { + result.addError(fieldName, String.format("%s must not exceed %d characters", fieldName, maxLength), value); + } + } + + return result; + } + + /** + * Validates numeric range constraints. + * + * @param value The number to validate + * @param fieldName The field name for error messages + * @param min Minimum value (inclusive) + * @param max Maximum value (inclusive) + * @return ValidationResult + */ + public static ValidationResult validateRange(Number value, String fieldName, Number min, Number max) { + ValidationResult result = new ValidationResult(); + + if (value != null) { + double doubleValue = value.doubleValue(); + double minValue = min.doubleValue(); + double maxValue = max.doubleValue(); + + if (doubleValue < minValue) { + result.addError(fieldName, String.format("%s must be at least %s", fieldName, min), value); + } else if (doubleValue > maxValue) { + result.addError(fieldName, String.format("%s must not exceed %s", fieldName, max), value); + } + } + + return result; + } + + /** + * Validates email format. + * + * @param email The email to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateEmail(String email, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (email != null && !email.trim().isEmpty()) { + if (!EMAIL_PATTERN.matcher(email.trim()).matches()) { + result.addError(fieldName, fieldName + " is not a valid email address", email); + } + } + + return result; + } + + /** + * Validates phone number format. + * + * @param phone The phone number to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validatePhone(String phone, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (phone != null && !phone.trim().isEmpty()) { + String cleanPhone = phone.replaceAll("[\\s\\-\\(\\)]", ""); + if (!PHONE_PATTERN.matcher(cleanPhone).matches()) { + result.addError(fieldName, fieldName + " is not a valid phone number", phone); + } + } + + return result; + } + + /** + * Validates IP address format. + * + * @param ipAddress The IP address to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateIpAddress(String ipAddress, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (ipAddress != null && !ipAddress.trim().isEmpty()) { + if (!IP_ADDRESS_PATTERN.matcher(ipAddress.trim()).matches()) { + result.addError(fieldName, fieldName + " is not a valid IP address", ipAddress); + } + } + + return result; + } + + /** + * Validates hostname format. + * + * @param hostname The hostname to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateHostname(String hostname, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (hostname != null && !hostname.trim().isEmpty()) { + String trimmedHostname = hostname.trim(); + if (!HOSTNAME_PATTERN.matcher(trimmedHostname).matches() && + !IP_ADDRESS_PATTERN.matcher(trimmedHostname).matches() && + !trimmedHostname.equalsIgnoreCase("localhost")) { + result.addError(fieldName, fieldName + " is not a valid hostname or IP address", hostname); + } + } + + return result; + } + + /** + * Validates IBM i object name format. + * + * @param objectName The object name to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateIBMiObjectName(String objectName, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (objectName != null && !objectName.trim().isEmpty()) { + if (!IBMI_OBJECT_NAME_PATTERN.matcher(objectName.trim().toUpperCase()).matches()) { + result.addError(fieldName, + fieldName + " must be a valid IBM i object name (1-10 characters, starting with letter)", + objectName); + } + } + + return result; + } + + /** + * Validates IBM i library name format. + * + * @param libraryName The library name to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateIBMiLibraryName(String libraryName, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (libraryName != null && !libraryName.trim().isEmpty()) { + String trimmedName = libraryName.trim().toUpperCase(); + if (!trimmedName.equals("*LIBL") && !trimmedName.equals("*CURLIB") && + !IBMI_LIBRARY_NAME_PATTERN.matcher(trimmedName).matches()) { + result.addError(fieldName, + fieldName + " must be a valid IBM i library name (*LIBL, *CURLIB, or 1-10 characters starting with letter)", + libraryName); + } + } + + return result; + } + + /** + * Validates IBM i user ID format. + * + * @param userId The user ID to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateIBMiUserId(String userId, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (userId != null && !userId.trim().isEmpty()) { + if (!IBMI_USER_ID_PATTERN.matcher(userId.trim().toUpperCase()).matches()) { + result.addError(fieldName, + fieldName + " must be a valid IBM i user ID (1-10 characters, starting with letter)", + userId); + } + } + + return result; + } + + /** + * Validates SQL query for potential security issues. + * + * @param sqlQuery The SQL query to validate + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateSqlQuery(String sqlQuery, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (sqlQuery != null && !sqlQuery.trim().isEmpty()) { + String upperQuery = sqlQuery.toUpperCase().trim(); + + // Check for dangerous SQL patterns + for (Pattern pattern : DANGEROUS_SQL_PATTERNS) { + if (pattern.matcher(upperQuery).find()) { + result.addError(fieldName, + fieldName + " contains potentially dangerous SQL statements", + sqlQuery); + break; + } + } + + // Check for basic SELECT requirement + if (!upperQuery.startsWith("SELECT") && !upperQuery.startsWith("WITH")) { + result.addError(fieldName, + fieldName + " must be a SELECT query", + sqlQuery); + } + } + + return result; + } + + /** + * Validates regex pattern. + * + * @param value The value to validate against pattern + * @param pattern The regex pattern + * @param fieldName The field name for error messages + * @param errorMessage Custom error message + * @return ValidationResult + */ + public static ValidationResult validatePattern(String value, Pattern pattern, String fieldName, String errorMessage) { + ValidationResult result = new ValidationResult(); + + if (value != null && !value.trim().isEmpty()) { + if (!pattern.matcher(value.trim()).matches()) { + result.addError(fieldName, errorMessage, value); + } + } + + return result; + } + + /** + * Validates regex pattern with default error message. + * + * @param value The value to validate against pattern + * @param pattern The regex pattern + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validatePattern(String value, Pattern pattern, String fieldName) { + return validatePattern(value, pattern, fieldName, fieldName + " format is invalid"); + } + + /** + * Validates that a value is one of the allowed values. + * + * @param value The value to validate + * @param allowedValues Array of allowed values + * @param fieldName The field name for error messages + * @return ValidationResult + */ + public static ValidationResult validateAllowedValues(String value, String[] allowedValues, String fieldName) { + ValidationResult result = new ValidationResult(); + + if (value != null && !value.trim().isEmpty()) { + boolean found = false; + for (String allowed : allowedValues) { + if (value.trim().equalsIgnoreCase(allowed)) { + found = true; + break; + } + } + + if (!found) { + result.addError(fieldName, + String.format("%s must be one of: %s", fieldName, String.join(", ", allowedValues)), + value); + } + } + + return result; + } + + /** + * Combines multiple validation results into one. + * + * @param results Variable number of validation results to combine + * @return Combined validation result + */ + public static ValidationResult combine(ValidationResult... results) { + ValidationResult combined = new ValidationResult(); + + for (ValidationResult result : results) { + if (!result.isValid()) { + for (ValidationError error : result.getErrors()) { + combined.addError(error); + } + } + } + + return combined; + } + + /** + * Creates a validation error for a specific field. + * + * @param field The field name + * @param message The error message + * @return ValidationError instance + */ + public static ValidationError createError(String field, String message) { + return new ValidationError(field, message); + } + + /** + * Creates a validation error for a specific field with rejected value. + * + * @param field The field name + * @param message The error message + * @param rejectedValue The value that was rejected + * @return ValidationError instance + */ + public static ValidationError createError(String field, String message, Object rejectedValue) { + return new ValidationError(field, message, rejectedValue); + } + + /** + * Checks if a string has content (not null and not empty after trimming). + * + * @param value The string to check + * @return true if the string has content + */ + public static boolean hasContent(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** + * Safely trims a string, returning null if the input is null. + * + * @param value The string to trim + * @return Trimmed string or null + */ + public static String safeTrim(String value) { + return value != null ? value.trim() : null; + } + + /** + * Normalizes a string for validation (trim and convert to uppercase). + * + * @param value The string to normalize + * @return Normalized string or null + */ + public static String normalize(String value) { + return value != null ? value.trim().toUpperCase() : null; + } +} \ No newline at end of file diff --git a/src/main/java/src-main-java-rest4i.zip b/src/main/java/src-main-java-rest4i.zip new file mode 100644 index 0000000000000000000000000000000000000000..898834151996cb408df5470e69e52e5f9650ace2 GIT binary patch literal 157997 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

tB&dj(1~~CZ+jE;R+G% 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 + + IBMiRSEAPI + + index.html + + \ No newline at end of file diff --git a/src/main/webapp/assets/.DS_Store b/src/main/webapp/assets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9ba2e4cb2c66f8d7da4c17d21b45f173673e9ee0 GIT binary patch literal 6148 zcmeHK%T5A85Uf^I5-%J*&M)`}8v6*#b zVVeeE^RM+iFat1UI^xHdarnOHC@zY*BwC!YLXXe&cC~r#N7d&E*WTb2Px!_=j`aP; z1y_3#+dMYuYtkRoyy4|^;(e|RgtJF08`%>Ax#NDMjokQ4Pszcg~zS_Xfd$U|3~-iubT7?w}kG^;t5F zak2E#0m(jN-3DWKK`o@Un~taH&VID@akv*dV;cKAnA@~MI)1?X=)z3OeOa<7vZ3Po7Zj5YBQr?g1$--FrmZ|Uo?!g);fLL`@zQRcO zo#(G)ynDrG*Z{HgZ46@>)7UD6Q}6}egX?rQ_};vN_<5*itu2gfhhZ=a_JeEM7{&@A zt>JVuzymP1kHGcw98?JPeK48p>M^vzL(rGyW+;YiWWJl=Eoi?G%!g|^)4Vrx&39{V zG{)yW06u?zrN32BpKvgTy7zV&+*^!M0kPImah{WFDZcinWf2Z$Kyq(%J-fef^3AE1 z;@$OQ{#_Hs^M0Ib`#PW+a-|zMJ`L{AFTpjw9-QY1a2)PJu5%qM&SDKFFR_KDWPzbIA-{($f1J`C=`3&SUkk3G$8SsA+Kgc2chyRoKZ9f9;-=6b4 zFS@5@EB9X0mn8XJPWo9+!~NNFP`pQDt8>(wO6b(y9IS!0FbV9FY_xC{5hobs2%4}txI$#g%0@qL#api \ No newline at end of file diff --git a/src/main/webapp/assets/icons/pdf--reference.svg b/src/main/webapp/assets/icons/pdf--reference.svg new file mode 100644 index 0000000..97d3582 --- /dev/null +++ b/src/main/webapp/assets/icons/pdf--reference.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/main/webapp/assets/images/header_banner_1200.png b/src/main/webapp/assets/images/header_banner_1200.png new file mode 100644 index 0000000000000000000000000000000000000000..2073e2b916f12609cfe9037697dacf6648f83f43 GIT binary patch literal 13755 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/assets/images/header_banner_2560.png b/src/main/webapp/assets/images/header_banner_2560.png new file mode 100644 index 0000000000000000000000000000000000000000..ad87d44016ad3bf2292b1d3d6932616821b79d24 GIT binary patch literal 18625 zcma%@c_38p-}gs~lqFk|n6F4Fkv-8&k+PuVIKjVc1GB$MP42o~72d>N)Uo60A zut%-0z$I=Ouv1ZcJ**Auinkmbf|-(y;<^Sk_%7VdiXSj#D&{-Ie6l~WdE$qsc+zr* z(x9|~pBHOzkhzBk013Kz-^1g6nti%kX}Z4v-VfNs5DQlxkH`O`9=ki&&~6yv7B##% zx`Ip?KGH?7LhNxv8yL44W0nr;uzZ@ z)BsqWMRX=}DuSf|v(}UXp6e#uMo<=BMmUCNKaU=_{#203r#0awkhUlLF%EHbMW;F* zD$@0~(*cn-{AON6P+Pmcf~rs+uaxps;)&Bq3_9ncxUqRe*v1n>R9(?S;N~g<0DDnu zoEZ^&0zMS$Zh5&gh@vzJ7-D3R&4O5dd6oBs{WQtzF_{let+jcztrzqu33<9V}4&9ar{b*g132xxKyUC~B|2q+i~N%x{Q#DCa~Cox-r3LU}b%&R9&bHe{!AU87|i=5@|Di{_ezdcZ#!75Oz=1R?p~zEp9RB_5&;y*@EGd*g7$8C zL&-RiGv;oK?2X4^n@Z8ce1by~6Uf`SmLCVhhYx{RO`1dlZMiH=0G5^>iD}E!n2)zU zdR2wegW3x~hd}2+>!nQDdOXrOphBY!B?|Cly0|rI`qzf#n0zgep11l1~ zAk*Z=0E$fa3jM)PJaEN|IVxT-3_+!kct_x%>FR`NXH>g(6O|t1MJ9Zr3nWtxNJ^5g zK0P%Cc%i6lb%-&kLds~>23=rX+EdKn#-~J9Fg>VF;t(jbaa=f6m=Z)6SSB{t8o^V} zcgwEu?mg}vKe-jzw8`EIfs|pfbS2B}6a*V`Qpom!q{CL(tztE{IzWG3>L{itbhi-? zYFdZ?@Bx`tBhwgx(L&TK*Ktsji`gtngVvM8n|DFePiY+s$pJM@>e2?5_BTSCF7OEG zHG82q$keO~b}XrppN%dsLr21MJ@{KiPSzujdQ%MrGF$|6-oVi{dEt5Y=7}$iaGMz8 zIinN1Z~9^_`s|;KdWbzeB&H=2X<$#5J<4H>GNa_Fk1x>vn9ed|gAp)b>`iF?EVf z3v(y5>>Hi%oC#$99pkaxZ;I>HSg$3L|LQimcV+Sp(B8WeHimn;*j`DsNI-%U0jJ^i zv$RKNf|~A1=!4$U=8!hQMY*6RX9r!XNgXVJLa?cihF8rzQ+mK1+5RS<4V1-3I@*3@ z@lif*-F+iFZtzh)PX@XwyMD;D>{)H$Mx|A9)0Y0;r#Qr8pu@!%S_NaOINwxe!d6 zP2;E=)&>J;fU?An9CCXP9s4F*FV9GBnXKBI(w9 zHLLkO8JYy^OalBQW~%1!xfvWy>QoYQWt{|F{WulhJdCRbEHPcqixeI|6GR=Ay86x; z_~Z;NrtVB2yuU$#(jwp+4u$(J?@F%OY>&n-N7DsTEO2N97UqNuAp0Z8n`{Q+2_-oB z3!mQNUTYiADZAW>kxaX1Kz@$Ctus@GCN&4DK7CY?ag*7~Q1=4p&V0*}68NIyoe?y8 zjzq%w>6YRHQTVf;un91k$Bz%b?XZ$_CF^WafPk0A2z{lXf!|}B06#dK!G|Da;fo{* zD2grRrBpFJP5o7z19Yn>2ORjGC!FAEgPX&j$@(x)YcgfZp3Qd%(RPv&#m!wANT`lC15RX=E#jSB`=DouaHEPgxB1zOE?fjEQ6$pY5DlprpK|AW}cEflkR& z=$Vvmj>ADM_n|=TY&#~V9j$`#Z?nEMilA-5A{4()Q_nCuN_yI8i-2$-D?mVZx@b*J z0_u9M$({mL&oO7JU_P-_;1t;cM{!h~gb%rnrsj<(;DjQ*9~xAR!8#9*1FzsVpi{{} zS0lB3lU}iRt;m@$PFLvWQ^9G71_Ce^@RnES$V(s23|N;dupddUHF( zpz+u`j}!U=c{BS4qU7K@QK#DAFv2CB?$U%#*iE$yAeYw}N6Rxfd`fZL??FvLGmw#-rWL3q2Z!>E}8mNg9zd_FC7e4Mkj4Hu8%yo=mb; za~w@PjR&#U=%*Q_Kv6wS`5a9`XH6#viUZV_j^Hp8466f3r3m~3ae_p(e@qsTl7Xur zG44eu>JjcnPYt@jxpN?wi4|^8bw3*X4s`9e=t%aU1!9X&!8zzbpE46{WUUo1D~+-q zmuy7kU>aSnq_w$4YL~r$*Q!3e!<{}@cGiDm{U9)oc{X}YU`4h4nWQRR<%Q*1-z{S1 z^Hr1T9);+QqdgYXQvB<;i!o1O0saEayM}pYSHARU3U@etIJh2?!_iciP3uaE1_FRl? zyGqBJE)VrABHHQRDk7;RU5KUc^yK!Xew+#>;O1>$cbD?S`O=BEQkw_tcgeX=vGz;b zD&vbyD`6yw&`6U2Mw0{EUlLsIF-@m68l4IB(mWG*|A6-@SETPYH#Fe$L1jp5vSYi0 zQkwmf@%}3*l@u)TTK2l1{&p24`=b4ioI@*#XLArlhbJVPk~F zPcMqrTP=$1;c!9MYdzzc`0>^HEgTW;!!(U|e;UMdS+Nm>T5U7A_9gx01ob8BTSeK4 z0Bkk=PB;$p2OYPuZkU@Aa4d{*Ee{Q-387J}@a3?78oXI~6}=}2p5qd8{N29S72upm zYloqg#O|Wx+a;A&8%ylK)|zp;Tbr+Rt5aVhhh!Irlv@BLfj#^P>Xa>(d^^{HJYpRN zKTrqIJ)q|$NK}84pi6z}TXS`nqiJpd=ne7Q2$u*PhQMc}J6^s{e6oVT(W|k-DcEYt zD4d~bQUE34OQyjgo$j2p8b5&`?MuwH$aS6|)Zv%|olHrL(jeA+Oa`;Rh4f*T9!xJa z?s%pNbSzz34R?ipr3YIFm}4-QsEGhVBVnh0Z=zB94$cP{CsPsAyjzn~J8v&EQX&W1 zDM^rNiAq%p!CL^d>q>1@LeAi{fZ8Fs-f}M0&hq5hA7_%GjPL=iL!?HG|h2bcwerLFGA)C-N#O_4EYowV-`$h7vSEFi#bp-8vJ7x;4`i zWGd;I3G3+k;XZl^L6_zaH`ZG{2di{=ZB~i>OI*~kfE_zr7tmyXEXp`Q&XRD{OZgv_ zrASE@+r1E>ZTr0SLotYOoK*OF71O}>N${26yp_Fn^3f@8Jko9DC2nJB4)s=%$h2Yu zR`$>^(+wXm!^T$n#eL?=vK?Y$)l6?as)-};HOW;5Pf&}T2cA~yTc85@YDl33!0GMh z@m)}O1RRdSgVH}vp`I+knpj7?fT!wUqOQ(9q=9&-y_KqE5ABl)>(E@N8Fr_(|=={=Njda?#ca( z!km*DE24_2nooDb8g^BEVJIVcDeZ2P*T1m|DhhTs6_B02%jM@vY;Wh3Z%hp41!r`G zcuWPPC4eBPbOlty>?fq^jh|Ox)ZUov@rXnGno&_v?|q_kcXZ4qP0LP4aUA7|*--97 z)w;;McrhyJNjdcT`Tn&>$;&e@oBAwc2S0QCt2gI3_7Q@zA9TQQK3tDKpAUu>P|GB_i1=TI*?< z9;jRCh_G2KG^-fb=TFjG{gL31$9;&&XY+vb(JT5hEn#QQsGoyc>yO?0)q!6ho=uJ_ z-bT7X%P!V@6<$+d2hI26cg}~Zr^lU5cfxbc8RmUm_jDRjA|7J9O_kW)4QtAM&BP_} zL)`L259#{U2E~6b(mV4r;xwbqcK`3&79K{8Ho0raCH2Z^f8C-Xy`xhj+35r%{GixXT{qFL02&RS-_q(3Eb2f(H{&NLyO!R%Xl7 zoTs&Gnm~BR_iX0C)4wi8^S*6W@9}{djv(2Ld(KaDg*+F%8QaYK!TCHl{m;mK5)o%z zai0v85?yb!jPV{?xPsqAeVVGhY3M_q+1tX6@|RKB3z zJ&zlyq)gt3xqdn!UFz?0d7iftw9d>l^i&b`PTFisr20~5zsip))DE1+PfTrRza}Uw z*ILN>L}@;%l?m#>w_x7QE7lX!M6sDb$L1<@dd$xN&s=&cwvY`}&gppm_jv#ORQyhL zf=I}}-~}B;(4&@{JinF(OFjA~2ALG7|P0(cn-Bux`AHPoQ}Ux6%O*#q>ZB~C&I!?-?F{wwqxvq7V`8vjvap%xn(;NJZZ`=T<#Ks-}t*0YwGRgPMgvM*0Q25+`9rc!?Bc)RXF zoLx=^0X?h~vKKp%F@%8KQyh}Ouwf>H15H(-FMjPswbw>Iw0R@;b)4EaTx>%TA9?*Kkc{$KlVV zD59luOqG()<|`ZUR8YU3Zm9vO?EX5k<4q3e+OL%rbK!KAV9@m*pKV4P^V87UV{ME4 zs`166?gJtgkcO_8Qki(}?jYw+vk(!u#zOLCjgU^wzGFtV#@w72ha4 z_cu(aaotQf-fw&E=JkI6>q%!O8sRBr^SoVi=qDa8g#=|odmnwbxP!#_*gEfTqnIwI z3d0yH-kc+Y22#m6Pb<%KN?Z4b5B?C0j*6a{7bbKuXm_xlc*csp1&`=>leIriEx~5l zPbKEG&6QpptulStO67NKH=c3vmb2P<*^`<091TwE-+RX` z@%zr#@82I-@z@jXl8TZu7Ed@~QSG)PKKPA&&_7CD&mT#Zfbe)*(f!Bj87s{TrxZ9E ztMq>s3sk-P88Q|(XHgps2}k8FK{FgP&M8kvDyZZp!1r}aVRhou+?5ms)$KUM-#g5^5<#pI_%z^@* zsMOdwS0?Sf4pM&5RF8`-;JY*)?J|ROE2)imZe;ugbUC(e{_a2moceR&r&QtaVHs$) zryB3RBkp2-i5|Eg+FHG$Y zp{B8!(D`rei0fXPoXM?4wt3P=;&iQ8h6-)RQ-rnD`ju{%bsx`;GYvSudtU1V37Wo?y4fYlU zQqz^=TgTFkyFQj9PM-$ANB+WvIVmPrJc=93aS8GwP3?mh?V%?R>_oo&F2vD4vtdAQ zye-$b@_2H5p6DeyMLNqxxSFhk2sfsNYu9ACWN@@HL}!+9)5z{?F8t2h!E^WiU52S* z@bBCS10}e@Y)9ZS6`JWL>ewIHoixw6DV*_<$Mn%c(#-o}nP?!Hu^k#1jX1oZCz9bO z`MVWG8br49*^sIle^dZdslA0`7FiJFFIFtucMx(+E<&n<64?a8k^!mImDwtD=bU+h0v239BKNh z4WEW|dtBM2VmaqaXyCA)d)!w{HhKLgBqs9+8))^JMZOS%rQx5*$Nr17IFAsj*no7LhkJJ7?U*SDQKX!%0( zlyUMkGw&J9I~mOP?w!JK5RyVy86w&qXV%Tx;d&U!c>q+WoRYjhFh&}31#=EWOZv8F zqpabcoXor;ABsJAGzu^6eQ20F_UzBHJ4_(eLh(aKev^5JKF7^>w?#N}`bCXt+HAVu7W-~`W3GLo#w{!} zGxM_Dn^$x$DcfjarmXWIvHew}uRrXuaTe{ifcG(CMfdbfH`BkjckNHuD=>bUQItR1 z0>!;Qzg-mG0sV`LOlOk!Q8Bqb_Hekr@dw3KN_#RTAmGOinV9L5mWA!>l@vR*a-&cT z8J&!WR{>LEzm1bv>2a&%PfjKd$o4=9GKcE>+HPefdcE&ukg^ zWy;f-6*Q}uiFENErHRR9h4?-;fq>jJ#| zDGEw(@>Rst&bDStHNm2uQVF(*2Igp93w7La7%3_Hq3T5ctJ?L30HhcC^PEb!Py|`y zeXKcW#L*Zgcb&^dd-FL<=dbuEw4MpAv~Tj9k7H7uqNU4J(&65; z^SOGb`Et*LX2fZ!X9xbA)8;R9V&&+A@TON%moGsgS6MQ<6vG`_tSvrh_xO9J@LAnl zw3cC;3Tc%T@zYwUH4qr7d*w4Vt@e`|^Z!z#!Y^vPy_dS1y1$b;ShL2dx8~rmmpwm; z(TYZlsK#sIttoPz#V~c#y|0tEY?Su8 zcV?*3&G6gt+~sS&V&tP04?aHkzxX#~#5mtLF=?okYYEA=70}CJs&d9Gh&?P-g0v3G zMgXlc z-F41V`p~y33_qMBp4W%|J4lj(e6ZK;phjVJH8Ej^e5Mz47X@>XvI-UOpm;v5&7I;~ z7#`jQK~Ep&)2%ax9?bru~B( zjqx`kw0{e0Bh&r+cZ-HMa>cqXk9pJ>nMcm_OxE4Ib?Y8pK}354PD1S(O}{zxuMD#q zJh)$iDl4#moSb;5_+0qNEnioEtsV8PKEA)T0N-wH>~K+(Dlc-W9-g?n6!knZ^KY2i zd?WL)j-PI&GSd{Tm~-j_qXl2FK&*^4-6{8_CV<$azf57WJX`Ya%P)Y``2&!KH*VfJQSy6f z`U?$pouM7t?XF}O+9&6{-~~$GnQs3*w7&-rosd&3@C zK0W@B=(3I7Rb~ODz*pRtaCJ9U7`}Rn81F6`6q!L#!G5}2=Y*6Xlg79Aeu8A%vXxo2{VBa6n}b^B3{B@5F-|W0Tnwq5?;l05 zA)q?M)}TYq{wqj4;Tw4KkGI)9J1jHE8>0T9l)-+u9IMSCl4UE9JNjLxc*tZ|bEL#7 zGR(xe(3|{@;C1REYM4)n`j{B$M~_9GKJCiHk_HtGBCfEcBS;N8vnIydczoB|yCO^w zvXQ?E9E3zI3OkK8wn#EcdmR{a@t~VDynJ!()Vfgxy*H*XfD;&4iam}RHCnO~I?a=^zl*$1qdZ@>nqP01 zvObXi({_Z0cm#DHDXnMQjo^Q}cE9O`y4KfYkMAE+*&lX-u;uc2zI?f3n7U@TYq+GS z@pvn!$qtfnj#(!?wPd&Z>gB5;Vi6Szb@BSPz>OP?X8Za8oSt$Yhk8j0z1^TJ{TLu8 zvR|5H_3$jua6anXbaE+A$UmX4wyG14)2-juIk)O3gQRlR?Qy5Ls?R8&iDZG)R~5MQ zT!Tz991<^oxBo(xXC2;u<0Hw&U0GOmvmJ*<0q7+ojd2-MHxXj6lKRq*LnHA%)|bzV zb{=fKa1uho~Pn5_^OOLW60t&gyJ=rJ3Ar8ju&5#FKp1$^ zkIX;n;AA;@3|yWWNs}|jaoU(qkrSXZ8%vvU|Eif#zWxQR_`BWKmJg4UFRLJ||BLe& zh+DZE=G|O-caygIF8)34#S~fgeVs*C*oJ9}$wdWYrT1E?VhNwSwC2bq)-%8h#*{5y z`ZVmhPZ-_O|2tAT|3u3CfAwBm<{>6}nTDxL^jK-otecDINMv5JZg5?9_&~a3!2?Ux zjzzCMinoiIvIK5$+=pKfC3Nu@+C|)$`)GIA2_R5xRa_(ijBRI82uJ`>h-+~p#zXoa0C>xg0c=h+KV=yl+ad=84 zT}r#X{NrRYgETUZ>rgZpy3muRc#5CD^=?^7&br(RQeF98#(((39aMK?9>k`S4FuV~ z?Ct5vz$S3bI4-4e-WhAh47cLsq59mq{{f+Q!?XR5`FP|^{BNRE4Cc@@QD^@CL*o|i zU04HPAicKz0m#X*)YY%*qpVtZm!Fw%^gh8CT)6K zlX=~FKQjk0iD-7H7gH77<7GtW$d`w`8bjFYAjZlYK0i&!vE@CM66-2*bj{Wx&s+~8 ztP+=jM>SMogvO&Y`gek05S6_EG}ylqGuJ-FLUSS=p>?-Giq#Z>-A!;EBGh8rghdQE zilJutH3p1s5bE$0FH17~TQnx9%h?iGEJPzi-D$kY?}YLi4`3-mQ3RkI(oO&`3RtV5 z2t0;*Qiv7_F!OL{JKe_gm_=u&_Hp#9hyZFC4zE)Iqh}x?y$}H<7=qjChTq=S%WB7QsM~;M*X)eiq zJ>Ooy@r4KMLih-s>D(aKH`_8(6&1H|NL5<71a`#-j7`9W;chEmD}A^4Te0Sq)e4Pi zjW^ebjvR8;m3Ja>dy)v?|bMy8`nh`18 zq1I`y&J1^UiHb{>8`D<`A+^qJ#-G&@*j)YO=i;lOar_hGqiyZ*4gpcnADRd_yO~%c zVe^P`I;vQf$sH~O{-0gQM6`OtOT-HNnX_K1x?10gZ7YDjTx8dK}=i(YqrpilRMyEhNAu-k@eA}#cgl27G#Xt%qF5ceh8x1FCYCg2i zr&ms08csSb_F9^Wt&*nrTb4i{|5^) z81E|l*Mhtn@qG2^zmc$IHz4?8W2F3x4Po(#XR^l^RcK1IuqSJ|ADY!TrAuN`*E<`aA^lKB zH#V_9E@a&``LXgFmJ%EnJ%9JK=J2oF&z#P~_$TGq->3SusqD*F-X)?1wVQW@96q^y z^Ev)Uv1x5$iUdFAjbKKGWNC@C0|s2GtC>m)kR0+-=B{5jwPE~t`00aF=eBO%AZY$g z7|naV>m0)#o_H07t2c2z{?Y2c8<9!BjmRJ%iGW9C5NBo;zM)4Tco?lhGIm5~sb9?cK)K*EbO_)YiIb*d zD15wB%bK69A!V%;WCLB?I13HzM_%((Ry%Qd=gxQ|`I`fW8e|w`#Xfo=MDWj%KBU-0 zJV{IBUcab^alZ`9p#SX$T=@cj1$g_DoeD#fW3FMWaF%V}n?D8#ZK_}?M}KBclNZ0L zoZ*<>80A&+1_@p;o$^v2VsTiBoo<0HntO1v$H0GuK%!v_54LEI;bslzWt;k zLPeak7cDD8DAa(BAJhNN=Q$WjNRw3^E(ImQ$^-b~@#UJXOaB0(( zXMsfarseK++Z>(RZ2+EDIa4@kWv{Q-=8(MwkgSgay27F%OxJ@X)=%9%KD^P>n?Ec= zJk4-?Y{3-=3oxZ5J>8ZkZ1YSOqA{!ul%dVhf((j%hfjsu=*wm-229u+S@St%dK?XcPqPS#f-n# zzU#3~`(XpIB28oU?fsbamfGfpL-+5`HspTb-?meIpq2P_@v`C2imdIG{_x{} zcW})+<@m;KUzKrh8c5yaZaYz1m|ey{=&pSCM7olK#YPHE<&Hc&Zg1~-v8a#w;a%)bR+M=X|)ZKti>CH2mgbhGXq| zm{xeI(uVssheNlL*D3DMg>PtYmrXb=uWbskGXICo3gM`BC&}#7;%9JlMuc%xh@2?< zsPuR6@2|kdQX0z^td-?-1)m&pH(`}+5Z~@ zejKqvs4SrDD&;P+Xqa{3w7z2 z;QpNBHccWI6XSc|EEpfZH5HmJ!-3Hk=+kfHl;RZYIa7E)~zQEPMx>p*-Ru zF}dXFTjeR}$9OBTK{KtFMLgBkp-H#N88!O?=PD{oJl_*9pMfc%Rfx61Uc~D-xUoDIzR(!4OP$Ro;ge?9@$dFHy z6JsAey8qHackXj<`)Ze&=)JZ&l~iXWF4^X?vql3Gt-uuRQ#Fs`&)9bC*)UfYR>bZL z%P;Q|X8y;!6r*{UlK+Euna>^*ar-|+F0ZIHTC(+I%0Tt}pxUSlhR89etBY#5x2=9E zZRMAFnZQXiFYi~hWT%U3zm=|EcfPx6>@?vMbO?ne^`ly{-@{A2TkPCgX19jdM`7y>fcOL9Cx1|L zOvE&O_PuC*L~~%#CdEo-=Jrq;piEPLuJ?adB%@&D=QJ%5tz5m@SKo?kFX0u`@2>A| zSU62oH!I@mKfQcZMH+o5wp1G>jD|&xasS<0F&{;@xI>Xj=p^*w6J8}n#UMtuw}qYB zu+1t@Sq4s|*9_9@-u=9Sn>WP8q{(w&BK~HNpH8gs#@Pw49@}n~4UKo_^!2aQzUZZv z=?BcK=apodtmTictcF=kp5URJQ1GOBIzA>EZ!IT$vTk=W5l8jn5yTxNB^|m7Cq~s% zmpqk-btVuIcT^Iq7E+-%3_g~185)svVANni9`kld0N=WyGd+)fOL_ElK45jgzJ;j6 z-*esCk|<-cGd6g#FaDuyhND07Nw3Zyj(oC+ zX2d|E9T1_n0a_K}UO+U)sl80T8lbtDZ*Pnw_#>Uo+*pL%a>9bmlknBWc1Qn86_R%E`)~ODx$SV(uWRXoT5=dT{iQ3 zVA6(!B+bi=h6KEv0B-TsQKFqtJ9U)tr*pVMC<3#HflT6wi%o>86)a|xgb$lUf>*r& z40*#V%jr?`r^MueN0GL(vc^A1^?o9iIMFDv^b0kg&1gm@FYq5m=KnkYy=P(ryD<;u z8e7EUMo|7x?fw(J`Ad9%n1So7Wy@^q(etR;rGlyvOGMLVoZen(&XXuhUw)n|N9}J_ z8Jc~G?evfGxIk0ohlaK)`);Pfo&QfW^XpGD)3u4~Z~lQ%u*&S9S5GC0p(i+B-^{X@ z9;e2kvuKzsv$(JamQ3PuZ+zDX$kp$%i2JC;oOBp7muJPXbUGYa5P50b8%(#E6FP4T z&@H9~Z1YE6`L~qC^StcJE}_j&7h^M^rTi`G#Jhx93)I`zl;g-?=D_0#w1`%8Vm7(T zR{00Ce>UfMoZpx)y>XZD#hUx|Z{Z|q0qO$T1!$j!ob5Vd=yf%o5@LA4;09~#kCorW z0v*}CvMfY*l519xWaxO6vow6iI8n>A7K>swzpTuajsLIV{+`x*O3Zf53$p(f%{1p)@K{zk`DiYo_(n@ zmIvK=SFBsc8b6iq$x(-Pn)Y_q<$()M+X^Y~GCNqJSd1x&g^lV0QgkHjrJ8)Ot=2gn z2hCE?A%sKiKrY4Xw~MLu%f(E+yA=8S)W3l%sMiwfdC*0xy{aLu z^(*`XBSy|t@sg+rvX?08_^6k}j7}Z(132SWp~V5a(*E$TeOKR!v8a+{7!J-l&l(@W zc4PgG;ug{^sK&?1| zv4>WD1dc}k%qbmyNQZ~?6gf3HEcS&UdRhZi*<)0G6|3pL=2HxBt*35h3``|1-qOJY zmeA__m%*hg#PJFhBvnua@AWMgCNAd)tIPgQ_NB?Kac#~~_1(W9n)ee#?G8AGRiq?- zL?;DnH?*Z3bmH=_PIYzPlBj-y1-m_%cshp&GEOf}hngiP^cFr&W8;wl+77R^px6vW>8=B zp2w4Xr(fv#`44)k)6la~NRnS5TDI4kQ;peilePZ`%3O{pG_+PXu!D$%Q;`p?B@%_X zRUh={8(v|yGMSFG|194Na{c>GF=-`RZ=goH?TyfcO3K_W5ZRMmu1zmz6RXn^7hGr( z=$K>^n%UB)M70+<1%Mz_!9<;;6CEm}u%qc>)}5;erBLw&IRthUIQ+CF>|9yb`8l}7$y zPag7G>3?C*`e@FRvxio0b^m4@FxiKbKp)RrA7K^>>nasyNdh99hbrX$wL`9c$va}f>|xx#iV(!Z?sADGXhQy@ zxKiTBcqPU7P}}nktNn5U*JF7k!&EK3{1SDF-9eX*wSQCRwfm{_1kTv)aMhBQT-Frp znGmJLiyAvL`mE`BhOvQ@oY%2Mqw_~gM`#^v2$}dV+AoJ?oFV;8oG)MSn-)1CrKM}O z!kTJj7))2MW8es}d_vN^OlEgp|7gJe+2f4FgvS*PyOcnm@XVZbaR&OvFFOhU+MHn; zPka8H?9SACZ#>J(OYF9;KGr+zK`XKpM*D0nvCBJ6Os9EG&9oIfqRbl5=u;emfY=%Q zyMBbW21EY9=gIW6zeSc3#7A`}gI=KP*3pW2=LZcrV>D?NM7tEDz=5?2rjB8oTcO8t zoiJqd;1o4(07dXwoOr(j_JvE8QxcybQT9FKPnW@oAk<<6GoG{zPDRmmHXXSgJB$Vj zt5AX}{m`LaGHh!Z>b~5u&>A2*H!?iC*&yCLL-45@k1m;mV4@(v6m|o#NXxYE*%U$d z*!X-Xy^>BuB_sscgvE_@VtgQ9O%l9o0ZhP{PF2I2w^RDHVcp>QK-Sd-g&y?u!fQ5| zK_M^&sBJ7jLx_l@U@GW1Ds*0YpoY>q=5~M&=gN$~>uvwAs)P=c;hZ5lAb~XxS)}O(NL!8+nm|d=_D&=PHTyql`Yq@$B{PuvxIA zB36Bg%y8iHF41o)uw5MKv^qQKMjn@ZS=Ba>HD#{FS@c17cyUrXCvy04l$g_s_>+;( zvn@N%azwl+Ad}(7&L67PQ)+4=++3_S@l8T`d{NGC?lkTOmcb}Yr%-6V)V66<8A=*J zQ+vuChY$c4f_U@B{2ft}=I@0g?5=fNLE_GxwomnClrJm%`G0MNqRaE`a@<$+3xZWX$dJd1x>HGzW!7I(0WaOHbD=u19=H`HiWb%4Rtbh`-%Vx{W{# z3vKP6Qwir};mKF$u9logLaQl%i=@G;leB?hz3qm22k7w=^)QU}npgL@%tzI)2eH4| zmGgqHR$uI+)}v#&(774sMz$RU^O{>}{#%b>i&5D73X9PBFPzp-+|SdzG2QcIPW@H8sopVb({%Ev?d{}i z_Ik;l%J$Y;UCwjPaKs&CMS=5IV=HLwdQJ0cbxzym2=tK-4LMeaj@g9xtX2;ZFW^kvs*MU(RFL7(u z`I%-$Lk--S52L7_z5`Y-WcgD`aO1{L`&8wQKl7`5kTekJu*=Wi1?V_B3XqzV{7e#s zDkyVz3WjZh3$Ix8162)vD=~BDK3%5X+xD?uttc}XQP*OwS?8Vz+Eq4M|4@CiX6F3e zYs%+hj>%s?-7?MjBsD}^uF`99Aou*hpysKgWsWt{t7cTxw(oGd3Np_(2lK%fw%zJ) zQB!_|(|NG+i(~$Wbagc3Tk|4V%@36@8}@^Pp{M>HAwxc?;>~J8tecD&=uw1O@6X>e z0I3=B3!F#0Y1@{FdCHy4C$+Vwvf?$?Lnh6+zr?~U#)qdjGx3uF@{>>nL|Xof z2X?ieY5K{D)Zwb3%6{l#PFxJ}zyHzc!HoH+>VpFN`Im2wEITtk&@ZBD>5F5A^!vei zKI<_Pq{4WT?uQqzM=SF3VuyE>eWnY8T`EjIsI#j0%xs6_@gl^h1Pk$9YQs_ykkUx4 zUZOaUlNl?=D|Zl}MV`gnIF>aq;>XA5oV|@Yo@3;&Ik(cXM#ODDN@P+fa;lmKF$TdS zAa zdNoGpd!)F_l2SDa9$etn0MY+yW*XtKQMPSYQ0rv*PRmv$_^d=-SVwO%IlB1nMs7SC_Sg zoP5*7q#!1Fw$z{VgXi!YVchu!szo+7`W}3Pu>!AX_*v)Ph>@*Pp5c(4p2O4w-hvpm=`XH&FlBm*-j{;Sr}_ zldu|j_1i;_YF+Wx^6-a0O6lcZ#tJiy0s}IJUl;RMt|BmP_ZJlIgH=zRe`(^I>*fqXz%0*ePfLz~( z+$#^yX{SXRe@SqwIUkp#6}2exd#XS@dff76b^g!mWL+!KpmRty@z^l~AIRM?is41> z3UcziW~*%i+F1vS#ORj;w<@+9Es^J6kXD}x8u4sCN}4;(w=GXDsw>#DUeZ+QI#B%P z1Z`i@uBHnec%zlpya?0t7mx7b$_-9^xO5X&kB<_#=$to(IDL`zp4WE-UaO+QE=~VY zywq2{-{aGCJOXdjcH+%mX^T^*X~lC?zazUbziPa4&d)An$2 zd7GBT-#}bQ8LROuKok;pgKbz3jkGXMvKKXX+jj~a()#o;3a^GCL?!)tv z@Ela%)uNo4S_$0iMRsjQ5S8{dX(vT#*TeO>OfyP+iihUSs}PH~Z)Z7TJ1siG1W?RqMxj3=hz*vjCZJ`@{GCWV&|DZ5^1jj_x#R<-(Y zj+9U?1o!kdCT=ZiAU5@E7de;9U}_&V685yF{P=CBw{iJkWXy1M47h4{C~^f6U7>8FkPDEz)@GJ8%<<#^LJW(7IdAY z;JqhTPf_V|MiUoxupMh=Z_@n3SP*-b*lP9E?vegawn)UyHgG+54&27A@yNDsiVa&~3Mn>L*{;*h&;fPCNNjxb)>h`fqA2K6PhxE1M3JUX2OfUf=7$J2E&i#2(9 zy2is}HBVH&VaNO2^D;Aq-dJ^-{8|2&h?hGpl*)iLXEU3gmK6S3j3^P64!;#8objLF0N1{gr z(LM8P%o`6!`LqQ`uW>7iZ8MY!JVx0hKo2!OV@^CKcPK&!=H6(>jwny>`r-o#`F?h? z=K6Ga-ND6dM!r7&PwKzN$q?k!-|E!#S*GH{i|b^q5Um>VgElQXWR|sUrhP77ZFbBm zboLR7nQ?yv?{Wu;yb6RtSA*ledD8uu@%!!7NqSPK7xeVl48@O+h6#Vg9;ED0<(@BZ zq-G&UU|e{_laao4(GSPRNh0Dt4k0X>_#RBL+T)#N27GB5o+Vg$WswChP{K`}qDCVXH31?<4NxA4zFFb900&cZKCgLSnR}ej4x}BwDYMSTcYj zdPm1II#N-CJ>bg&a8eX-F=_cjwtDmXz}mU~{qRYcIQU}5@09Y5bG8l$T`>WHXTFk& z9&xd5QXs|XTrmE+TYd@pE+w;@+1`p*M!LYPQ?ijf|JO8BeBj(A5E)Ah^&^ik9YUB+ zIsdTqqSQvV{_b>pdJ_xG2|>1t8-ENqODzyHqMbo1nlni(Gx8!qM!ut}L>1TK zNX~XKH)W60HSEpj;1(g@$(hx&1=r+@bL4DUVVggNw|pc!U&&nDXu_DAu~DE^mir8K zg2X;*Xnx(f7-w06Xq>?=IY`ufBRMA9_(az(_bNHrp5akZ$yu{ma>>~hH*o3wIQr}Q z{6~9L|$4=nD>7?pU}5BxeoyUwplIG<3gff>we6I_D3ukCBrAm`aFOUQl2n zLQc8qJ2eA?CwJ3lLLP}`@mR8=`RitHeK91Rj~h)_ysi%S|gjM?WaNK#*WCsQ)O*Zc&G*QSV=NJX`AvD5%#5hNW~K$=JY%uYIoqK1ZIvYHTjJXgNjJfh&r zvB2IB&eHp3{IdaO1$y~cx%!z?UplzDP+OE`d;w{Ds;p?pl}Q;P9{YuV4i>*5GjV!j z)PVpR9vF2SSFK}^B%4RLB0Zy=zTgu-V^MQyP3JfdN0{nBlaRfB@AlM$pyBy`8Bk>h za8B^RDzTTpGgheD?*#!xFQ)aYJxj!loGP8W<$}-2jvtZc4n6#z77Hz}env*X8b8*` zOOXS9Q>p26zN_H*FaUiHDJG+N3($f*-fHfA>w3w*gDUm|$0X)6vS%?AVrO(k@ID)R zz9X}nh5v9h>=d!-1GQs%0(Q4PO$jwa{XG5qxN1p&&Y1L3RJrAlw8|d2ZTAXASsD5!--Y2ki^Z`JYJ_O$ z{nQ01H|j!`o9OJSu0*;Z2|xlGAHH)WcRkhlOfcCr zyI_1~xU4MNvI`znEgQX&C$mvVLbgw!-Rp1N$|=Dz zRN{t!8PDaEU*k*`PEI& zO4wckbRC~l>L|>9k7|B~TwTc`sl5Hpq69HpyU*HhzHB%#RqotuLTC+o3imZI4$_BV z9zyyf>W&^>A^GbCrsT_K#d}85R-n|Ll7CffBHaAuLs$q2A}z(;L_-~F{*f+Gil8Er zj@BhTEyXjhZ}e?U(L)ebA)xmA+A>PDIaA|>-!#G1% zU(m`hoR40bagnT$DBzV&7spj<3*;`x5OBscy-MNXjME|q$L!Z;K;k4t-y&Uv(bm;q zCrvtfD>In{22zuXhMIhlytB{jUWgxfCAb+e)Cu4Tdgh@j3@;Zv<-t5)z2sq6QtzU% z`u;@1EO>dXPTy%%e)pykVYuT>M_gQH2A&aa4FyP@(>;FBc59oEuN4KSU{m6DJScX* z&lK>;3AoZowe5gtSL(G+j|M6@1n%8&rAj$CI5~zcsDRTX*Pp*db~1SBKo;#=7Mvsq z@jkW`1Jlv#L{;f!u(HaoXd04srqu+WzJFx?dgy`8-%;8;>JZ8Ov`1K%lWj)h9t(xuu%bnx!| z^`VYG=JlhvMEtins}8ihCxS+@;05hr5Ms?Y9>&>-$x&gI7$9SG+E_`~nWh~g0)j*6 zbH;64e!y-;()n$CQGT0_;$0jfyX8a%uTP7WbP%Tg?22VbHa{D5+@O!i?xm!fmSbtc z;|)3o46o2LDCS%~C}HnP^;E=zk)CHvXxx;fFkR1LAZ2r2$_$K%_}WbHrG*If;b*e9 zCVtr7Cqia?A!JyeTUz)l>(f6t`cP0IzzuMPX6vB^BfMx|NZO}zhk>e$=c{K_=YOnoPzgt_ESdEx4JnH@=TtvexFYq1 zNzFmRDcc|k_$Rc~o5*UXrEkR$?0dZb*@CS8ed-oQT?@dqG}yU~9Zx)?lS z%o5z3bDdSFMf;P7Gr;LGd-)+rsCAU*S9-q2LTonx1z9K9fXjvNs!nlWR$OF)Nr084;{yrHB zKa-I|)K%+u|Iq|~Oqs2)F03DYQLtQ%=2kd9#Rwp8az{FWT9VC0He^KBEG|#RW{9Ug|)M&mTsB9q6iGGQH!^UJsw-6$Z(JcsK)@@drBTu@KNm zTA_%d50I-m{I_0kO3YO7by{sX-d)>8yGCOl&eOyuhAt%@-e-ZAFJBg|9{;*}3qeRo zY__NhR{0TKbno_7>#)THGwFurIb26h>!7p>=Knsfp`T3YQ)ZY&I;X1#jI&EpUzV=J zLwZJTfS~SsP&p7d;~ULQC~NXMmu?w=&p#(Lwd5T&TNp%2_8ns`vfGnzFCM&cxpd%2 z-y@ouxWsK@vEUG9j$RRFLTDaRHJY#?W=+7V99*$KHVlHy%xfDoq>YY>W)eIM#SC`ZG;x(j=|9d_xzv-?-X)hNhbD(d#bZm z(W(5*@e8q2PzXYSbrp28EA$eQuIXJli-Dyf(Jd<_YFDpks;kW{!7ZCe{lT@{x4GIw z2oZj~E`B$AFs2B>rI1}}0gB(AtoNP$l-^=&xI|((_xXL^awC|N3Ftv(p)EpWQQ^CF zq^@=f*U{OHFiC~(GdUkW^J48|3Q|0vob8D!xQ6ZDFiRMNg=+K%X&I#~$Yax9l)ZN2 z{b@EMlcLuw9!-%ovrlHY%WUAqk~|fSDuU^~=4rG`_0FtsoA>=Jbj)PnW~yL;R67n= zcP>xEq{`4&c2u-nM2RPt2K$B{&)p0t6Myy5Z<{QC4_Q9)bZaM+2GRBw;bk_C62*PK z44QYgPm_sl*ajQz7GiP9u<+^U)y;D9CuYFLBE45|ZeES5Mmb^^wBU^o}9{ zhHZAn5GNnt*seXxT@|N6%1JRT zjT0iTpi(aEk%+sIL|oz&$$=Ty<}GisY!a}MkiR;78CF>BC$O?arwDDX9v@vnzQ)M0 zBJIq@oyws3wR1^B_jKk_^o&nCWUvsaRcbX!Xh^NgXi=JH;!e-<_iK})|E6!4d!O!= zWf9`(>G=_Q8;INPff=hshL;CXi-(BnW9IyOpveg@M3m^$#AD z81?WKalXAWgPJy1b_mNo?J?q)70jBK@MzfrmeNM(72{0$I0ZgDa0TT(+SEC?{lrqK zu{$x0SjLy*rXJna-oKwBlL}x9wR)ZERxd(!lkz|07V` z*c2*{$RQkYVI6oi!S!bv9cv1uR3&n!=@8i%1gkl~(&aE+-dDc#+Fu6XQQA0}NV3YN zl7(S&&8N0&6Wftj+cBt7c7rHYHhLDH6e$b49xJ=gHznpGqdXZaY?03Pxq-)`{TOfz zdu>|(X#VRaO5s3IR7>Y@7`fgCnO1gQXzt+d73sQIRNH25V=9+y9kSlDJ)*=>uHbWS zgK(WUzVtRIObJv{)^NGK{4fTJ%7D^v*Y@=hUp6BY12NM1cqXlDu)L5pD&Jmd-P!p^ zc)Nw%c*cZ6-=4t?pKQ>@38;`LYDxnf3bUZqq4ez_K`I|agkqvkj z51Ya0CBXlAGY0z>P&%s{Bm@s4uO4QnmK-^1RD$k8|%q z!)ZfW0k-po_HSIx)8<5E7Y97jeRlPh8Tu?~Z&OJe={G2>!u{re0qb`9_h$b>y^Ccn zOu%>KHdr3g4f1{Qg(-?wf=iMyy$zjr!@Pe9PlgGb^;8t5Y+8FiR%MH3sKkX_LYn3HN+|L_HYy`K4gM?C#+N zA)T&(cmfPGkj&KNXT=`XL>lxjycbZd33ost&c8iEBBVLyO~_=$yrWT&MJ_ddn2bgw z`2fW$l|g|;;d-;nTg*j7aA-iU0%BRL@*YHMjRKAupS$O0jm@yWlvuORF4x z4m-};=_+Jjd& z??^u0w=Q5vO<}x1Z*Scyn3ybXxU}p93(YX^J>*gTw?4Y~23B2duHKgMghmYDd?9;l zTl@}tc=j^J{ocr3^vT+cH{nnzg@qfMupuP$2Ga>^8_4w?H-Q&VNZ>^y+49Ts-9Ti| z-eXL3Z!H$i?(n3C#9(IYz?Vptr5%_VS#?pdcg|N3dtoB(XY|JtLoyM6q^L~LFhuhN8BI4qnZhp+LZh&zJ4>GcmVbs1(!@0xz zd|GS5FAzwgOCVIZ*c|?ZB};zcwlis{E>Qdo)m#z^U86k~f5NN=nq6yt>2NF6rn$av za+nh#)**kaP4*agO_1)P^XV;1byXO#wJr`|S$S1!Z;~)Oam!>6BoF7$92C zv2@-9`!G|#vS~Nb zS&m+i+jr`!2Hwz+3(}<~jnQ`%>h^*6LEkP??+y+p6Bx$qo)vg1kb^hwG zsxg2O3E@h9X(MNAK9p`F==Qn)>}Mjl8KOlpVeoTVK`(oHlE@O7Ncvjb`DN;JecWhz zs-pKjDzcG96`Gp>p`1^%Ij_ez#mk zQ!ve6(Gl0hKd+6+|001|e9;(}c%Go_?wq$S?w>S>uQnZ9fL zL`b@^`3ggk3xGRp9xVA z!uFMDsR*r(vgex1Cf^-~uwrxl07#zXM4gm5@pUedg$!;v+K*S)scO*JrBE&%`v(hb z42?j=B_DbYC7uZ-;v^L99o^CwGbkS0XN5%@50CqG$^R-td%uQ&1iw1-UiUuos}cM7 zqQ8`Gq_GfKNzao&-4tkjD zVX{_(_qlp>;_dx~;}J<#5m=XHYVo|ZnRDsJ`~8wyMj3;UA#KU=6xnf3sSr}C5-i{c zmvHZ06`QaKk0q#_uknEv8>w?vNXFKUrmxS`{9zS>d?${!)3|Vw+z;GzHQ(hqnKRh> zNU>B{3I4Y*iye=Us!BqT@E$UJnIGV);@lQ{@;bjTd?Xkvw6GGcMD^s>K-NX6*lNU@ z1zZmEF04gv)mzWD zj>Qqw#E|R*WZr_|JYnG*;^~L$Y9S(D){0?qC&G#%!+2HqE~d$Qs&=)Kv6h@sQ*N0k z8_)W-yqjq_A5wK^90g_<&0Y=MxT?zfzH@FOOma_Sie~3g$L3s^_)Q=IW?Xlg#xeW) z^E@%1a8NJ~-Ze$i&;$D(3#=5wFd=&)ku4}VFe%Rkjo&7H`Kv{s04-5z4Nmk}*{k2t z&Qcz}(r5xy@soJ!YEv6(u4fOZV$b`KzOx?A;90n1A)%m7!#RGC_0D0J(QEm2D=F#6 z3)w}{96#N^zx~z=>1Fg|C@6#}k$OKdM*X(52sv;hXxRt_XpugG48`PK#2ramJ%%z z$DoO7LZXkN8WiIJ*#+v9na{n#i-K7-GL^Q0S~D@|qz*G6-A)ZB>X z7peVCvG3XDnlhVyTEg?}K0!VW|B~jX6P_$le{1x=1r`O}I)hy{4sosihubWT(|GoG z*%F3BxGA8L7P;;OzSO={)w~2UrXB0GB}hE>S`W)3!c+iF*AoNPiySwc*X?4db3RhF zD!G>#tfHCNDFqp%S)!96^@`IlL_#9)44DaUZXp3D7aH_fHYSUPvx}?MH;?s(SvCbF zvd)3?$Im}l`R~HkcgdV!2np2RqMsrgkW!y!Mrq_-|LgC zSxYHpR;tVz_^();$OCjZOn@84Op+z|20giG7+KVP|a(G^TMIKq>=$WY(smKq{< z0V{l0&CU2@6Q@Y}U7F&}0L;)x7r?nN;J7$@MF`y>soMA8YdR@`gbdaXVMWm4r{4x& z--Rt;B^|ONBI%&~)3?##edL~54BuD(Rz<7?g|@E_n59olN+s!jSXu>RqJ4Ni4@$2= z(xs7&XL~C`b4l8ZH45sfEvIW@nidjm|B>vj|gu`0<`-+&+4hmJg^| zEpKCVG953sGopXG%?|P@#mg&M0o_whCP;^yv)@mdp9B5yli^L!E5@9AKS{KRo>j3z zb#q@sHA$-N!7op-D|H@bN3P8a$2Z!|GUiUob}5Sq-1p;cx>v2Oj5BPRV?-ULU$1Hk zE`UIKhVHES>SFgC;k+@^Hp4k0PvOSvhATJFSBc%3*?w;FOK9fY{zJPx?|yh?s`PO{ zy1*lQP_y_*UbfL=^3f_$+N3e$^S=3Yzy);C_A-p``4ZMH24)!a^gm0$v}yx$7IffLCZ^){SH0^M&r|7TRt zm^GcX%i|9<6CZl~s9rF}xRa|j- zWD@O+fP%n^e}^sGI1fwdGxJvffNJOOECBRmY`wdN<t7%TBDje>CEg47q(aCIJB3_L~_VnRDNaTo1$3Y z^hZAO{&O>a9l_mokNU_$zY`ot>c!dKAeo(DkV}-| zp)@YQF7CHV1)=R1MSSrA*N=QEe)IFFze~$HDlh>w{bkz`$t2tt~Q>|4|rkafc zvoTQi;*T^INMMd5^Mh1)>>de+0790SrpsT4V}Fc~J#HBkWa{Y?{gFETj3@hKZWYtlp4@HtsHMN2=0X6@fr+p5)#)^nb}G{E317Yp}NH` zmA}@1vP-veA*LpFkCB3Ff`(d%=!UM;-dHWG9s6?PO^=pT53*hTKeZ*6BS$K81lt#= zO-ZUT+XWww93r9or4)XV{6b53QT5VBF>m@fuUTmd_LcA7TMRUl5t_I|qjKVVSNK^d zQ9Nt9TJ4VUWdbPo%R9fHxtiT8Al25=Q_6`7n2I7+7Zt(G%6u6l<# zNWMgytA9l8vuP7kC27?p1?S1tT16uZ=@YU0Mlhpotv1}% zy7J9<%?Gvgfo+7F_)=QM&M-9VRYm#mAHG{cEW6Z%=F}(iwR%bn{vaqR^>T44C?O1r z`uVc~|M>>3d+QB2GU}pt*U0DnTL7uKi64_%-IaSc5WnCF47MesuOyU#XQx#)W)|-t zc%h4*Hlg0OIuJbr8}adD|qE96U=S^6n(?gEYYNiWedn{SYPc1vU(ikC}8Ijz*;E+VQZe7%0E`~%uO0>-*s8< zXAXrCR11OeDhU~OL1tHv+Sl^ol}I~HK1#=-Q$qO^R(BHWXxTW)kE#Jw`OGp4Pb}9U z&!d}|Q`bsD{!Zo_kvY}^!}fdVfvY6iFz4pOg;oWj%&tdLmI(iSIFH$hM3K#nHt&b6 zQF94^hcA-HyCJw;V&xXH@YgOe@cz^MOtp;$LH0F86hxv62>8~4n~G6q&pXbYg~+J7 zHPm=r;FI)HR+?mw!j2Ni zk9Tl;zp`LEdGO%xc1nGR2^62(dq^6!gn}CY|FDPcwc4~37WZdMrv{0<`q(B-jSZ90 zTuiy1Yz<;pJA3j3bZvT9_CQ=puP1oeU!T!o2 zaU`)j_pK|AhCGk567w9$Sey8#=Y*t#C5)2+O0uGB`Z!G_!~*lQ{RcQ06$Bmg%d|P~ zTmBkRP#&0+O_&TIDH$aVKtz1Q9o5FT`}}F~a3vxEZo72BYbXN&sqnNHQsR23{~@7* zqOJZf{#RXBbKDd$_{j@olGyLQyp2K5%Kw=X-AJCj(fyI7+n`}<@E8d+?3DM5Gx((8 zBQOwIrrI;iT&+Q7!K2{yLRvJ_DXyDHLp{piV4?fht3*odjasr4uTFiG+4hHr2-ndyO$LMb=$tF4KE*tr&`rH^hAYHYpE?qRL| zH1fECam6#R>?bAitl~FgTA3H^Dq7e6%L3`k-(3TkY_e(e4$Ke(g#HnfEg8IEdNy3< zn>%9Hv++eJEf>P~osk>6Wnhz8n|;XE{)7M5qD$WA{Bm5NxE|s^ zQt8E0g2>DNcHY-#cAbV1J4yS|DyXJ8%@;ag6V}+a&d9Ymr&z9k#r;F_oosQ+esgfJ zK2vksiBXYo@0p@y2U*pes8UF=g~!>b4Vt`)lCH=1NB?A8_gfeGGLfIyI%25RPZN9h z)>mtGR47JNg0g!kC6kukO0vbqU;GK@jc589%T-H}7zIFn=BtRVcXW4^F6LKT*k-<@ zQK)w9yNT#LuQV1sD$o(5!ff7i|LpLXcdJ+F^@%_s+2i?V5Kaodll9SiF|EcN$Fr`*;j)#h-;L`@gjAxt9;H=lJm5|>r@bJ}O=D<5d zruvMTGL$rWXBk>q%G3{p7*FV;Px4Fmw$Df%r%xCuJ(Ln4tfmoKbySIG5`$x$!MXO) zS&+PZeB@1@Cgqr?RGr0_BfELKpE3CQkLf5bG~4r8UqVpXyp$^$-I*;7W{e1^Nu8?be3tq$IIw!>U_B3{296ssRO zK%0Gz;qB|a44fA{@>YtlTGW=zy4G+0m_R9@6iJvZ-w|VC{$RqFR^6FaKl%QVJe-te zbx>y#C~#R3n&Guz*dBBVg-$mLfj0$A5_x#MQm$LvzM3&`rAMx5_}eDH)qS$+e7N8g zg{u|!N@$A4-2EP_fg0;Pw+(_R@~cQ{lKzzwdUYn762nl;EYgT*3C7Suq7ewb` znZj6s6Y|K3#HzqtE=OD>-16BXFvD<9mCv5QwdXJQM*sA!qXb>}9Td9Jt;cD?^3*f zdZZA+_7ri%)UxA|Tw{B$U)Xn7w46|thjrdgl=5Pg<{5FlV^^i?fZb!{{6(x;+L{Sk7dDl3wtxG@P@@T))(*O}(D{tVC@Ecdv`E zgc3Q}r_WhuVdmEz=bl$^?#kSIsIoekqP@I4`nMnSs8(2tyK8G0UjQ}nH(Hz(hgPf< zEFGknw%8HXJzaT<@Wx%tnjaGz@8k78=Dh@*KgXY=FD=OVbEmOt*DzhgTuiUW!1l6v zRPk9kd3Y8Yf6z}ye17q5hSMcjHHp5?@#>A`_D>5RYaSjU%8ajvlWu2eA?v4GDDSWX z^GUb_-94O(kD+_t1k_oBq1+1Jtbh4663STBq)%nHnmQp_%(7%^BT6zS#YuB@Cddi^ zrU4W5b0exp&eSCwr`tQtg|wPL@lbUHA>>y^oRNxrEyF*L2smDSF(DiT$NYmS{)h&` zuvb9IYhs`z)Z8=XebJ>qyub7dBdbtoVHjROnoPm)-8c2`iY_lz{#_G?0qtM2q-iN# zJ<BHxufYkDBV=)jpxe|KdPC|%y8EvQef>ypz|k=vGGgpUYjAgF)|jmRUT_gISsUARi{iF zRMBd&ma;Xm52}44te#PLj!xA7KaNd&9e-U_8ywi@eU}~C*G_|~cgvRe&th`V(_@bE z1xlj*+eyV^tF;QyN_bn_Dt#icPmJkXp5)g~-r_KOT=JqtN|`-b`{7 zcvF_A++dMLE=seno5)AOpJJa#yPqM)gF2eOx$}xwpkxQuc)c@m3bLQbeJc4`5LEsn zm9vl%iwI@fYxoYga(nv_pifn|e7(Bu*l~1_pO7lLun+j4=SIN6u4fID$;O#=YmHKH zs2cO?wl-YN+W%Jd;jNL!$Gl=WPfc5oxIt0IZNe{}1TRI}g5j(1i=%lU*ki?Q;>Eju zxrFDN8a;bnEEJ&&F2ktv)r#5;9QfxwfZff>waY*uH}JCX(OJ*Mf!}~Ej#{1Bj+oG+ z;1OYxi8^vwzZLSXHrPEO5Ypfja8oxi$^1VzDt=-BDD)>_xwmrY`ENS})^q~^$h@Jl9 zGQE@)A|R;cDKh(drj>M5j3Hzz!AJ4P$Gr}b9f{F>ew(PEyiMFPe2qU^D8g(5({bbp z90Mn^T}O|HoAxnF$81uvk+jih>$@o35>KpD2R0qYWszJw74U4bbQ*cl-e-Hm=q zlJGuP+A&Gme*5n>z0Z~)b&_iE(twK^Emhvg+>(p z>1S)8xZoFPL!G@}%4&!fC23-{{Znns=X5pK*j|HY~ zHct%Lsa6TUH{9gKHg0$t+VV0}7-&P#mbAg2um5BL#U?Nv=`u_TsH7&Cehd(lULMPm zn6#fRmbe?ux|F1Y!&JH?VO9AkAtu})lZJ%e}sv-yOz%?!hD z#}}Uh9Z+d6ZwPEA5&al;vqLYzVLtxW>}zQ7;Sp!1f`r7_JarM&k-oJen`MdN!K)NL zZv9zoiG6@#>1AJQu;3%!<7}b%)fqM|45d3^TEY07emD)xrWR?i%cb=@2SE5qzc6Qr z^8)YpW47df695(oAM}d}Wkm9e^d{j^@(qGG5zIUf6FTpI_ z+P-MLd8)IJHf`fo)D=e6;>a5BCHNl6LzP!^3t@le`CP*!Nc>}xM>ZKTExlm}HOVn% zn_CLUqwOVE1FlTiNSh>O^YPkoraoSoW~5mNwM61r3X+Ruma}DwKU&w7@Y200B=kf$ zDCFlE_HSO)R@t6Jd9&19yG9b!ae1caor+dW%?e4S)SM<>@L*E1kh)YWT@+7^Gr2JK z;cw}>r1W?^o2!5e?jEE2VX3eYp^%WMrMcp~MLMlk8d#Xt{)}BNBQ)H~BdzJ4^B6Xc znZ5)~A=Fa?HfWdq$OWF_6cL@u=#gF-bT_qLW(iyH+2b5$P* zVqn?*qp@s@&lvZLwx?m;S0bzb;aV|J?jAFk`#%6Srn<7uq=P@^K*^%+vs#@+?NmDcJeVS0V8x`*_ofSt?+XycU zil4r*HINA(7)*_vBWq}U z0FTT^jx9@b`{fHc(L!InQ#!u8Q_>?kd74~~N`C2)^?U=Tx#pkIR{^{m2AA_%b3p#) zvZaC@2OA_xgz3xYWFDbD5m{i@9zI+o0SAT zM@l?104h5~Vpwws`T!WV&%}cxixlSQFY!LcYrfLzyb|fXfUH05gKCaL(okb(FnY&x zceBXsoSer7S(GncUpyI15x)RR>XE{RkFrSnMH(g&-_3^z$!Q%U$o}mUg61l;{35{&UZ(b}Qv&ME;b_zzb&J4c&ybP0XKc8U}On%1Y zf^m`!RW3~to|9<{$kx!0I@naA+8LMR1RCyRi0AKZvt&yp;cLxd{V40;;-4y}C;byp zLCDv}(V(GHN1A0xzk9&Yz?h#}Vxqz0d40*=u6*CvDuOz^jQ9H#ViAbvoz z(u_|(;~UH>pw1h}Y-Ux{^^j&vo9sl@Xk6o`l0H6-oZ`9-u3Bgujz`Pl z6W9sJ^S>dC6ZfVf3w&XQF4t~?lWIq=>-xJ%8GgA(!u1@xU&;Lyk5Ak+JQCJG(NQt! z@tO4a2>6hZm)Ch7JUHoR^Vnv{U(X|4WYHE9zi8j_M%=B`f!b!x!W!N1+$RVmq0HU? z+?7KaT}pbz3D>EZ9c3p!uGya>C4u*i16cMVlE!%G)RJ13wZ&v~`jiy(MRK$Tt7Hke zd#X%JNt_mzUX%)jNKBi3?YEZTmxv(bVPu^kot7g&6LxjiPMVrBa&JYFRaxb+hq;Mc zzZ)xf3`q)q()mQ7@!CY01&-KA>h0HbADr&}h;qA5OhTxEA;!n+EE%dyvRuS)>9VmX>vsU-$NlZoQ5m>`+K?`ynadq#{&$ z>27(6M|mDe=6~*h6?Bhe=1tlyJ?E62u1H2&oj}k~f9lc5PH@^UiBaj3m zbnI$ymg`p4G;3#w|2cT5EqLzL)Kq;RWe-$IOIwGeTv*ffe)B(?>FGVbBxBw^vMoVC zjt6;$RA+ymD-a8b!~cOCH6ig{yO1lnlXe82E{y#jO}~R70f*v<>$iX#fM2d?Tw1M& zn&uF)r6ZjtwTRM3`DNtkh*p~Du=&LiiyVEb9F@qSgGoWu5VCSw!Ue}c3G_Bpc}1qE z%EV@vEf$)!!M^-_>=@^GRCW{h2nb|zFPk8dJq!EFxM8W&NT=24H4*Dz0v+ZS3L87k ze21nmA1juydzE?B=-F8>E~W3e4!bk&=kkd;Pi?BtrYOC%JgGGRqS#g?8_(%emAc}N z{6VhIM@E};+Ul8n5=AY1CfH+xbEC#pOKG9~b!qf$b!oHbrxlNsG2~fDZwF4h|9=vy zEt!oo+4MN-2c4*G2%&yi)kdYJCO3*?!s)M?UijdTnwbB*eT3`HBZA|8$#|bXp}u+W zaHOAKsGf1~pdlmI?B;q#jkoo0Fwj9(Hlnr(M^p2=8rtwI+65>i%o=P3vLMGRUM70& z0={f*glY`QvZCv{cGt}`S-3Oy75_nU+Tdl;(JP&i!w$9)sO`hpsh~%L62SKKRdB4~EzJ=T8PD=gn7w zzo?X3q*}QlO>!d%bWK=E_VP1hpmj zhe``tri}&O;b|Jp7HBlN^?P*Js@D`Pr^QvS9+&9XSk4GsfWUNR8U6GJV%(x7WVLb@|Tf+>lQne$c`5dO<@=;(P&_h_B^B=JjA&H#VwwMm1#hMZ~*(4SKxn(uo=() z(oB~kMrifcR(!4DA&*s3>0t0s-qB%_&H9`!{qEB{eQIz@K|rG>o<6UDbV#tmEG84?Cx=hWEX;`OSeTSSt(%{|GY9EcQA&c9sjIiG(UU|9AEuk#BsAJ_>4Jo$< z*=$%8v>8tSk3=iWD8UJL&Y-|00lZ3@lAt@@IO^{Rj1a&QH$`=)A-;DFsHC0WmOz3r zZbIF0sZe3!#DHWyz{ef8-R3_i=hr#=t_74lC^e0Vvlfti>$1_`_WZQ@_5-yv8p|GX zJ+AsJC40lA`^vG!7bWNrO%_UiA=o_x^aMN9nw~r;1-|$#S7lkLP|g2d$;0S%h@O$0 zZLzo^o6G>Ef)WU=!n+es`f03~;`Ab;sWG+yP=me+3| zWTgV^sV5~d=Vf9ULX-i%gf|CPx5|P)SS2!G(DU?G2=<*n-v$9@O^X}P@i@*=1}z72 zoeuSbjDGN9y?LXn>Q^$(N+mqW>s2?~<{FmBQ8nDZ!C$;?v$ z1k#jT5}UxPWA6(00oiUjk7HMpJfowT%cn~hgi3k~gI zj@7l&rRhbpLIWd}&*Y(r5VV`UOfx(?t{7I*sQLQ3#bEQZ?H4Q&6c9mFat;l0*Z8(Al(f!bcY4fA<_~e9Yc33B@NOIk^?hm z^tbo_>~qdOZ@9SN#WUY{?)6#sy(U&$Q-zp-jsO4v5UZ&^eF*?yqJIV0;NhTu3KQv@ z0{|=lwWsp0{IU+Sjo&k<_^hDP4b$46j{|5I2RLf@KzuLiZFneI)mJ*T2!=z>X3xr* zZB*=UR>iAIy(?19o)PuCNccQIB=1<20z|O@i6%sTG?Wd+vx}4mG&>fxRC8Zmnv5k+ zGI;6hzMgqhewOtf)h%#;NTd7=A0gZa^E{HfTrqNdWfivPttq4+*l_RaO|);(gD=;7 zQ8T;#_ywN+B68CVs(X=qHw1mGMuo@XWT%1!o+<+Q+w5fx)G5=lX{2 zrM~wM$fkd_m!V1=;8qux<~6++3)>UxRC`O{<^p8d<*Y0+tGBLRyw%V#&~y?xbROM% zSS1&7Q3*}*`Fgu=1~2iyOFn?}>}~kp%kN%cY$17b9=Vupo>t9!&z7`ocoXl>rT<|V`;_K+tIgo=UDcdnBt3Q0htg6GZ4jRSd9LRIdEl_kDgbhMkXaPj$ zRf&;@pMj9xt%5Dgg9uW&+f@pL}j zc`Z2r$Sw$w_o0jlFqZ~RXV#_z1T{D%vT>VVMfyn;&TBD`d%>I{{C035d9;tu?ci@qiOy6Q81w6 z4M5v2kPeV_41|sCk4uE>5!)je9@oIDh!^AGLO2Q7N5u*5HbxL5%y*_${aG|c3%{aX z8l#uGO?wzbFi@;LX1!{D9ludJakWw>?pMwM&&kF*V9C?4JRvPIt+-t2wYzPI%sQi) z7`m5U+swTHc1(?rzf-B6J~Vf}j~*WV$%R^q!isvk-}6O7SkL-9n}xMCW_oLswBQ~`s-`>{U@v+2J;P=y*-_>CPs9{u0K%eiP8CwT|fW63S+7}`b2N8h) zUSe)4%uho!d2xuUGi0fQ`RHwV$pW}N7$xMJ$dP8Q8#sFF=wg0#6Ighcj`VZ5$qLwG zTKUL~DqRxk6&avp)@V!4=)OI0y;%`Tn%wp3sC7NLxYSwQtqX)X-Oj#F{~~` z`(O=X1}PGNsECu9-Ok#d!uuxfWUEmCYe@x1*)F3zH<<9ZS1d%>kEQz++*oK41c!u~ zhptnJt)UlI2K!9#=2`ve3msWNi6L|e@wo_l?&o)ap0oKqhTi$LVAH+sg1(`4c}Xy` z{FlBYth6b3^ip3Io)(NOOERZYxkSa_!qP$n2Ndo6+JKFvv>o~{Hap9j+dr;O;N?wV znHb*O@`p~4v(iSKd(>ywJhbjoE>gg*H&;ECXP1|_gJ0iH7k9)nW8MNmH`UCkzoz0O zd1J4r2@x-|m^D53R*<|r%ZE!rGI|k+Z-d7wIDp0pUJ4LMY&yu!>E(a%C&frQtH|Mk-ILLxQ z{9R##=6{^mo37>*}nPLp}tYBA} zBsmqUm^u?+C*$G=0EPqj!W7 zo+04Fdz+X;#Fb?`l3*hE7?cWo2Vn<%&pI@(00xnJ+lr<#=C?Ui^ulQYbI>$mUith> zokpBE8q|iK95n&Z=*yg7Ye)BBBAi9JA)VWs0!9XqYzM#OY?6 zGv35HqWI`+=u-a=Tz8Y}^(fDpaCfkpkLD*t0!z~qp&meT=c|6hi%3mS4Qqi@gtkm~ zCN32B{EQG1yg{0N#%#HrZm17obpX2BKklVfq>75{#VM(U2;)fzEP}bskj2u!cI6>; zxT{{I`E&20VZ*stt&3_Ec8vFotr#=bbr)JVNR5`8!b^{-8(+k6$@J4-S{0?(Qs37B z462Kn((5sjR6j`Nt^xNmOzlgzeWUo*O3Ie25!ikp32$6ezc{Y6@-e=$2Z~yr00(F2 zx-G&^$k@e2@`pOAT_N;Hs9NQ#NBZ=(staZn*A&1CRzbw$Nc6- zzJNL_%!mdI#w1rKe+{imRf zB%xejd=qlrJx_|!)q`n)`i0-w+X_IqxA~GWze)fVSx3g);zCQaPC+bIO*U%-ACJv# zuqiV=0^pGeW>#O(N8aeV zL6rZ^+g>^?&uuNL#l;2qu9ubRmKHnwjYIi$#T>>>wlN5Lh2ikgJP_p=t^L)9M0E+2 zvbWBMqy40zY600mBAlhTbU7OwTE2iRn_XFv#41MLL8T?yARV2^xY?|57z=j7&=cg^ z&))~as(uUfg*`>Nf66(qXd3J5lZyhvihZGB%h_6?JNan@d=;ppL%AHH1WIKA&Vi)v zY`X=?yql*XhSoI%OKSGE|K|S+*oTy(bDqR#NEk4BQ*LZ@* z$B-)@A(%q>v=`%;MTO;=4AA~bn}T-6RIfY;OP(S53Clx#kn)bG6m6oi->n}Zx&%AF z14-FG$!AvuZPRAcKljQn9wjjy^U0yA9kMubMM_I2eDR|DWqHt^Ft3!*MM`Gg9{c)| z3Fn%j?24Y@X`nq8*}5uwO%sNb;j=EAnoJ@M^n+UYyp=dp%h~kmWGrO2$*4Ias^etE z9!~Z66Twf$Imq(qNhVc8B9UWS`Ai`M!bk!=W)^xHgti7%>X?qHJ^QCS=&w?|ULD~Q zpYB?8VRO#ACJSR(X52S3F~*E%YxO_xGqhh0JeD21F)}g<%PiLj2)tZFl1q(^HPV>BI?b{%f? z7_#57Qs!+gPWC*^Hn~ncpH)nGz4*!#K~1Lvp0it8J=vFxk^!JMQQ9)*v=jKp9;>}Q zDQ`&`zT!)sH(O=iziHgyv}a*gsdjal#i7zulZy!{rNc*m0!ago+`~iXKSxXFX4Yd% z5oY^-EoJ2A z{^Nc8>*dkR)uqb#m62~F>O{F0q6z5F&?7CUv6uhW-G4U>PKsE_HDuMS3z`dnth0G8f5RDP8Wk2|m8)hls;_8ywYlEsS%JL zA{d%gVMV>;3RsVCnAPpCni$OWyyG0rY{>d}o;6bG`x|LjJ51`0Om@x1AG+E;@bjQ^^&SpW9$jQDIB9^TeNcXMUBT|ClyC7up_7LN5^8jt(- zr#veJOP-&!PiS!kH)5PZhy>hq6wYV??$yQ|)f?W#r{(g`fu$&ZXLGYmd65G^U-DY@ z{BuD%vu)@#jyZU~t8wCnix2fO9|({K9cvia-z-RCXey~n(|_HH>;CRB}!5 zOCQA$OEd}fVDxK?d?lkos~m&D@-Z}i>9-mo=W0k%THVAcWzp%P?&mC(4x^MxM8E@C z7Q8SPy-@0=hg{>2P%|5rK`ylWOSMcsmRPjsyfRVq{BYq{?2+G^QGu+D_7-hT;Sg6# z*Q;$>Fp5%@iPb``rRqve;<*%ULaW<&kEddB8;K}@1<%S~HP+9WSRwgzYgFA!F_+6i zoWWW@m~gs&*hbY-r@Oql$;BS1jw6; zc?IEz`Pmteb?;d{O&n*=b2_jm=*6IM988f9W7QS*nfI=?AikX;foJ;S+!f0o@Nd1-N4`y#8uU?{WluF<+hJ95 zM2I!Tnf!SEX`s=K*JEmQiFV_&1ztN!{h;-h((lq5BImJS{4oczFx5&nuz%;6yOy>| z0{n)Yjlw~T7=N;i7Hrzp<(CXFML-J>?y(1X;YVl=o>OXWNk*E5)wxi(GqBq(juQXf z?J-Wh!u8!tv3FI+K#$+L(jtN&J8z`Uw_|=PIsgdkbZo%lK+N{N??r+7BuJSZkLsQK z)`qwejFODpv8N&Rvkwi8I7)W?4{6Fbb3ogIglf)bdiNgpt#`3%(@|3lQ(I)!lx3!! zeD;`bZ3ewj=tmlm@a#(rw~taD=KwX^MnjY}Wi{QwT(Hul^Era|EIpta23E@P3HTLz zi7On?H=d=mB86k%Fb%iD``+nE#T+@p6OzuR)F#lSm{zgw+n4`Wx%r9t4j(iuP-sCO zf8??d{m8X?JDLgM(Sm%5c!aJ>?hgL$+a$l;B+`#GVm(Pno2fR8o*G(ZSpF=hG zSH%sTm3y7&Yb;&rFEd*;`D=_xt5mHRW+OA+aPtm>P2{*pA)}BJwHwbf0E4Mqi%V|OA zf+Etcd{Pb%Ei5QA5~I#>h#p6d;6(H>STb~Dk=ZCvv!Nw{0xb#5%4kUt?fTJgtQ|Q9 zmh{&rAV%NklFkpeBZHIvh#Db{HdK>E+>0VUnG!HI`!T74JitbiQJQW z=SlusV&;LPGqF77+GwWun1anYRx9+Jv*%W7#6IHG@4#eIPw*En*5E0vgQ&+c=HJIh zyxh6ti`-6TT3hYRAA>4SO7lhk!vvAh!hNyPh_Eg44Cc!{0jHno2<%UT>@w-VS)bro zh#{di^Wi5}C7K812WOlh6wlnp=U98ZKi``}j+c5nk&dFX#9e5DBTdl=CwvKDL%$PL zi9h?-`8Z890o`}LQU5!_H12o3?F56r$8T!2hUPv`FESm02S0cI4W_lbv7^;!Cp#wj zdjJLx*ocIy{V@z;+=7=h8GZaQtO_+5xt)=$?aQ~+bNAGr+vL_=A_lhbCw?P2@WLbp zDWy<*ld>!iB&P`$yoXJ;!Jprl@J0T;7Of(QHcQS!U7Z29)?Q|Z4<>43dkhcLDLt+xu!5w|k`#Zbdc~^W7Ka0mKkY(g6 zryh1&WNR{^@qr-bU3ukHNa5T0BU9Dcs^|dbN0+pM$00Zu2c8yp90#J65+`pFGiR=^ zh$4d@OAg`G(8;S7ZkS^y*% zn&)1i(dA_8`AJ<}x(x6G&nNVO{(k%qS_spjHXhHm&EMTXrz@5*JM<2Q#4kuf%OoSn z>l7b2ZuT9pg!3W!iMQ0W7^(b8S3|dxz;b8Y#;{?p3|3nsV03?zOqcvQ?ld@} zUV0ki1+}70`$|x7uV3kSNK6rBBUAFEa6^qJilTMyw#S)%kWHs`bYrU1%t|ZJw`7%5D2b+y!Ep2@X3rNRuPrx(z$m!NIT0?Y(jdGy>-D*>lKGjh9N2}S)3L$5a#DCmXML2D8- zP>aMHX{#*wiHlJRj9xI2UXM@92>^sW2a&m|j+tjFUjouD98+MNFd&&feQEa-QAgQp0Fv--K`!-Rdi zx&@ztbFCTFN5%gv*fOR@7k8au^uikp4JWYd5y$D!u2pN9mMfgHD~8W)Ow}__DV0(l z|Dk~J7z&|<;w85i^8G(H+5T;32JPItD}MOLv)~_m@+sn2>p#gf2#*s`Q>0Do*utT; zlM8u;rAo&7R^Sxi)e^6+kHR!M<0N5%0{^sk+20Wxk*P>C_%~@UhfOj3a=Nx6oKCZ# zF=$N{us*L z`L&RFN#Lx@5{6R_j9)RMNI(TvHdXQveXz^vG`>d=ql-F|ot3_OEiYv9+mnZy)W3le z8?Scl^>=M3;!6YX_O{4M)W2v3j9%(~%vJh)XU;aexHUHQ8%YN#POl|zO5%m&m`V&j zQo%6aeHD<<5O{bNh7x*{o4H&MtKh&kfn!xfd=^ zgErn>%K#h((ONNM?tq(H$O*0w9^M)_m41ht7ZjrI4;T_ZdE1Ihh1Cuv{&?)0o-JZq zF{QO|QK#%ARjOsZjp-KZ=!mc{4H>8OyPiddoXLruc>1-?8>%D&aw{#9;9V9MDcPijX zGY^g8#<1EP+`e{3nEpT97Ncxb)dV%nD=I|JRy|caat8G($H7*lz2%JX^RUGDAN2}y zG=*Zh2fUr?MMl4fK>H2SgdejhPSAa~#gey$Xpf(SmnZ(rK|mso*SkhiTLMmWk%#1; zS5fAG10uq4qoq&}l#a6k{%y;UJ*TO@Hz%cN+Qi*S>3Wx0c9S_QU8d{9gE=9jhCP;% z!VqRfFT`plIEk6w!gJrPoCG@@kA#B)@v7MmTO(VF`YiM1$x{ z=J={lZjwNv^OXn6_B^qpE*Nr+(X-O#fQ4LOw=>?KiSgDY-bfMhTgmAO01R#aOsdY9PV+NB43~C=Fh6f(L1&LuqdCG4L10EYB8{Ya)D{I8U zVf{H)V?w7=4vf3YjaRE9(YS%7*^~yJvFVQiA#eP9OCKyXVG>10j({IaemNH$L_2b< zqyXbX4X@t)Y>fQ8!+5&0`oeWG=-ArrUa{n>R%rM_%C2I>63Mqe zb+aDZjKvRJD?;j?v%J1_f#vxTYCA)`mliQy($3LQZll9T!vpz}A@+{Vbqa&JwjTSn zv$8|oNbE|7K%JW4k0kJtW*G0gCifR6dHwq%o;rWDoVKpiPGugYNwfq{f-}$jrC950 z&ydeUaz8`Of06cX?OvY;T}UFvuWP_*`w8~K8s%Y&>*;fy`4U>?Opg@2LC<6$w8JKRLW8%=K15xf02m_ciMa! z8cpKNPX8tOP-;%y$dTbKlT(bsT`M@v}D^_rg9i;@rG^s}X zoV-tA%9z#f*T}!Qkzu(H1_0>S z{>}p6>mCtS*A!{UiyS&RP5JwfF{&ebx<8Qy#!zm69l~)*m=ucfe`6;VP3-LdM9T)5 z#eo%atqpKlwKY*~21~q4d6d*y6``x~tt4whUW_43;{Hw`71g~W!Z#cXpnDF(N45M| zV-9u1w+sZ+d|vT7S9&av)to_;ptE(uO&fNMBA2d`!_K*r|p#t4gwi-Ft|gEL7xR9mk)aaX@4pB^mJuU>eTn2{P5 z$8yKz8SGF-?A&Ld4YX%yHz_EXG=;tK>VAZ<&@dbIp$L%m#vXu1-cqG4UOEcl6t%P> z)&yP9v$tg_y% z<*xS<#woa~Svp_Nzw#JP#lD{3wws?-Q9Av&Y4?IGT+rjW7l|P8xak3(4!M#4CPB+9 zT%f3WeqDqZhTv10z{9JFCZwF-Hd(MqEM_ z+xNeNtSu2<9}~YkQ-xavb{E9mI#QVjhwyEwWyT$P_cFwk?N`NhU((#3sqmKQBGtA} zu1DiJze&Wkp1T zX=^)KW@WPQ|7yF4x8c8W=kv6uQVgsM`EQJ2jN3AMGa%0d`$6>A{F9h{*p_pS(~3eO z8gKidL|)BD`M>rrK>antkDc0pv7`K1MR;AHk&MBy>tS|zP;pl57+fFmAW?bl*RQTT zv=KY@#0wiOC~@hJ_f|Z zb?h-K?+Su9WPS*`XRuPBZ_YN2BWrC9N?~=nTz^dAvJ;ni^%p4=Pt*vK$~JnAWk8s5 zfoJoEK$1zC*!iEuq_&N+c!qW(cCtN5|LsZdmzP?}SqxVC6&NGAJU; zUxk&WQlR(Fu;Lu-n*`Bhzo@y6!?)p&utZ9C<2~Z2nSb7advy}9O=y7-v1lX?WinzI z_@20WbzkgLa~JE1lUdf+d$%y#ql^r9zvt|>I7^gh`&TVI<3~YR2DU7NFc+GvXaOxq zy!v9JJ0@aHz|5r$>vGv*aYb=4$s`A+!I!~;?mS|PCT{eTkAQ;%qsCL@DNSgzNdde~ z_n%tv=HY(j?mx=e7YZkwigxFv&FB@wrHx4P_`(7&)(eh$6M5vu+-(_USstC&&GL09 zS)(S}yLuJJ2<=fm7qCE0qHst;5^es_S@J!lq_vqJ=$#cPeCE|V!Gi9fA06A@Tn%Ft z{4PV-p|d2XE6&1UUGlx)?{n6zt%wIdYsW)R(_%zHxN;sVM##jt{OV)R%xak&xP8+9 zG-QmdZUWw!0E+1{{`T=ry_^V#IwN#$Dp1M%`R!~E3C179y;=^8zQHB*i8M^lg#w*#Lp4y6aC<+s+j_qGXlS6|a-5kXivYsKMbu5d z!1BR|Y!!=#53Pc_XM~+lPu5B0aQ-}6T?PZX9~I6iw>Y93iqrR@O0#vkG?d{8?#>9WhwQrG>) zWyu2@;|n1i4PtAIJz&M)F7p;ri(M=6w80+ALk;pEaYxJ!>x1p6hu3Kk!G;W`cC`1IFxzu+)~A z4|?Q{#yGdpncdLja?zG#`M>Zdx++LyAhs8t(B-~QQB3x?Ig#hLfTEcdJCiGK0sBK& z-mKz>ZTxD%dkWEWf8Z72PcY91$L=YdywT z&!cLx*%w=*5|8gW9y5MMt;$*)3rzjaAEp2ahyTn#yaMDzTHIkI4CXnfcjDoZWXV3? z0Vo&}F58;Bn&`(@74cNEQ3%Rs5pw3lzUwdjEiBP`ICrqpO}}^Tk#qsuiFGjSOK-TV zov@pKB4?pH2_Tk8`QuHrWy2i5t4ywR|)#9OB}QWM;KkHW}x< zApE4T0Y3>!;Xs)va`K|p$DcMF9T^pT;93nErI>I4?oP@8-(;{oNL~^{qxx{z|BW)S z1pgYZrbtVvsJZK`PeaPbh1&cuGKMg|gt|~o%vKBi4k>yIeL0MGV2mRajl~H_4Gc84 zw#UORjn*ED!V%`iELM)FJ}!G9b>iONx9>e$-%<4|%v=|`B*}(VqcN0<;@x_^_w|Hj zBwg|CyuTt|-Xzvy5N^)?NpeSB1i&+W#4^LBOTI61U+aH&WVVU$7PHvFxLp$2&_hIl z!e5-({)aR2Bb{6LwCJeFi2cBa%>D2T3(w_bEs@@0t=});_{pE;23qEW&<3kCF31qt zpvt4MKzmS6|BGhyI!E3F{W==j{p-mn@}uXD(8-yxjgU`E2seq2S1euEoC%N}Qskmw zI_K1cB{dj>$riQiWO2B+Qo!SCRei73k-0!yG(MRiVKf=~o|f2fxb*cv#YViXj~Of? zuKmKirhQ$}d^TL$R{5a_yZbwy{ymzJ_@&%!o=-YsCfQIyRvTS^lic_HchZqUR}bVD zS~0H`LTY@10qx1fNY15QH%=@3 zbduu8dpJ(f@ys7XV5J$`OA76wqpKA65CvM^$kB09k`6Op1k!mC^>iCWi?0>-2|Ys4 z)6^IFCd;bK$oxyUSz2>{vi{(l{zr4+@p=aYKd;x0lEB{R`66hscNs^cYoJCw_c-Yx zVb-_g_|hjBD<|=D$3ZcgL70{S59XJk5p>R$=Fo8UCGS6^0WlGf7;o`hNDb)O!VR`y zc5Y7Q=B=bkZw~j+WDxC7kfguArc!zQk_n&} zH!b*8d{9wHg?LAM8+H8&rN$J;ezC2F6-Ft*43poYldl+&o`|j?gKni|+JC0pIV5zt&95f0zcYdT4XpWJN;m4;Xv$vxOW>{-=cm zI)BSRzc#zg37u_b|Q^a^Png$?R&wRDxXO_tfwrj zUO3KJ--(Z?9>JnR>X&&-zpm@}2GU`Xl@}^(!<}xx*;PeaFLd7teoPO0y}5GZehNBe>7bRgnEIg)+~71SpOB7U#k7D*poP?aOPRWi2z^ z?w+%oCNZvktd5RFD-8&jer427Jm1D|bUDWo4bFkN^Ju{?Fa}QN-uO{q$&Y*~JckN> zInRCZhvWstKZD2R4X0_te!BLAiRNR^0-+g6PdGrtKjM+dDxJ21)Op4W@_Z)tx5D3t zPF^zSQJW$wbL%O_PY4)SX6M2I+7V$KQwrPIq~nOrP2{umXyl?&LeVx~C& z9uAkq#?WDAD3Oy>V^=>SP*WG3B~_O(76)UXCxC`~FUb}b7Iv0ea1F2@XG&;bz6{8Q zhLvFcBe(`p@~ZCfF5rjhB@z*snjL*DFGEIewSx3t&n5-@GUJU<(F7x)+cM0uJUMTa z#gM2i*CN>zKmmq$o=eTVQdi$Lh3>IsD4Kli2-MEd?WhQw^j zztI@R8tQw!^2pb6B$Y^oJaHW#lEHW~ zo@>=@wBTv#%4q^W_1X&DiOXEJ+x;LRnj1XV+&&2iFS_5V#l~%t*A{jRc2qp0UHSGT z;TJUaIy|}K52?iw3RCEo;LRE6u#T8$`2Z+`6BZ>sqj{mo1!KZ^hAB^IQV;Z=%yx7+ z+kfJ>i?rc13JT&K%jMmNrCH@A?^TQ&$#~}_-3NOR+3p4!sw@$FVs0=sV-`VTSSPf^ z{=D=>q-}C)*bZGCA6y1~12=zXRyuc(>Kq=2x3;|xv9)@;`Q;+sr{em?c5CM+P5)@j z{0i5?&GGvU*YW;8H-pSXMShTx+KBi9u1$c52Y-SsMes-F>lsvczE)FrI1ovaQ zm7JU3J*GcrZra!GIvIIR^Aa!L();@Sj*0Uk+wBA=^fkzcC6y8D?49x5?K9-J>Hja$ zRCK)MU+!%jW@t^D+xndfWe;9R`qS-Ubt?GT4I`(eCl>ZM#s#U{{2!?C&Vk8SZJz<> zS^<|W^$%o!P%gfvD?*cvyV$2^GyOxch^?Kw<9B1=(?D3I5V}nGKK#&xEeF+~ZDM_8 zL|9#yWJH)fCZr5=Ij0j_M)BE{#Ui_}Lfddt21Ty2NDsv7K^z@F@HBb`vb^QONyu5| z+{K)Vtni$@y95yg;Q>q^x+Sq(rRTUcqHW`MTUYScuONoG@OP5UNbXX=M_F7Btx#&Y z_kMj5Xuugj15QYL$^}GnJlmxy4T>51>M1QQ!O$3LUSzc(^kRzyqb;&+DcAaLaqkZ7 z5ghhpwpQ&PqiEt<@aNFLJFTBE4UNAL(?#3k=m`2E1EfL65qiUe{sS^vkn$h$wEZb> z;+$lB7SJFUz|%ENFbus}X)QblXHDItp&5_=!9P7qDiz0#prV#PoDP!sAtzXzNoZjm zP7g}3tdR7Y>LZ*18XIG_1Q9mgRKcXd7=5HE#1z#@@`~K5vQd*5mIHYe*zeR?c3RGe zwODlNC;SsmeTy(&^?1;vYMS24qKru51hgnK`2}6C7@xdnNordrc;=~_)Po$a^&hLb ztKE2n>J3QOz5nK5sX5Abx!;noj^C(<9#N?Vmr&CxsFFd1SqKv|rT>tnt>VHv#)hGQ z|1x*#QF!0K_KqW;jM0~vno?v#n~P&I{kvPvUf)jG?7|?@{?#+o3V)jikzy}I5u*Vp`yn-TqT!32n1&ro`jsh;LEhH=k zgYQuFn~bNFDO@lEPEV`rVd}dT$t|#5CE6^|epm6fA3<}m>HqW^F9Wl5^q_oUHDHtn ziCyhr$u9aE??rVuvJHiEF-p^Mo!%mjv0zu|a|HQMre!_dOgUU$!)&^P(8&Uw9{mTA zI4sTAH}nW!)b4(Oy_3-b@NMEZqQ@!bB!PV_|BXm?I6et<6Y%3Dsb&JM#V3=O*8Bs+eNQ3@Y+Ka z*aZMlLdwXcxvVem4 z13wC7H|{N5oG7@5JBG5k`CL0M{Ww{;Jqn~wesEf0ld!qkE86X2#!M>;FS7(=AB2q_!pkXG4F9eR|3X@6=Cf6C8> zl^-TX{!7T-c{(x!R$D3P0vFbmzdE?CvL(DF^UkxnKO@xg_m{*x&KhaMIu#hv1!maY zwm;JW1DA-2g#DI39bTF;$SyCRQk(t;MfLNO?nG=7b(1SZ7CDAAf z)sXmlKq2oC@6gA;x!BL80+ZKk=&7A2IhwW4LC07Z?P!@Q?`qGh`YK7d=9L56N5?1p zp656fwv$GnZtk#W(V|@ZY@3-Q8x_uzjY=r+jiFLRkKwY6308Dr zYLnSg6q_Z3DW)8~&i@E4n&37))@Ds(hSgG<%tyeb%-}W6Tnb&~ci601tJQ7n-OmuU1#*@3_Y7M!B z!Vbq_YhqXd_U z$_Tr~^yKxAP5hLX>wSszjc2F*9g&2U?z^3#I#la=!aaSY`p+knk1P%Tu;Q&}r`z2K z%K4BZE-%Fbu8x3uzywdIuA_3 zzvE)1QH!^}{Ics?2V|_m5k*}!5hED)Ibt>@F3hasKHbKi=8k8qZW|!)ABRe`Z6PIN z9z4z@n|pn|Nk`lkEw<7({1r{)_ZX@n;i|y@k-51nrwlU+Es^X?dfi|Xw4vCx!0rz6 zk=BY+&QE{-r?&HT8T~Q-N}VyvS(vy#evV~M{(rJfm~UDFJ|v__eh0!cK!nYHu9RT<&6n;}Bcv(3#e_jZ{M zjJE-r{6#w)4qskn!Fd@&bbmy28eUU7j`qjc`=s-fC-T=n+%r*Giov`}8+?w^Z}ov% zBm0k-?8RYAuJsm2NG^-&==#{KO}7qZ!u@R-?rAW6U{w>3v%Zik;w2Nz!sp&YGAk{4 z+(}v^1(jW)9W*{m;g7BHdl1li^M3Qbe)1;uJt#q;zimff9bp;=6j!>r2`DOodeDAO zA06I)!f%=zHJTcJcbz`9h;PbZEaDEvcpT3dD<;nj#+0}mNaD3LTcP-woJ@X|Z zc`?NEl_#gv&HMVTvWwUCayq|UJc7^LdG{nn z4bQ(Qxi?5&zDHcuAg+Z+Ygjd9{zg)GR!Vp@3xp+q0*|U<+fP??nHHGg9rAPNGV-E4 zUi}Vi{rv`j+l`8G`twecUcLe}0}mYt*jYFIu9Ev1bqH1fBbhr+@+oM~FM~;RB?`|B zP%Pqi@9%RBIR0i;VG^Ztu(i$(Ly8}#bDIR!dl2Y#8dR+~fkkh`PslA5npuOmIp5xK ocE0hK@hcZ)#Hx`3+(A>_`YBe!>rBz#{{^TiX+Et~un7CV0DwU62><{9 literal 0 HcmV?d00001 diff --git a/src/main/webapp/assets/images/header_banner_922.png b/src/main/webapp/assets/images/header_banner_922.png new file mode 100644 index 0000000000000000000000000000000000000000..afd967d5293ed7910f842d65a953bd4782d860e7 GIT binary patch literal 12300 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 + + + + + IBM i API References + + + + + + + +

+ + + + + \ No newline at end of file diff --git a/src/main/webapp/scalar.json b/src/main/webapp/scalar.json new file mode 100644 index 0000000..66aa8c3 --- /dev/null +++ b/src/main/webapp/scalar.json @@ -0,0 +1,4574 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "IBM i RSE API with REST4i Integration", + "description": "Enhanced IBM Remote System Explorer API with REST4i integration features. This API collection allows clients to work with IBM i system components including QSYS objects, IFS files, CL Commands, plus enhanced database operations, file operations, and PDF processing with improved response formats and connection pooling.", + "version": "1.0.7-rest4i", + "contact": { + "name": "API Support", + "url": "https://github.com/IBMiRSEAPI" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "/rseapi/api" + } + ], + "tags": [ + { + "name": "Administration Services", + "description": "Administration Services provide APIs that give information about RSE API and the runtime environment of the RSE API server. To use the APIs, the authenticated user must authenticate to localhost and have the administrator role or have *ALLOBJ special authority." + }, + { + "name": "CL Command Services", + "description": "CL Command Services provide APIs for running CL commands. " + }, + { + "name": "IFS Services", + "description": "Integrated File System (IFS) Services provide APIs for accessing objects in a way that is like personal computer and UNIX operating systems. This includes listing objects in directories, reading from files, and writing to files." + }, + { + "name": "QSYS Services", + "description": "QSYS Services provide APIs for accessing QSYS objects. " + }, + { + "name": "SQL Services", + "description": "SQL Services provide APIs associated with performing SQL operations. " + }, + { + "name": "Security Services", + "description": "Security Services provide APIs relating to security, such as the mangement of digital certificates and the retrieval of TLS system information. \n\nAll the digital certificate management APIs require the Digital Certificate Manager, option 34 of the IBM i licensed program (5761-SS1), be installed. In addition, the authenticated user must have the *ALLOBJ and *SECADM special authorities." + }, + { + "name": "Server Information Services", + "description": "Server Information Services provide APIs about RSE API. " + }, + { + "name": "Session Services", + "description": "Session Services provide APIs for authenticating a user and managing sessions that are tied to an authenticated user. The user must have a user profile on the IBM i server to be accessed. Once authenticated, a bearer token is returned and must be submitted on requests when invoking protected APIs in an HTTP authorization header." + }, + { + "name": "Database Services", + "description": "Enhanced database services with connection pooling and multiple output formats. Execute SQL queries with support for JSON, XML, CSV, HTML, and Excel output formats." + }, + { + "name": "File Operations Services", + "description": "Enhanced file operations services supporting both IFS and SMB file operations with improved error handling and response formatting." + }, + { + "name": "PDF Services", + "description": "PDF processing services for creating, manipulating, and extracting data from PDF documents on IBM i systems." + } + ], + "paths": { + "/v1/database/query": { + "post": { + "tags": [ + "Database Services" + ], + "summary": "Execute SQL query with multiple output formats", + "description": "Execute a SQL query against the IBM i database with support for multiple output formats including JSON, XML, CSV, HTML, and Excel.", + "operationId": "executeQuery", + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ], + "requestBody": { + "description": "The SQL query and formatting options", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseQueryRequest" + }, + "example": { + "sql": "SELECT * FROM QSYS2.SYSTABLES FETCH FIRST 10 ROWS ONLY", + "format": "json", + "treatWarningsAsErrors": false, + "alwaysReturnSQLStateInformation": false + } + } + } + }, + "responses": { + "200": { + "description": "SQL query executed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "success": true, + "data": { + "rowCount": 2, + "data": [ + { + "TABLE_SCHEMA": "QSYS2", + "TABLE_NAME": "SYSTABLES", + "TABLE_TYPE": "SYSTEM TABLE" + } + ] + }, + "message": "Query executed successfully", + "timestamp": "2024-01-15T10:30:00Z" + } + }, + "application/xml": { + "example": "trueQSYS2" + }, + "text/csv": { + "example": "TABLE_SCHEMA,TABLE_NAME,TABLE_TYPE\\n\"QSYS2\",\"SYSTABLES\",\"SYSTEM TABLE\"" + } + } + }, + "400": { + "description": "Bad request - invalid SQL or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Validation failed", + "timestamp": "2024-01-15T10:30:00Z", + "validationErrors": [ + { + "field": "sql", + "message": "SQL query is required" + } + ] + } + } + } + } + }, + "401": { + "description": "Unauthorized request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "Forbidden - dangerous SQL detected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "get": { + "tags": [ + "Database Services" + ], + "summary": "Execute SQL query via GET with multiple output formats", + "description": "Execute a SQL query via GET request with support for multiple output formats.", + "operationId": "executeQueryGet", + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ], + "parameters": [ + { + "name": "sql", + "in": "query", + "description": "The SQL query to execute", + "required": true, + "schema": { + "type": "string" + }, + "example": "SELECT * FROM QSYS2.SYSTABLES FETCH FIRST 10 ROWS ONLY" + }, + { + "name": "format", + "in": "query", + "description": "Output format: json, xml, csv, html, excel", + "schema": { + "type": "string", + "enum": [ + "json", + "xml", + "csv", + "html", + "excel" + ], + "default": "json" + } + } + ], + "responses": { + "200": { + "description": "SQL query executed successfully" + }, + "400": { + "description": "Bad request - invalid SQL or parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "Forbidden - dangerous SQL detected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/files/list": { + "get": { + "tags": [ + "File Operations Services" + ], + "summary": "List directory contents", + "description": "List files and directories at specified path using SMB/CIFS or IFS", + "operationId": "listFiles", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "description": "Directory path to list", + "schema": { + "type": "string" + }, + "example": "/home/user/documents" + } + ], + "responses": { + "200": { + "description": "Directory listing successful", + "content": { + "application/json": { + "example": { + "success": true, + "data": { + "path": "/home/user/documents", + "files": [ + { + "name": "report.pdf", + "size": 102400, + "isDirectory": false + }, + { + "name": "archive", + "size": 0, + "isDirectory": true + } + ] + } + } + } + } + } + } + } + }, + "/v1/files/download": { + "get": { + "tags": [ + "File Operations Services" + ], + "summary": "Download a file", + "description": "Downloads a file from the specified SMB path.", + "operationId": "downloadFile", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "description": "The SMB path to the file", + "schema": { + "type": "string" + } + }, + { + "name": "domain", + "in": "query", + "description": "SMB domain", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "description": "SMB username", + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "SMB password", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File downloaded successfully", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request - path parameter required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "File not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/files/upload": { + "post": { + "tags": [ + "File Operations Services" + ], + "summary": "Upload a file", + "description": "Uploads a file to the specified SMB path.", + "operationId": "uploadFile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadRequest" + } + } + } + }, + "responses": { + "200": { + "description": "File uploaded successfully", + "content": { + "application/json": { + "example": { + "success": true, + "message": "File uploaded successfully", + "folder": "/upload/path", + "filename": "document.pdf" + } + } + } + }, + "400": { + "description": "Bad request - missing required parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/files/zip": { + "get": { + "tags": [ + "File Operations Services" + ], + "summary": "Zip directory contents", + "description": "Creates a ZIP archive of all files in the specified directory.", + "operationId": "zipDirectory", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "description": "The SMB path to the directory", + "schema": { + "type": "string" + } + }, + { + "name": "domain", + "in": "query", + "description": "SMB domain", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "description": "SMB username", + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "SMB password", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Directory zipped successfully", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request - path parameter required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "Directory not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/files/exists": { + "get": { + "tags": [ + "File Operations Services" + ], + "summary": "Check if file or directory exists", + "description": "Checks whether a file or directory exists at the specified SMB path.", + "operationId": "checkExists", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "description": "The SMB path to check", + "schema": { + "type": "string" + } + }, + { + "name": "domain", + "in": "query", + "description": "SMB domain", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "description": "SMB username", + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "SMB password", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Existence check completed", + "content": { + "application/json": { + "example": { + "success": true, + "path": "/path/to/check", + "exists": true + } + } + } + }, + "400": { + "description": "Bad request - path parameter required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/pdf/convert": { + "post": { + "tags": [ + "PDF Services" + ], + "summary": "Convert HTML to PDF", + "description": "Convert HTML content to PDF document", + "operationId": "convertToPdf", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConvertRequest" + }, + "example": { + "html": "

Report

Content here

", + "size": "A4", + "orientation": "portrait" + } + } + } + }, + "responses": { + "200": { + "description": "PDF generated successfully", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/v1/pdf/merge": { + "post": { + "tags": [ + "PDF Services" + ], + "summary": "Merge PDF files", + "description": "Merges multiple PDF files into a single PDF document.", + "operationId": "mergePdfs", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "PDFs merged successfully", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request - at least one PDF file is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/pdf/rotate": { + "post": { + "tags": [ + "PDF Services" + ], + "summary": "Rotate PDF pages", + "description": "Rotates all pages in a PDF document by the specified angle.", + "operationId": "rotatePdf", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "PDF rotated successfully", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request - PDF file is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/pdf/unprotect": { + "post": { + "tags": [ + "PDF Services" + ], + "summary": "Remove PDF protection", + "description": "Removes protection from a PDF document by converting it to images and back to PDF.", + "operationId": "unprotectPdf", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnprotectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "PDF unprotected successfully", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request - PDF file is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/v1/admin/memory": { + "get": { + "tags": [ + "Administration Services" + ], + "summary": "Get information about server memory usage. ", + "description": "Get information about the JVM memory usage of the server running RSE API. ", + "operationId": "adminGetMemoryUsage", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"jvmFreeMemory\": 17428920,\n \"jvmMaxMemory\": 4294967296,\n \"jvmTotalMemory\": 78249984\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/admin/settings": { + "get": { + "tags": [ + "Administration Services" + ], + "summary": "Get the general settings being used for RSE API. ", + "description": "The settings are global in nature and include settings for tuning, environment, and administrator override categories. ", + "operationId": "adminGetSettings", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"adminUsers\": [\"USER1\",\"USER2\"],\n \"includeUsers\": [\"USER1\",\"USER2\"],\n \"excludeUsers\": [],\n \"maxFileSize\": 3072000,\n \"maxSessionInactivity\": 7200,\n \"maxSessionLifetime\": 21600,\n \"maxSessionUseCount\": 1000,\n \"maxSessionWaitTime\": 300,\n \"maxSessions\": 100,\n \"maxSessionsPerUser\": 20,\n \"sessionCleanupInterval\": 300\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + }, + "post": { + "tags": [ + "Administration Services" + ], + "summary": "Set settings for RSE API. ", + "description": "The settings are global in nature and include settings for tuning, environment, and administrator override categories. ", + "operationId": "adminSetSettings", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The settings for RSE API.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_Settings" + }, + "example": "{\n \"persist\": false,\n \"adminUsers\": [\"USER1\",\"USER2\"],\n \"includeUsers\": [\"USER1\",\"USER2\"],\n \"excludeUsers\": [],\n \"maxFileSize\": 3072000,\n \"maxSessionInactivity\": 7200,\n \"maxSessionLifetime\": 21600,\n \"maxSessionUseCount\": 1000,\n \"maxSessionWaitTime\": 300,\n \"maxSessions\": 100,\n \"maxSessionsPerUser\": 20,\n \"sessionCleanupInterval\": 300\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful request, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/admin/environment": { + "get": { + "tags": [ + "Administration Services" + ], + "summary": "Get information about server environment. ", + "description": "Get information about server environment, such as host, operating system, Java version, and port. ", + "operationId": "adminGetEnvironment", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"rseapiBasepath\": \"rseapi\",\n \"rseapiHostname\": \"UT30P44\",\n \"rseapiPort\": 2012,\n \"rseapiVersion\": \"1.0.6\",\n \"osName\": \"OS/400\",\n \"osVersion\": \"V7R5M0\",\n \"javaVersion\": \"1.8.0_351\"\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/admin/sessions": { + "get": { + "tags": [ + "Administration Services" + ], + "summary": "Get information about sessions. ", + "description": "Get information about sessions. The information that is returned applies to active sessions on the server. Active sessions may include sessions that are expired but have not been reclaimed by RSE API.", + "operationId": "adminGetSessions", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"totalSessions\": 3,\n \"sessions\": [\n {\n \"userid\": \"USER1\",\n \"sessionCount\": 1\n },\n {\n \"userid\": \"USER2\",\n \"sessionCount\": 2\n }\n ]\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + }, + "delete": { + "tags": [ + "Administration Services" + ], + "summary": "Delete sessions.", + "description": "Delete sessions. You can delete all active sessions or only sessions tied to a user ID. Sessions that are deleted are marked as expired.", + "operationId": "adminClearSessions", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "user", + "in": "query", + "description": "The session(s) to delete. Specify the user ID to delete all sessions created by the user, or the special value of \\*ALL to delete all sessions for all users. ", + "schema": { + "type": "string", + "default": "*ALL" + } + } + ], + "responses": { + "204": { + "description": "Successful request, no content." + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/cl": { + "put": { + "tags": [ + "CL Command Services" + ], + "summary": "Run one or more CL commands on the server.", + "description": "Run one or more CL commands on the server. If a command fails, any messages relating to the error is returned. If the command succeeds, no data is returned. By default, a response payload will be returned if an error occurs that will include the \nfirst level message text. If you want the full message details, set the includeMessageHelpText property to true. If you \nwant messages returned in all cases, set the includeMessageOnSuccess property to true.", + "operationId": "clRunCLCommand", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "List of CL commands to run. If more than one CL command is to be run, you can indicate whether all commands should be run even if an error is encountered while running a CL command.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_CLCommands" + }, + "example": "{\n \"continueOnError\": true,\n \"includeMessageOnSuccess\": true,\n \"includeMessageHelpText\": false,\n \"clCommands\": [\n \"qsys/crtlib lib1\",\n \"qsys/crtsrcpf lib1/qrpglesrc\"\n ]\n}" + } + } + }, + "responses": { + "200": { + "description": "Command(s) issued successfully, error(s) may have occurred.", + "content": { + "application/json": { + "example": "{\n \"totalIssued\": 2,\n \"totalSuccesses\": 1,\n \"totalFailures\": 1,\n \"commandOutputList\": [\n {\n \"success\": false,\n \"command\": \"qsys/crtlib lib1\",\n \"output\": [\n \"CPF2111: Library LIB1 already exists. \"\n ]\n },\n {\n \"success\": true,\n \"command\": \"qsys/crtsrcpf lib1/qrpglesrc\",\n \"output\": [\n \"CPC7301: File QRPGLESRC created in library LIB1. \"\n ]\n }\n ]\n}" + } + } + }, + "204": { + "description": "All command(s) issued successfully, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/cl/{commandname}": { + "get": { + "tags": [ + "CL Command Services" + ], + "summary": "Retrieves the command definition for the specified CL command. ", + "description": "Retrieves the command definition for the specified CL command. The command definition is returned as an XML document. The generated command information XML source is called Command Definition Markup Language or CDML. See the Document Type Definition (DTD) in /QIBM/XML/DTD/QcdCLCmd.dtd for the definition of the CDML tag language returned by this API.", + "operationId": "clGetCommandDefinition", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "library", + "in": "query", + "description": "The library name. Valid values are a specific name, or one of the following special values: \n- \\*CURLIB - The current library is searched. \n- \\*LIBL - The library list is searched. This is the default.", + "schema": { + "type": "string", + "default": "*LIBL" + } + }, + { + "name": "ignorecase", + "in": "query", + "description": "Boolean indicating whether case should be ignored. The default value is 'true'. If set to 'false', the library and command name will not be uppercased.", + "schema": { + "type": "boolean", + "default": "true" + } + }, + { + "name": "commandname", + "in": "path", + "description": "The CL command name.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"definition\": \"\"\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/ifs/{path}": { + "get": { + "tags": [ + "IFS Services" + ], + "summary": "Get the content of a file.", + "description": "Get the content of a file. The content is returned in a JSON object. ", + "operationId": "ifsGetFileContent", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "ETag", + "in": "header", + "description": "Whether to return checksum for the file.", + "schema": { + "type": "boolean" + } + }, + { + "name": "path", + "in": "path", + "description": "Path to file for which the data is to be read.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"ccsid\": 819,\n \"content\": \"Hello world\\n\"\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + }, + "put": { + "tags": [ + "IFS Services" + ], + "summary": "Write a string to a file.", + "description": "Write a string to a file. The file must exist and its contents will be replaced by the string specified in the request.", + "operationId": "ifsPutFileContent", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "If the If-Match HTTP header is passed, RSE API will check to see if the Etag (MD5 hash of the object content) matches the provided Etag value. If this value matches, the operation will proceed. If the match fails, the system will return a 412 (Precondition Failed) error.", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "path", + "description": "Path to file in which the data will be written.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "File content.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_FileContent" + }, + "example": "{\n \"content\": \"some data that will be written to file.\"\n}" + }, + "text/plain": { + "schema": {}, + "example": "some data that will be written to file.\n" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful request, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "412": { + "description": "Precondition failed." + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/ifs/list": { + "get": { + "tags": [ + "IFS Services" + ], + "summary": "Gets a list of objects in the specified path.", + "description": "Gets a list of objects in the specified path. The information returned includes the name, whether object is a directory, the description, and the object subtype. For objects that are not in the QSYS.LIB file system, any part of the path may contain an asterisk (\\*) , which is a wildcard that means zero or more instances of any character. For example, a path of '/tmp/am\\*1.txt' will return all objects in directory '/tmp' that have names that begin with 'am' and end with '1.txt'. For QSYS.LIB objects, only generic names may be specified in any part of the path. A generic name is a character string that contains one or more characters followed by an asterisk. For example, '/qsys.lib/am\\*.lib' will return all libraries that have names that start with 'am'. Another example is \u2018/qsys.lib/am\\*.\\*\u2019, which would return all objects that start with \u2018am\u2019. This would be equivalent to specifying a path of \u2018/qsys.lib/am\\*'.", + "operationId": "ifsListDir", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "description": "The working directory. For example, /u/IBM/test", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "subtype", + "in": "query", + "description": "Subtype of objects to return. Valid values include a specific object type (*LIB, *FILE, *PGM, *OUTQ, etc.) or *ALL. Note that many file system objects do not have a subtype. For example, any Root, QOpenSys or UDFS object. ", + "schema": { + "type": "string" + } + }, + { + "name": "includehidden", + "in": "query", + "description": "Whether to show hidden files.", + "schema": { + "type": "boolean", + "default": "false" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"objects\": [\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCLSRC.FILE\",\n \"description\": \"\\\"test\\\"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QRPGLESRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QSQDSRC.FILE\",\n \"description\": \"SQL PROCEDURES\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QSRVSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QWOBJ.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QAUDJR0043.JRNRCV\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"\"\n }\n ]\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/ifs/{path}/info": { + "get": { + "tags": [ + "IFS Services" + ], + "summary": "Returns information about the object referenced by the path.", + "description": "Returns information about the object referenced by the path. The information returned includes the name, whether object is a directory, the description, the object subtype, CCSID, size, last modified timestamp, and object subtype.", + "operationId": "ifsGetFileInfo", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "path", + "description": "Path to object for which information is to be returned.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"path\": \"/qsys.lib/user1.lib\",\n \"description\": \"user1's lib\",\n \"isDir\": true,\n \"subType\": \"PROD\",\n \"owner\": \"USER1\",\n \"ccsid\": 37,\n \"lastModified\": 1680559633,\n \"size\": 401408,\n \"recordLength\": -1,\n \"numberOfRecords\": -1\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/qsys/search/{objectName}": { + "get": { + "tags": [ + "QSYS Services" + ], + "summary": "Returns a list of QSYS.LIB objects that match the search criteria.", + "description": "Returns a list of QSYS.LIB objects that match the search criteria. The filter is the object name, and may be a value of \\*ALL, in which case all objects that match the search criteria is returned, or a generic name. A generic name is a character string that contains one or more characters followed by an asterisk (\\*). If a generic name is specified, all objects that have names with the same prefix as the generic name are returned.", + "operationId": "qsysGetQsysObjects", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "objectLibrary", + "in": "query", + "description": "The library or set of libraries that are searched for objects. Valid values are a specific name, or one of the following special values: \n- \\*ALL - All libraries are searched. \n- \\*ALLUSR - All user libraries are searched. \n- \\*CURLIB - The current library is searched. \n- \\*LIBL - The library list is searched. \n- \\*USRLIBL - The user portion of the library list is searched. ", + "schema": { + "type": "string", + "default": "*USRLIBL" + } + }, + { + "name": "objectType", + "in": "query", + "description": "The type of object to search. Valid values include: \\*FILE, \\*PGM, \\*LIB, etc., or \\*ALL. ", + "schema": { + "type": "string", + "default": "*ALL" + } + }, + { + "name": "objectSubtype", + "in": "query", + "description": "Object subtype. For example, PF-SRC, PF-DTA, SAVF, etc., or \\*ALL. Note that not all objects have subtypes.", + "schema": { + "type": "string", + "default": "*ALL" + } + }, + { + "name": "memberName", + "in": "query", + "description": "The member name to match for objects of type PF-SRC or PF-DTA. Valid values are a specific name, or an extended generic name where the asterisk (\\*) may be placed in any part of the name, or \\*ALL. Note that members are not searched for unless the object subtype that starts with the prefix PF. For example, PF, or PF-SRC.", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "memberType", + "in": "query", + "description": "The member type to match for members of objects of type PF-SRC or PF-DTA. Valid values are a specific name, such as C, CBLLE, RPGLE, SQLRPGLE, etc., or \\*ALL. ", + "schema": { + "type": "string", + "default": "*ALL" + } + }, + { + "name": "objectName", + "in": "path", + "description": "The object name. Valid values are a specific name, a generic name (for example, AM\\*), or one of the following special values: \n- \\*ALL - All object names are searched. \n- \\*ALLUSR - All objects that are libraries in QSYS or the library list are searched. The object library must either be \\*ALL, \\*LIBL or QSYS. The object type must be \\*LIB. A list of user libraries is returned. \n", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"objects\": [\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/AXISLIBS.FILE\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"SAVF\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/DEALER.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/FLGHT400.FILE\",\n \"description\": \"\",\n \"isDir\": false,\n \"subType\": \"SAVF\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/IWSDB1.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-DTA\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCLSRC.FILE\",\n \"description\": \"\\\"test\\\"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n },\n {\n \"path\": \"/QSYS.LIB/USER1.LIB/QCSRC.FILE\",\n \"description\": \"\",\n \"isDir\": true,\n \"subType\": \"PF-SRC\"\n }\n ]\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/sql": { + "put": { + "tags": [ + "SQL Services" + ], + "summary": "Run a SQL statement on the server.", + "description": "Run a SQL statement on the server. If SQL statement fails, any messages relating to the error is returned. \n\nSQL state information is returned only if errors are detected. You have the option of indicating whether the state information is returned on all responses. By default, if a SQL statement is run successfully and there is no result set to return, no data is returned in the response.", + "operationId": "runSQLStatements", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The SQL statement to be run on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_SQLRequest" + }, + "example": "{\n \"alwaysReturnSQLStateInformation\": false,\n \"treatWarningsAsErrors\": false,\n \"sqlStatement\": \"select * from QIWS.QCUSTCDT\"\n}" + } + }, + "required": true + }, + "responses": { + "200": { + "description": "SQL statements(s) issued.", + "content": { + "application/json": { + "example": "{\n \"resultSet\": [\n {\n \"CUSNUM\": 938472,\n \"LSTNAM\": \"Henning\",\n \"INIT\": \"G K\",\n \"STREET\": \"4859 Elm Ave\",\n \"CITY\": \"Dallas\",\n \"STATE\": \"TX\",\n \"ZIPCOD\": 75217,\n \"CDTLMT\": 5000,\n \"CHGCOD\": 3,\n \"BALDUE\": 37,\n \"CDTDUE\": 0\n },\n {\n \"CUSNUM\": 839283,\n \"LSTNAM\": \"Jones\",\n \"INIT\": \"B D\",\n \"STREET\": \"21B NW 135 St\",\n \"CITY\": \"Clay\",\n \"STATE\": \"NY\",\n \"ZIPCOD\": 13041,\n \"CDTLMT\": 400,\n \"CHGCOD\": 1,\n \"BALDUE\": 100,\n \"CDTDUE\": 0\n }\n ]\n}" + } + } + }, + "204": { + "description": "SQL statement(s) issued successfully, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/cert/delete": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Delete a digital certificate.", + "description": "Delete a digital certificate.", + "operationId": "securityDCMDeleteCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to delete a digital certificate.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certAlias\": \"mylabel\"\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/tls": { + "get": { + "tags": [ + "Security Services" + ], + "summary": "Retrieve system transport layer security (TLS) attributes.", + "description": "The API retrieves TLS attributes for the system. The system level settings are based on TLS System Values and System Service Tools (SST) Advanced Analysis command TLSCONFIG that allows viewing or altering of system-wide system TLS default properties.", + "operationId": "securityTLSGetAttributes", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"supportedProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ],\n \"eligibleDefaultProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ], \"defaultProtocols\": [\n \"TLSv1.3\",\n \"TLSv1.2\"\n ], \"supportedCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"eligibleDefaultCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"defaultCipherSuites\": [\n \"AES_128_GCM_SHA256\",\n \"AES_256_GCM_SHA384\",\n \"CHACHA20_POLY1305_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_RSA_WITH_AES_128_GCM_SHA256\",\n \"ECDHE_RSA_WITH_AES_256_GCM_SHA384\",\n \"ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256\",\n \"ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256\"\n ], \"supportedSignatureAlgorithms\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"defaultSignatureAlgorithms\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"supportedSignatureAlgorithmCertificates\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"ECDSA_SHA224\",\n \"ECDSA_SHA1\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\",\n \"RSA_SHA224\",\n \"RSA_SHA1\",\n \"RSA_MD5\"\n ], \"defaultSignatureAlgorithmCertificates\": [\n \"ECDSA_SHA512\",\n \"ECDSA_SHA384\",\n \"ECDSA_SHA256\",\n \"RSA_PSS_SHA512\",\n \"RSA_PSS_SHA384\",\n \"RSA_PSS_SHA256\",\n \"RSA_SHA512\",\n \"RSA_SHA384\",\n \"RSA_SHA256\"\n ], \"supportedNamedCurves\": [\n \"x25519\",\n \"x448\",\n \"Secp256r1\",\n \"Secp384r1\",\n \"Secp521r1\"\n ], \"defaultNamedCurves\": [\n \"Secp256r1\",\n \"Secp384r1\",\n \"x25519\",\n \"Secp521r1\",\n \"x448\"\n ], \"defaultMinimumRSAKeySize\": 0, \"handshakeConnectionCounts\": false, \"secureSessionCaching\": true, \"auditSecureTelnetHandshakes\": false}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/cert/export": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Export a digital certificate.", + "description": "Export a digital certificate. Only server/client and CA certificates can be exported. Certificates can be exported in the DER, PEM, or PKCS12 formats. A server/client or CA certificate that is to include the private key must be exported in the PKCS12 format. CA certificates without private keys cannot be exported in the PKCS12 format. \n\nWhen exporting certificates in the PKCS12 format, a password for the exported certificate must be specified. \n\nThe certificate data in the response will be encoded in Base64, even for certificate data returned in the PEM format. ", + "operationId": "securityDCMExportCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to export a digital certificate.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertExportRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certFormat\": \"PKCS12\",\n \"certAlias\": \"mylabel\",\n \"certDataPassword\": \"myPassw0rd\"\n}" + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n\"certFormat\": \"PKCS12\",\n \"certData\": \"BASE64-BLOB\"\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/cert/info": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Get detailed certificate information.", + "description": "Get detailed certificate information, such as subject, issuer, subject alternative names, and serial number.", + "operationId": "securityDCMGetCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to get detailed information about a digital certificate.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certAlias\": \"mylabel\"\n}" + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"alias\": \"UNIQUE-SAN\",\n \"trusted\": true, \"subject\": \"C=US,SP=Minnesota,O=IBM,CN=UniQue\", \"issuer\": \"C=US,SP=Any,O=IBM Web Administration for i,CN=mysystem_CERTIFICATE_AUTHORITY\", \"keyAlgorithm\": \"ECDSA\", \"keySize\": 256, \"hasPrivateKey\": true, \"signatureAlgorithm\": \"RSA_SHA256\", \"keyStorageLocation\": \"SOFTWARE\", \"serialNumber\": \"6526CFBC0601B8\", \"effectiveDate\": \"10/10/23 11:39:24\", \"expirationDate\": \"10/10/24 11:39:24\", \"subjectAlternativeNames\": [\n \"booboo.ibm.com\",\n \"9.9.9.9\"\n ],\n \"keyUsages\": [\n \"DIGITAL_SIGNATURE\",\n \"NONREPUDIATION\",\n \"KEY_ENCIPHERMENT\",\n \"KEY_AGREEMENT\"\n ]\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/appdef/associate": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Associate digital certificates to an application definition.", + "description": "Associate digital certificates to an application definition. A maximum of 4 certificates can be specified. \n\nOn successful completion, the specified certificates will replace any pre-existing certificates associated with the application definition.", + "operationId": "securityDCMAppDefAssociateCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to assign digital certificates to an application definition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertAppDefAssociateRequest" + }, + "example": "{\n \"appDefinitionID\": \"myappdef\",\n \"certAliases\": [\"mylabel1\",\"mylabel2\"]\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/appdef/list": { + "get": { + "tags": [ + "Security Services" + ], + "summary": "List application definitions.", + "description": "Retrieve a list of application definitions. You can filter what is returned by application definition ID and application type. When filtering by application definition ID, you can specify a generic ID. A generic ID is a character string that contains one or more characters followed by an asterisk (\\*). If a generic ID is specified, all application definition IDs that have an ID with the same prefix as the generic ID are returned. ", + "operationId": "securityDCMAppidList", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "idFilter", + "in": "query", + "description": "Application definition ID filter. ", + "schema": { + "type": "string" + } + }, + { + "name": "typeFilter", + "in": "query", + "description": "Application type filter. Possible values: SERVER, CLIENT, SERVER_CLIENT, OBJECT_SIGNING. If not specifed, all server and client application definitions are returned.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"appDefinitions\": [\n {\n \"appDefinitionID\": \"QIBM_OS400_QRW_SVR_DDM_DRDA\",\n \"appType\": \"SERVER\",\n \"description\": \"IBM i DDM/DRDA Server - TCP/IP\",\n \"certAliases\": [ ]\n }\n ]\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/appdef/untrust": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Remove a certificate authority (CA) digital certificate from the application definition CA trust list.", + "description": "Remove a certificate authority (CA) digital certificate from the application definition CA trust list. ", + "operationId": "securityDCMAppDefUntrustCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to remove a CA digital certificate from an application definition CA trust list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertAppDefTrustRequest" + }, + "example": "{\n \"appDefinitionID\": \"myappdef\",\n \"certAlias\": \"mylabel1\"\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/appdef/disassociate": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Disassociate digital certificates from an application definition.", + "description": "Disassociate digital certificates from an application definition. All certificates associated with the application definition will be disassociated.", + "operationId": "securityDCMAppDefDisassociateCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to disassociate digital certificates from an application definition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertAppDefDisassociateRequest" + }, + "example": "{\n \"appDefinitionID\": \"myappdef\"\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/cert/list": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Retrieve a list of certificates in a certificate store.", + "description": "Retrieve a list of certificates in a certificate store. You can filter what is returned by alias, certificate type, days until expiration, and whether to include expired certificates. When filtering by alias, you can specify a generic alias for alias. A generic alias is a character string that contains one or more characters followed by an asterisk (\\*). If a generic alias is specified, all certificates that have an alias with the same prefix as the generic alias are returned. Note that if the daysUntilExpiration filter is specified, CSR certificates will not be returned in the response since CSR certificates do not expire.", + "operationId": "securityDCMListCertificates", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to list certificates in a certificate store.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertListRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"filters\": { \n \"certAlias\": \"*\",\n \"certTypes\": [\"SERVER_CLIENT\", \"CA\", \"CSR\"],\n \"daysUntilExpiration\": 5000,\n \"excludeExpired\": false\n }}" + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"certificates\": [\n {\n \"certAlias\": \"GLOBAL-MULTISAN\",\n \"commonName\": \"myserver.ibm.com\",\n \"type\": \"SERVER_CLIENT\",\n \"daysBeforeExpiration\": 1831,\n \"keyAlgorithm\": \"ECDSA\",\n \"keySize\": 256,\n \"keyStorageLocation\": \"SOFTWARE\",\n \"signatureAlgorithm\": \"ECDSA_SHA256\"\n },\n {\n \"certAlias\": \"LOCAL_CERTIFICATE_AUTHORITY_106F947K(5)\",\n \"commonName\": \"Local CA for myserver on July 17\",\n \"type\": \"CA\",\n \"daysBeforeExpiration\": 6807,\n \"keyAlgorithm\": \"ECDSA\",\n \"keySize\": 256,\n \"signatureAlgorithm\": \"ECDSA_SHA256\"\n }\n ]\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/certstore/changepassword": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Change digital certificate store password.", + "description": "Change digital certificate store password. For system certificate stores of type CMS, if the current password is omitted, the system stash file will be used.", + "operationId": "securityDCMChangeCertificateStorePassword", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to change a digital certificate store password.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertStoreChangePasswordRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": null,\n \"certStorePasswordNew\": \"myNewPassw0rd\",\n \"daysToExpiration\": 0\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/appdef/trust": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Add certificate authority (CA) digital certificate to the application definition CA trust list.", + "description": "Add a CA certificate to the application definition CA trust list. ", + "operationId": "securityDCMAppDefTrustCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to add an CA to the application definition CA trust list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertAppDefTrustRequest" + }, + "example": "{\n \"appDefinitionID\": \"myappdef\",\n \"certAlias\": \"mylabel1\"\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/dcm/cert/import": { + "post": { + "tags": [ + "Security Services" + ], + "summary": "Import a digital certificate.", + "description": "Import a digital certificate. Only server/client or CA certificates can be imported. A certificate can be imported in the following formats: PKCS12, DER, or PEM. If the certificate to be imported includes a private key, then the PKCS12 format must be used. \n\nIf importing a CA certificate and the certificate includes a private key, the PKCS12 format must be used and the certificate type must be set to SERVER_CLIENT. When importing CA certificate that do not contain a private key, the PEM or DER format must be used. \n\nThe certificate data in the request must be encoded in Base64, which includes certificate data in the PEM format. ", + "operationId": "securityDCMImportCertificate", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The API properties required to import a digital certificate. ", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_DCMCertImportRequest" + }, + "example": "{\n \"certStoreType\": \"CMS\",\n \"certStorePath\": \"*SYSTEM\",\n \"certStorePassword\": \"passw0rd\",\n \"certType\": \"SERVER_CLIENT\",\n \"certFormat\": \"PKCS12\",\n \"certAlias\": \"mylabel\",\n \"certData\": \"BASE64-BLOB\",\n \"certDataPassword\": \"myPassw0rd\"\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Request successful, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/security/tls/stats": { + "get": { + "tags": [ + "Security Services" + ], + "summary": "Retrieve system transport layer security (TLS) statistics.", + "description": "The API retrieves TLS statistics. The information returned includes TLS handshake connection counts by protocol type and cipher suite on the system since the last reset for the system. The System Service Tools (SST) Advanced Analysis command TLSCONFIG connectionCounts option identifies the system level setting to enable handshake connection counting. ", + "operationId": "securityTLSGetStatistics", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"protocolCounters\": {\n \"TLSv13\": 5,\n \"TLSv12\": 10,\n \"TLSv11\": 0,\n \"TLSv10\": 0,\n \"SSLv3\": 0 },\n \"cipherSuiteCounters\": {\n \"AES_128_GCM_SHA256\": 0,\n \"AES_256_GCM_SHA384\": 0,\n \"CHACHA20_POLY1305_SHA256\": 0,\n \"ECDHE_ECDSA_AES_128_GCM_SHA256\": 0,\n \"ECDHE_ECDSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_ECDSA_CHACHA20_POLY1305_SHA256\": 0,\n \"ECDHE_RSA_AES_128_GCM_SHA256\": 0,\n \"ECDHE_RSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_RSA_CHACHA20_POLY1305_SHA256\": 0,\n \"RSA_AES_128_GCM_SHA256\": 15,\n \"RSA_AES_256_GCM_SHA384\": 0,\n \"ECDHE_ECDSA_AES_128_CBC_SHA256\": 0,\n \"ECDHE_ECDSA_AES_256_CBC_SHA384\": 0,\n \"ECDHE_RSA_AES_128_CBC_SHA256\": 0,\n \"ECDHE_RSA_AES_256_CBC_SHA384\": 0,\n \"RSA_AES_128_CBC_SHA256\": 0,\n \"RSA_AES_128_CBC_SHA\": 0,\n \"RSA_AES_256_CBC_SHA256\": 0,\n \"RSA_AES_256_CBC_SHA\": 0,\n \"ECDHE_ECDSA_3DES_EDE_CBC_SHA\": 0,\n \"ECDHE_RSA_3DES_EDE_CBC_SHA\": 0,\n \"RSA_3DES_EDE_CBC_SHA\": 0,\n \"ECDHE_ECDSA_RC4_128_SHA\": 0,\n \"ECDHE_RSA_RC4_128_SHA\": 0,\n \"RSA_RC4_128_SHA\": 0,\n \"RSA_RC4_128_MD5\": 0,\n \"RSA_DES_CBC_SHA\": 0,\n \"RSA_EXPORT_RC4_40_MD5\": 0,\n \"RSA_EXPORT_RC2_CBC_40_MD5\": 0,\n \"ECDHE_ECDSA_NULL_SHA\": 0,\n \"ECDHE_RSA_NULL_SHA\": 0,\n \"RSA_NULL_SHA256\": 0,\n \"RSA_NULL_SHA\": 0,\n \"RSA_NULL_MD5\": 0\n }\n}" + } + } + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "The specified resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/info/serverdetails": { + "get": { + "tags": [ + "Server Information Services" + ], + "summary": "Get information about the RSE API.", + "description": "Get information about the RSE API.", + "operationId": "serverInfoGetVersionInfo", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"rseapiBasepath\": \"rseapi\",\n \"rseapiHostname\": \"UT30P44\",\n \"rseapiPort\": 2012,\n \"rseapiVersion\": \"1.0.6\"\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + }, + { + "basicHttpAuthentication": [] + } + ] + } + }, + "/v1/session": { + "get": { + "tags": [ + "Session Services" + ], + "summary": "Get information about the session. ", + "description": "Get information about the session. The information returned includes session settings in addition to information about any host server jobs tied to the session.", + "operationId": "sessionQuery", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + }, + { + "name": "envvars", + "in": "query", + "description": "Comma seperated list of environment variables to return from the remote command host server job.", + "schema": { + "type": "string" + } + }, + { + "name": "maxjoblogrecords", + "in": "query", + "description": "Maximum number of message log records to return from the remote command host server job.", + "schema": { + "type": "integer", + "default": "0" + } + }, + { + "name": "joblogfilter", + "in": "query", + "description": "Comma seperated list of message IDs. Only remote command host server job log messages that do not match the filter message IDs will be returned.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "example": "{\n \"sessionInfo\": {\n \"userID\": \"user1\",\n \"host\": \"localhost\",\n \"expiration\": \"2023-04-04T00:56:35Z\",\n \"creation\": \"2023-04-03T22:04:50Z\",\n \"lastUsed\": \"2023-04-03T22:56:35Z\",\n \"domain\": \"rseapi\",\n \"expired\": false\n },\n \"sessionSettings\": {\n \"libraryList\": [],\n \"clCommands\": [],\n \"envVariables\": {},\n \"sqlDefaultSchema\": null,\n \"sqlTreatWarningsAsErrors\": false,\n \"sqlProperties\": {\n \"XA loosely coupled support\": \"0\",\n \"access\": \"all\",\n \"auto commit\": \"true\",\n \"autocommit exception\": \"false\",\n \"bidi implicit reordering\": \"true\",\n \"bidi numeric ordering\": \"false\",\n \"bidi string type\": \"5\",\n \"big decimal\": \"true\",\n \"block criteria\": \"2\",\n \"block size\": \"32\",\n \"character truncation\": \"true\",\n \"concurrent access resolution\": \"2\",\n \"cursor hold\": \"true\",\n \"cursor sensitivity\": \"asensitive\",\n \"data compression\": \"true\",\n \"data truncation\": \"true\",\n \"database name\": \"\",\n \"date format\": \"iso\",\n \"date separator\": \"\",\n \"decfloat rounding mode\": \"half even\",\n \"decimal separator\": \"\",\n \"driver\": \"toolbox\",\n \"errors\": \"basic\",\n \"extended dynamic\": \"false\",\n \"extended metadata\": \"false\",\n \"full open\": \"false\",\n \"hold input locators\": \"true\",\n \"hold statements\": \"false\",\n \"ignore warnings\": \"01003,0100C,01567\",\n \"lazy close\": \"false\",\n \"libraries\": \"*LIBL\",\n \"lob threshold\": \"32768\",\n \"maximum blocked input rows\": \"32000\",\n \"maximum precision\": \"31\",\n \"maximum scale\": \"31\",\n \"metadata source\": \"1\",\n \"minimum divide scale\": \"0\",\n \"naming\": \"system\",\n \"numeric range error\": \"true\",\n \"package\": \"\",\n \"package add\": \"true\",\n \"package cache\": \"false\",\n \"package ccsid\": \"13488\",\n \"package criteria\": \"default\",\n \"package error\": \"warning\",\n \"package library\": \"QGPL\",\n \"portNumber\": \"0\",\n \"prefetch\": \"true\",\n \"proxy server\": \"\",\n \"qaqqinilib\": \"\",\n \"query optimize goal\": \"0\",\n \"query replace truncated parameter\": \"\",\n \"query storage limit\": \"-1\",\n \"query timeout mechanism\": \"qqrytimlmt\",\n \"remarks\": \"system\",\n \"secondary URL\": \"\",\n \"server trace\": \"0\",\n \"sort\": \"hex\",\n \"sort language\": \"\",\n \"sort table\": \"\",\n \"sort weight\": \"shared\",\n \"time format\": \"iso\",\n \"time separator\": \":\",\n \"trace\": \"false\",\n \"transaction isolation\": \"read uncommitted\",\n \"translate binary\": \"false\",\n \"translate boolean\": \"true\",\n \"translate hex\": \"character\",\n \"true autocommit\": \"false\",\n \"use block update\": \"false\",\n \"variable field compression\": \"all\"\n },\n \"sqlStatements\": []\n },\n \"jobRemoteCommand\": {\n \"id\": \"094268/QUSER/QZRCSRVS\",\n \"ccsid\": 37,\n \"homeDirectory\": \"/home/USER1\",\n \"curLib\": null,\n \"systemLibl\": [\n {\n \"name\": \"QSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library\"\n },\n {\n \"name\": \"QSYS2\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library for CPI's\"\n },\n {\n \"name\": \"QHLPSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"\"\n },\n {\n \"name\": \"QUSRSYS\",\n \"attribute\": \"PROD\",\n \"description\": \"System Library for Users\"\n }\n ],\n \"userLibl\": [\n {\n \"name\": \"QGPL\",\n \"attribute\": \"PROD\",\n \"description\": \"General Purpose Library\"\n },\n {\n \"name\": \"QTEMP\",\n \"attribute\": \"TEST\",\n \"description\": \"\"\n }\n ],\n \"envVariables\": {},\n \"jobLog\": []\n }\n}" + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + } + ] + }, + "put": { + "tags": [ + "Session Services" + ], + "summary": "Refresh session settings.", + "description": "Refresh session settings. The settings affect the remote command and database host server jobs that are tied to the session. Refreshing sesson settings may result in the ending of existing host server jobs. ", + "operationId": "sessionRefresh", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "The settings for the session.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_SessionSettings" + }, + "example": "{\n \"resetSettings\": true,\n \"continueOnError\": true,\n \"libraryList\": [\n \"lib1\",\n \"lib2\"\n ],\n \"clCommands\": [\n \"QSYS/CLRLIB LIB(BUILD)\"\n ],\n \"envVariables\": {\n \"var1\": \"var1val\",\n \"var2\": \"var2val\"\n },\n \"sqlDefaultSchema\": \"lib1\",\n \"sqlProperties\": {\n \"auto commit\": \"true\",\n \"ignore warnings\": \"01003,0100C,01567\"\n },\n \"sqlStatements\": [\n \"SET PATH = LIB1, LIB2\"\n ]\n}" + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful request, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + } + ] + }, + "post": { + "tags": [ + "Session Services" + ], + "summary": "Authenticate with user credentials and return an embedded token.", + "description": "Authenticate with user credentials and return an embedded token to access different RSE APIs. On succesful authentication, a token is returned in the Authorization HTTP header. The client must send this token in the Authorization HTTP header when making requests to protected RSE APIs. For example: \n> Authorization: Bearer 4eaa14e6-ea9c-4dde-b6f7-f542b34d5309-60da75d3-3132 \n\nFor optimal performance, specify localhost to access objects located on the same system in which the RSE APIs are hosted on. If accessing objects on a remote system using the RSE APIs, the host servers on the remote system must be enabled for secure communications. \n\nAlternatively, the non-session APIs may be invoked using basic authentication. In this case, the credentials must always be sent on every request.", + "operationId": "sessionLogin", + "requestBody": { + "description": "The user credentials to be authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RSEAPI_LoginCredentials" + }, + "example": "{\n \"host\": \"localhost\",\n \"userid\": \"user\",\n \"password\": \"pwd\"\n}" + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful request, new resource created." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Session Services" + ], + "summary": "Logout, releasing resources tied to the session.", + "description": "Logout, releasing resources tied to the session. If a logout is not performed, it will be discarded after a period of idle time (default is 2 hours).", + "operationId": "sessionLogout", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "The authorization HTTP header.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful request, no content." + }, + "400": { + "description": "Bad request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "description": "Unauthorized request was made.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "403": { + "description": "The request is forbidden.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "500": { + "description": "Unable to process the request due to an internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "security": [ + { + "bearerHttpAuthentication": [] + } + ] + } + }, + "/v1/health/ping": { + "get": { + "tags": [ + "Health" + ], + "summary": "Ping", + "description": "Lightweight liveness check.", + "operationId": "get_api_v1_health_ping", + "responses": { + "200": { + "description": "Service is alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + }, + "examples": { + "ok": { + "value": { + "ok": true + } + } + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "ApiResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful" + }, + "data": { + "description": "The response data (varies by endpoint)" + }, + "error": { + "$ref": "#/components/schemas/ErrorInfo" + }, + "pagination": { + "$ref": "#/components/schemas/PaginationInfo" + }, + "message": { + "type": "string", + "description": "Success or informational message" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 formatted timestamp of the response" + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Additional metadata about the response" + } + }, + "required": [ + "success", + "timestamp" + ] + }, + "ErrorInfo": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Error code identifying the type of error" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + }, + "details": { + "type": "string", + "description": "Additional error details" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 formatted timestamp when error occurred" + }, + "path": { + "type": "string", + "description": "The request path that caused the error" + }, + "method": { + "type": "string", + "description": "The HTTP method that caused the error" + }, + "httpStatus": { + "type": "integer", + "description": "HTTP status code associated with the error" + }, + "validationErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "description": "List of validation errors if applicable" + }, + "context": { + "type": "object", + "additionalProperties": true, + "description": "Additional context information about the error" + } + }, + "required": [ + "code", + "message" + ] + }, + "ValidationError": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "The field that failed validation" + }, + "message": { + "type": "string", + "description": "The validation error message" + }, + "rejectedValue": { + "description": "The value that was rejected during validation" + } + }, + "required": [ + "field", + "message" + ] + }, + "PaginationInfo": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Current page number (0-based)" + }, + "size": { + "type": "integer", + "description": "Number of elements per page" + }, + "totalPages": { + "type": "integer", + "description": "Total number of pages" + }, + "totalElements": { + "type": "integer", + "format": "int64", + "description": "Total number of elements across all pages" + }, + "first": { + "type": "boolean", + "description": "Whether this is the first page" + }, + "last": { + "type": "boolean", + "description": "Whether this is the last page" + }, + "hasNext": { + "type": "boolean", + "description": "Whether there is a next page" + }, + "hasPrevious": { + "type": "boolean", + "description": "Whether there is a previous page" + }, + "numberOfElements": { + "type": "integer", + "description": "Number of elements in the current page" + }, + "empty": { + "type": "boolean", + "description": "Whether the current page is empty" + }, + "links": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Navigation links (self, first, last, prev, next)" + } + } + }, + "DatabaseQueryRequest": { + "type": "object", + "properties": { + "sql": { + "type": "string", + "description": "The SQL query to execute", + "example": "SELECT * FROM QSYS2.SYSTABLES FETCH FIRST 10 ROWS ONLY" + }, + "format": { + "type": "string", + "enum": [ + "json", + "xml", + "csv", + "html", + "excel" + ], + "default": "json", + "description": "Output format for the query results" + }, + "treatWarningsAsErrors": { + "type": "boolean", + "default": false, + "description": "Whether to treat SQL warnings as errors" + }, + "alwaysReturnSQLStateInformation": { + "type": "boolean", + "default": false, + "description": "Whether to always return SQL state information" + } + }, + "required": [ + "sql" + ] + }, + "UploadRequest": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "description": "The target folder path for upload" + }, + "filename": { + "type": "string", + "description": "The name of the file to upload" + }, + "domain": { + "type": "string", + "description": "SMB domain" + }, + "username": { + "type": "string", + "description": "SMB username" + }, + "password": { + "type": "string", + "description": "SMB password" + }, + "fileContent": { + "type": "string", + "format": "byte", + "description": "Base64 encoded file content" + } + }, + "required": [ + "folder", + "filename", + "fileContent" + ] + }, + "MergeRequest": { + "type": "object", + "properties": { + "pdfFiles": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "description": "Array of Base64 encoded PDF files to merge" + } + }, + "required": [ + "pdfFiles" + ] + }, + "RotateRequest": { + "type": "object", + "properties": { + "pdfFile": { + "type": "string", + "format": "byte", + "description": "Base64 encoded PDF file to rotate" + }, + "rotation": { + "type": "integer", + "description": "Rotation angle (90, 180, 270, 360/0)", + "default": 90, + "enum": [ + 90, + 180, + 270, + 360, + 0 + ] + } + }, + "required": [ + "pdfFile" + ] + }, + "UnprotectRequest": { + "type": "object", + "properties": { + "pdfFile": { + "type": "string", + "format": "byte", + "description": "Base64 encoded protected PDF file" + } + }, + "required": [ + "pdfFile" + ] + }, + "ConvertRequest": { + "type": "object", + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert to PDF" + }, + "size": { + "type": "string", + "description": "Page size for the PDF", + "default": "A4", + "enum": [ + "A1", + "A2", + "A3", + "A4", + "A5", + "A6", + "A7" + ] + }, + "orientation": { + "type": "string", + "description": "Page orientation", + "default": "portrait", + "enum": [ + "portrait", + "landscape" + ] + } + }, + "required": [ + "html" + ] + }, + "RSEAPI_CLCommands": { + "required": [ + "clCommands" + ], + "type": "object", + "properties": { + "continueOnError": { + "type": "boolean", + "description": "Continue processing CL commands if an error is encountered.", + "default": "false" + }, + "includeMessageOnSuccess": { + "type": "boolean", + "description": "Return CL command messages on success.", + "default": "false" + }, + "includeMessageHelpText": { + "type": "boolean", + "description": "Return message help text for CL command messages.", + "default": "false" + }, + "clCommands": { + "type": "array", + "description": "CL command to run.", + "items": { + "type": "string" + } + } + }, + "description": "List of CL commands to run." + }, + "RSEAPI_DCMCertAppDefTrustRequest": { + "required": [ + "appDefinitionID", + "certAlias" + ], + "type": "object", + "properties": { + "appDefinitionID": { + "type": "string", + "description": "The application definition identifier." + }, + "certAlias": { + "type": "string", + "description": "The certificate label." + } + }, + "description": "Add/remove a CA certificate to/from an application definition list of trusted CA certificates." + }, + "RSEAPI_DCMCertAppDefAssociateRequest": { + "required": [ + "appDefinitionID", + "certAliases" + ], + "type": "object", + "properties": { + "appDefinitionID": { + "type": "string", + "description": "The application definition identifier." + }, + "certAliases": { + "type": "array", + "description": "The certificate label. Maximum of 4.", + "items": { + "type": "string" + } + } + }, + "description": "Associate certificate(s) to an application definition." + }, + "RSEAPI_LoginCredentials": { + "required": [ + "password", + "userid" + ], + "type": "object", + "properties": { + "host": { + "type": "string", + "description": "The IBM i server from which objects are to be accessed.", + "default": "localhost" + }, + "userid": { + "type": "string", + "description": "The user ID." + }, + "password": { + "type": "string", + "description": "The password." + } + }, + "description": "The login credentials." + }, + "RSEAPI_DCMCertStoreChangePasswordRequest": { + "required": [ + "certStorePassword", + "certStorePasswordNew", + "certStorePath", + "certStoreType" + ], + "type": "object", + "properties": { + "certStoreType": { + "type": "string", + "description": "The type of the certificate store. Valid values: CMS." + }, + "certStorePath": { + "type": "string", + "description": "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION." + }, + "certStorePassword": { + "type": "string", + "description": "The certificate store password. If field omitted or set to null, the system stash will be used." + }, + "certStorePasswordNew": { + "type": "string", + "description": "The new certificate store password." + }, + "daysToExpiration": { + "type": "integer", + "description": "Number of days before password expires.", + "default": "0" + } + }, + "description": "Change certificate store password request." + }, + "RSEAPI_Settings": { + "type": "object", + "properties": { + "persist": { + "type": "boolean", + "description": "Save settings to property file in persistent storage (hard disk).", + "default": "false" + }, + "adminUsers": { + "type": "array", + "description": "User IDs that will be designated as an RSE API administrator.", + "items": { + "type": "string" + } + }, + "includeUsers": { + "type": "array", + "description": "User IDs allowed to use RSE API.", + "items": { + "type": "string" + } + }, + "excludeUsers": { + "type": "array", + "description": "User IDs not allowed to use RSE API.", + "items": { + "type": "string" + } + }, + "maxFileSize": { + "maximum": 15360000, + "minimum": 0, + "type": "integer", + "description": "Maximum size of IFS file data that can be processed (reading or writing) by RSE API.", + "format": "int64", + "default": "3072000" + }, + "maxSessions": { + "minimum": -1, + "type": "integer", + "description": "Maximum number of total sessions. The value of -1 indicates there is no limit.", + "format": "int64", + "default": "100" + }, + "maxSessionsPerUser": { + "type": "integer", + "description": "Maximum number of sessions allowed on a per-user basis. The value of -1 indicates there is no limit. A value other than -1 must be greater than zero.", + "format": "int64", + "default": "20" + }, + "maxSessionInactivity": { + "maximum": 7200, + "minimum": 30, + "type": "integer", + "description": "Maximum amount of inactive time, in seconds, before an available sesson is invalidated.", + "format": "int64", + "default": "7200" + }, + "maxSessionLifetime": { + "type": "integer", + "description": "Maximum life, in seconds, for a session. The value of -1 indicates there is no limit. A value other than -1 must be greater or equal to 30.", + "format": "int64", + "default": "-1" + }, + "maxSessionUseCount": { + "type": "integer", + "description": "Maximum number of times a session can be used before it is invalidated. The value of -1 indicates there is no limit. A value other than -1 must be greater than zero.", + "format": "int64", + "default": "-1" + }, + "maxSessionWaitTime": { + "minimum": -1, + "type": "integer", + "description": "Maximum time, in seconds, to wait on a session to become available. The value of -1 indicates there is no limit.", + "format": "int64", + "default": "300" + }, + "sessionCleanupInterval": { + "maximum": 900, + "minimum": 30, + "type": "integer", + "description": "The time interval, in seconds, for how often the session maintenance daemon is run.", + "format": "int64", + "default": "300" + } + }, + "description": "Global settings for RSE API." + }, + "RSEAPI_FileContent": { + "required": [ + "content" + ], + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "description": "The file content." + }, + "RSEAPI_DCMCertRequest": { + "required": [ + "certAlias", + "certStorePassword", + "certStorePath", + "certStoreType" + ], + "type": "object", + "properties": { + "certStoreType": { + "type": "string", + "description": "The type of the certificate store. Valid values: CMS." + }, + "certStorePath": { + "type": "string", + "description": "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or *SIGNATUREVERIFICATION." + }, + "certStorePassword": { + "type": "string", + "description": "The certificate store password." + }, + "certAlias": { + "type": "string", + "description": "The certificate label." + } + }, + "description": "Certificate request." + }, + "RSEAPI_DCMCertListRequest": { + "required": [ + "certStorePassword", + "certStorePath", + "certStoreType" + ], + "type": "object", + "properties": { + "certStoreType": { + "type": "string", + "description": "The type of the certificate store. Valid values: CMS." + }, + "certStorePath": { + "type": "string", + "description": "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION." + }, + "certStorePassword": { + "type": "string", + "description": "The certificate store password." + }, + "filters": { + "type": "object", + "properties": { + "certAlias": { + "type": "string", + "description": "Aliase name filter. A simple generic name can be specified. For example, myCert*" + }, + "certTypes": { + "type": "array", + "description": "Certificate types filter. Valid values: CA, SERVER_CLIENT.", + "items": { + "type": "string" + } + }, + "daysUntilExpiration": { + "type": "integer", + "description": "Days until expiration filter." + }, + "excludeExpired": { + "type": "boolean", + "description": "Whether to exclude expired certificates." + } + }, + "description": "One or more combination of filters." + } + }, + "description": "List certificate request." + }, + "RSEAPI_DCMCertAppDefDisassociateRequest": { + "required": [ + "appDefinitionID" + ], + "type": "object", + "properties": { + "appDefinitionID": { + "type": "string", + "description": "The application definition identifier." + } + }, + "description": "Disassociate certificates from an application definition." + }, + "RSEAPI_SQLRequest": { + "required": [ + "sqlStatement" + ], + "type": "object", + "properties": { + "alwaysReturnSQLStateInformation": { + "type": "boolean", + "description": "Always return SQL state information. Default value is whatever has been set for the session." + }, + "treatWarningsAsErrors": { + "type": "boolean", + "description": "Treat SQL warnings as errors. Default value is whatever has been set for the session." + }, + "sqlStatement": { + "type": "string", + "description": "The SQL statement to be run." + } + }, + "description": "SQL request to run." + }, + "RSEAPI_DCMCertListFilters": { + "type": "object", + "properties": { + "certAlias": { + "type": "string", + "description": "Aliase name filter. A simple generic name can be specified. For example, myCert*" + }, + "certTypes": { + "type": "array", + "description": "Certificate types filter. Valid values: CA, SERVER_CLIENT.", + "items": { + "type": "string" + } + }, + "daysUntilExpiration": { + "type": "integer", + "description": "Days until expiration filter." + }, + "excludeExpired": { + "type": "boolean", + "description": "Whether to exclude expired certificates." + } + } + }, + "RSEAPI_DCMCertImportRequest": { + "required": [ + "certData", + "certFormat", + "certStorePassword", + "certStorePath", + "certStoreType", + "certType" + ], + "type": "object", + "properties": { + "certStoreType": { + "type": "string", + "description": "The type of the certificate store. Valid values: CMS." + }, + "certStorePath": { + "type": "string", + "description": "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION." + }, + "certStorePassword": { + "type": "string", + "description": "The certificate store password." + }, + "certType": { + "type": "string", + "description": "The certificate type. Possible values: CA, or SERVER_CLIENT" + }, + "certFormat": { + "type": "string", + "description": "The format of the certificate. Possible values: PKCS12, DER, or PEM." + }, + "certAlias": { + "type": "string", + "description": "The certificate label. This property is ignored when certificate type is set to CERTIFICATE_SIGNING_REQUEST." + }, + "certData": { + "type": "string", + "description": "Base64-encoded binary data object representing the certificate to be imported." + }, + "certDataPassword": { + "type": "string", + "description": "The password to access the certificate data. This property is only used when the certificate type is set to SERVER_CLIENT." + } + }, + "description": "Import certificate request." + }, + "RSEAPI_DCMCertExportRequest": { + "required": [ + "certAlias", + "certFormat", + "certStorePassword", + "certStorePath", + "certStoreType" + ], + "type": "object", + "properties": { + "certStoreType": { + "type": "string", + "description": "The type of the certificate store. Valid values: CMS." + }, + "certStorePath": { + "type": "string", + "description": "Path to certificate store or one of the following special values: *SYSTEM, *LOCALCA, *OBJECTSIGNING, or SIGNATUREVERIFICATION." + }, + "certStorePassword": { + "type": "string", + "description": "The certificate store password." + }, + "certFormat": { + "type": "string", + "description": "The format of the certificate. Possible values: PKCS12, DER, or PEM." + }, + "certAlias": { + "type": "string", + "description": "The certificate label." + }, + "certDataPassword": { + "type": "string", + "description": "The password to access the certificate data that is returned. This field is only used when certificate type is SERVER_CLIENT." + } + }, + "description": "Export certificate request." + }, + "RSEAPI_SessionSettings": { + "type": "object", + "properties": { + "resetSettings": { + "type": "boolean", + "description": "Replace existing sessions attributes. A value of false will merge settings in request with existing session settings.", + "default": "false" + }, + "continueOnError": { + "type": "boolean", + "description": "Continue with session processing if an error occurs.", + "default": "false" + }, + "libraryList": { + "type": "array", + "description": "Library to be added to library list of the remote command host server job tied to session.", + "items": { + "type": "string" + } + }, + "clCommands": { + "type": "array", + "description": "CL command to be run in remote command host server job tied to session.", + "items": { + "type": "string" + } + }, + "envVariables": { + "type": "object", + "description": "Environment variable to set in remote command host server job tied to session.", + "additionalProperties": { + "type": "string" + }, + "additionalPropertiesSchema": { + "type": "string" + } + }, + "sqlDefaultSchema": { + "type": "string", + "description": "Default SQL schema to use when running SQL statements in database host server job. " + }, + "sqlTreatWarningsAsErrors": { + "type": "boolean", + "description": "Treat SQL warnings as errors. ", + "default": "false" + }, + "sqlProperties": { + "type": "object", + "description": "Java toolbox JDBC property. ", + "additionalProperties": { + "type": "string" + }, + "additionalPropertiesSchema": { + "type": "string" + } + }, + "sqlStatements": { + "type": "array", + "description": "SQL statment to be run in database host server job tied to session.", + "items": { + "type": "string" + } + } + }, + "description": "The settings for the session." + }, + "Problem": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "code": { + "type": "integer", + "format": "int32" + }, + "details": { + "type": "object" + } + } + } + }, + "securitySchemes": { + "bearerHttpAuthentication": { + "type": "http", + "description": "Bearer token authentication.", + "scheme": "bearer", + "bearerFormat": "Bearer [token]" + }, + "basicHttpAuthentication": { + "type": "http", + "description": "Basic authentication.", + "scheme": "basic" + }, + "basicAuth": { + "type": "http", + "scheme": "basic" + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] +} \ No newline at end of file diff --git a/src/main/webapp/styles.css b/src/main/webapp/styles.css new file mode 100644 index 0000000..b732026 --- /dev/null +++ b/src/main/webapp/styles.css @@ -0,0 +1,224 @@ +/* !!! --- CSS reset START --- !!! */ +/* Reset all elements & pseudo-elements */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +h5, +h6, +p, +ul[class], +ol[class], +li, +figure, +figcaption, +blockquote, +dl, +dd { + margin: 0; +} + +html, +body { + width: 100%; + height: 100%; +} + +/* Fill the viewport even when empty, increase default line height, increase page load time */ +body { + font-family: 'IBM Plex Sans', sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + min-height: 100vh; + scroll-behavior: smooth; + text-rendering: optimizeSpeed; +} + +/* Reset list elements with a class attribute */ +ul[class], +ol[class] { + list-style-type: none; +} + +/* For links without a class atttribute, nicer render */ +a:not([class]) { + text-decoration-skip-ink: auto; +} + +/* Fix weird gap at bottom for non-block elements, fluid images */ +img { + max-width: 100%; + display: block; +} + +/* Avoids tiny or mono text */ +button, +input, +textarea, +select { + font: inherit; +} + +/* Resets animation, transitions, and scroll behaviour for user who want reduced motion */ +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +/* !!! --- CSS reset END --- !!! */ + +.container { + display: grid; + grid-template-columns: 100%; + grid-template-rows: auto 1fr auto; + min-height: 100%; +} + +.headerContainer { + display: flex; + flex-direction: column; +} + +.navbar { + background-color: #000000; + color: #ffffff; + grid-area: nav; +} + +.mainContainer { + padding: 2em 0; +} + +.mainWrapper { + display: flex; + flex-direction: column; + text-align: center; +} + +.titleContainer { + display: flex; + flex-direction: column; + margin: 3rem auto; +} + +.mainTitle { + font-size: 1rem; + font-weight: 700; + padding: 1rem auto; +} + +.mainDescription { + margin: 1rem auto; + width: 50%; +} + +.cardsContainer { + display: flex; + flex-direction: row; + justify-content: center; + margin: 1rem; +} + +.cardWrapper { + align-items: center; + background: #002d9c; + border-radius: 5px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.4); + color: #ffffff; + display: flex; + flex-direction: column; + margin: 1rem; + padding: 2rem; + text-align: center; + width: 35%; +} + +.cardTitle { + font-size: 1rem; +} + +.cardIcon { + margin: 1rem 0; +} + +.cardIcon > svg { + height: 50px; + width: 50px; + fill: #0f62fe; +} + +.cardButton { + background: #0f62fe; + border-radius: 5px; + box-shadow: rgba(0, 0, 0, 0.9); + color: #ffffff; + display: block; + padding: 1rem; + text-decoration: none; + transition: all 200ms ease-in-out; +} + +.cardButton:hover { + background-color: #78a9ff; +} + +.footer { + background-color: #000000; + color: #ffffff; + padding: 1rem; +} + +/* Media Queries */ +@media only screen and (max-width: 568px) { + .cardWrapper { + width: 50%; + } + + .mainTitle { + font-size: 1rem; + font-weight: 700; + margin: 1rem 2rem; + } + + .mainDescription { + margin: auto; + padding: 0 1rem; + width: 100%; + } +} + +@media only screen and (max-width: 767px) { + .cardsContainer { + display: flex; + flex-direction: column; + align-items: center; + } +} + +@media only screen and (min-width: 768px) { + body { + font-size: 1rem; + } + + .mainTitle { + font-size: 1.75rem; + font-weight: 700; + } + + .mainDescription { + margin: auto; + width: 60%; + } +}