From 46ed07e16eafcf63af0d5a12f7768fa6401c6caf Mon Sep 17 00:00:00 2001 From: Nikita Amelchev Date: Thu, 14 May 2026 10:54:59 +0300 Subject: [PATCH 1/2] IGNITE-28670 Add support for custom REST HTTP endpoint extensions --- .../processors/rest/GridRestProcessor.java | 8 + modules/rest-http/pom.xml | 6 + .../http/jetty/AuthenticationFilter.java | 84 +++++++++++ .../http/jetty/GridJettyRestHandler.java | 140 +----------------- .../http/jetty/GridJettyRestProtocol.java | 80 +++++++++- .../http/jetty/IgniteRestExtension.java | 48 ++++++ .../protocols/http/jetty/WelcomeHandler.java | 91 ++++++++++++ .../rest/protocols/http/jetty/rest.html | 79 ---------- .../ignite-rest-http}/favicon.ico | Bin .../main/resources/ignite-rest-http/logo.svg | 1 + .../main/resources/ignite-rest-http/rest.html | 122 +++++++++++++++ 11 files changed, 442 insertions(+), 217 deletions(-) create mode 100644 modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/AuthenticationFilter.java create mode 100644 modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtension.java create mode 100644 modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/WelcomeHandler.java delete mode 100644 modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/rest.html rename modules/rest-http/src/main/{java/org/apache/ignite/internal/processors/rest/protocols/http/jetty => resources/ignite-rest-http}/favicon.ico (100%) create mode 100644 modules/rest-http/src/main/resources/ignite-rest-http/logo.svg create mode 100644 modules/rest-http/src/main/resources/ignite-rest-http/rest.html diff --git a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/GridRestProcessor.java b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/GridRestProcessor.java index f26b3af040e2b..9bc063b0c1be7 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/GridRestProcessor.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/processors/rest/GridRestProcessor.java @@ -89,6 +89,7 @@ import org.apache.ignite.plugin.security.SecurityException; import org.apache.ignite.plugin.security.SecurityPermission; import org.apache.ignite.thread.IgniteThread; +import org.jetbrains.annotations.Nullable; import static org.apache.ignite.IgniteSystemProperties.IGNITE_REST_SECURITY_TOKEN_TIMEOUT; import static org.apache.ignite.IgniteSystemProperties.IGNITE_REST_SESSION_TIMEOUT; @@ -237,6 +238,13 @@ protected IgniteInternalFuture handleAsync0(final GridRestRequ } } + /** @return Security context for given session token, or {@code null} if none found. */ + @Nullable public SecurityContext securityContext(UUID sesId) { + Session ses = sesId2Ses.get(sesId); + + return ses == null ? null : ses.secCtx; + } + /** * @param req Request. * @return Future. diff --git a/modules/rest-http/pom.xml b/modules/rest-http/pom.xml index f6f86b1ae3bed..52d3636024e1f 100644 --- a/modules/rest-http/pom.xml +++ b/modules/rest-http/pom.xml @@ -82,6 +82,12 @@ ${jetty.version} + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + + org.eclipse.jetty.toolchain jetty-jakarta-servlet-api diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/AuthenticationFilter.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/AuthenticationFilter.java new file mode 100644 index 0000000000000..4d0002053cbee --- /dev/null +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/AuthenticationFilter.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.processors.rest.protocols.http.jetty; + +import java.io.IOException; +import java.util.UUID; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.ignite.internal.GridKernalContext; +import org.apache.ignite.internal.processors.rest.GridRestProcessor; +import org.apache.ignite.internal.processors.security.SecurityContext; +import org.apache.ignite.internal.thread.context.Scope; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.jetbrains.annotations.Nullable; + +/** + * Servlet filter that authenticates REST requests via session token. + */ +public class AuthenticationFilter implements Filter { + /** */ + private final GridKernalContext ctx; + + /** */ + public AuthenticationFilter(GridKernalContext ctx) { + this.ctx = ctx; + } + + /** {@inheritDoc} */ + @Override public void doFilter( + ServletRequest req, + ServletResponse res, + FilterChain chain + ) throws IOException, ServletException { + SecurityContext secCtx = resolveSession((HttpServletRequest)req); + + if (secCtx == null) { + ((HttpServletResponse)res).sendError(HttpServletResponse.SC_UNAUTHORIZED, + "Missing or invalid authentication token (maybe expired session)"); + + return; + } + + try (Scope ignored = ctx.security().withContext(secCtx)) { + chain.doFilter(req, res); + } + } + + /** @return Security context for given session token, or {@code null} if none found. */ + @Nullable private SecurityContext resolveSession(HttpServletRequest req) { + String token = req.getParameter("sessionToken"); + + if (token == null) + return null; + + try { + UUID sesId = U.bytesToUuid(U.hexString2ByteArray(token), 0); + + return ((GridRestProcessor)ctx.rest()).securityContext(sesId); + } + catch (Exception ignored) { + return null; + } + } +} diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestHandler.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestHandler.java index efb4aff121cc9..f4f101782d058 100644 --- a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestHandler.java +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestHandler.java @@ -17,15 +17,9 @@ package org.apache.ignite.internal.processors.rest.protocols.http.jetty; -import java.io.BufferedInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.LineNumberReader; import java.io.OutputStream; import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.sql.Date; import java.sql.Time; @@ -100,8 +94,8 @@ * Jetty REST handler. The following URL format is supported: {@code /ignite?cmd=cmdName¶m1=abc¶m2=123} */ public class GridJettyRestHandler extends AbstractHandler { - /** Used to sent request charset. */ - private static final String CHARSET = StandardCharsets.UTF_8.name(); + /** */ + public static final String IGNITE_CMD_PATH = "/ignite"; /** */ private static final String FAILED_TO_PARSE_FORMAT = "Failed to parse parameter of %s type [%s=%s]"; @@ -148,12 +142,6 @@ public class GridJettyRestHandler extends AbstractHandler { /** Request handlers. */ private GridRestProtocolHandler hnd; - /** Default page. */ - private volatile String dfltPage; - - /** Favicon. */ - private volatile byte[] favicon; - /** Mapper from Java object to JSON. */ private final ObjectMapper jsonMapper; @@ -175,27 +163,6 @@ public class GridJettyRestHandler extends AbstractHandler { this.authChecker = authChecker; this.log = ctx.log(getClass()); this.jsonMapper = new IgniteObjectMapper(ctx); - - // Init default page and favicon. - try { - initDefaultPage(); - - if (log.isDebugEnabled()) - log.debug("Initialized default page."); - } - catch (IOException e) { - U.warn(log, "Failed to initialize default page: " + e.getMessage()); - } - - try { - initFavicon(); - - if (log.isDebugEnabled()) - log.debug(favicon != null ? "Initialized favicon, size: " + favicon.length : "Favicon is null."); - } - catch (IOException e) { - U.warn(log, "Failed to initialize favicon: " + e.getMessage()); - } } /** @@ -302,113 +269,14 @@ private static int intValue(String key, Map params, int dfltVal) } } - /** - * @throws IOException If failed. - */ - private void initDefaultPage() throws IOException { - assert dfltPage == null; - - InputStream in = getClass().getResourceAsStream("rest.html"); - - if (in != null) { - LineNumberReader rdr = new LineNumberReader(new InputStreamReader(in, CHARSET)); - - try { - StringBuilder buf = new StringBuilder(2048); - - for (String line = rdr.readLine(); line != null; line = rdr.readLine()) { - buf.append(line); - - if (!line.endsWith(" ")) - buf.append(' '); - } - - dfltPage = buf.toString(); - } - finally { - U.closeQuiet(rdr); - } - } - } - - /** - * @throws IOException If failed. - */ - private void initFavicon() throws IOException { - assert favicon == null; - - InputStream in = getClass().getResourceAsStream("favicon.ico"); - - if (in != null) { - BufferedInputStream bis = new BufferedInputStream(in); - - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - - try { - byte[] buf = new byte[2048]; - - while (true) { - int n = bis.read(buf); - - if (n == -1) - break; - - bos.write(buf, 0, n); - } - - favicon = bos.toByteArray(); - } - finally { - U.closeQuiet(bis); - } - } - } - /** {@inheritDoc} */ - @Override public void handle(String target, Request req, HttpServletRequest srvReq, HttpServletResponse res) - throws IOException { + @Override public void handle(String target, Request req, HttpServletRequest srvReq, HttpServletResponse res) { if (log.isDebugEnabled()) log.debug("Handling request [target=" + target + ", req=" + req + ", srvReq=" + srvReq + ']'); - if (target.startsWith("/ignite")) { + if (target.startsWith(IGNITE_CMD_PATH)) { processRequest(target, srvReq, res); - req.setHandled(true); - } - else if (target.startsWith("/favicon.ico")) { - if (favicon == null) { - res.setStatus(HttpServletResponse.SC_NOT_FOUND); - - req.setHandled(true); - - return; - } - - res.setStatus(HttpServletResponse.SC_OK); - - res.setContentType("image/x-icon"); - - res.getOutputStream().write(favicon); - res.getOutputStream().flush(); - - req.setHandled(true); - } - else { - if (dfltPage == null) { - res.setStatus(HttpServletResponse.SC_NOT_FOUND); - - req.setHandled(true); - - return; - } - - res.setStatus(HttpServletResponse.SC_OK); - - res.setContentType("text/html"); - - res.getWriter().write(dfltPage); - res.getWriter().flush(); - req.setHandled(true); } } diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java index c1d0319d88334..c132c4201ed46 100644 --- a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java @@ -23,25 +23,39 @@ import java.net.SocketException; import java.net.URL; import java.net.UnknownHostException; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import jakarta.servlet.DispatcherType; import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; import org.apache.ignite.IgniteSystemProperties; import org.apache.ignite.internal.GridKernalContext; import org.apache.ignite.internal.IgniteNodeAttributes; import org.apache.ignite.internal.processors.rest.GridRestProtocolHandler; import org.apache.ignite.internal.processors.rest.protocols.GridRestProtocolAdapter; +import org.apache.ignite.internal.util.CommonUtils; import org.apache.ignite.internal.util.typedef.C1; import org.apache.ignite.internal.util.typedef.F; import org.apache.ignite.internal.util.typedef.X; +import org.apache.ignite.internal.util.typedef.internal.A; import org.apache.ignite.internal.util.typedef.internal.S; import org.apache.ignite.internal.util.typedef.internal.U; import org.apache.ignite.spi.IgniteSpiException; import org.eclipse.jetty.server.AbstractNetworkConnector; import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Handler; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.NetworkConnector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.util.MultiException; import org.eclipse.jetty.util.resource.Resource; import org.eclipse.jetty.util.thread.QueuedThreadPool; @@ -53,6 +67,7 @@ import static org.apache.ignite.IgniteSystemProperties.IGNITE_JETTY_HOST; import static org.apache.ignite.IgniteSystemProperties.IGNITE_JETTY_LOG_NO_OVERRIDE; import static org.apache.ignite.IgniteSystemProperties.IGNITE_JETTY_PORT; +import static org.apache.ignite.internal.processors.rest.protocols.http.jetty.GridJettyRestHandler.IGNITE_CMD_PATH; import static org.apache.ignite.spi.IgnitePortProtocol.TCP; /** @@ -81,6 +96,9 @@ public class GridJettyRestProtocol extends GridRestProtocolAdapter { /** HTTP server. */ private Server httpSrv; + /** Registered REST extensions. */ + private final Collection exts = new CopyOnWriteArrayList<>(); + /** * @param ctx Context. */ @@ -155,9 +173,15 @@ else if (log.isDebugEnabled()) connector.setPort(port); if (startJetty()) { - if (log.isInfoEnabled()) + if (log.isInfoEnabled()) { log.info(startInfo()); + boolean isSsl = connector.getConnectionFactory(SslConnectionFactory.class) != null; + String proto = isSsl ? "https" : "http"; + + log.info("HTTP REST protocol address: " + proto + "://" + host + ":" + port + "/"); + } + return; } } @@ -312,11 +336,51 @@ private void loadJettyConfiguration(@Nullable URL cfgUrl) throws IgniteCheckedEx assert httpSrv != null; - httpSrv.setHandler(jettyHnd); + Handler extsHnd = loadExtensions(); + WelcomeHandler welcomeHnd = new WelcomeHandler(log); + + httpSrv.setHandler(new HandlerList(jettyHnd, extsHnd, welcomeHnd)); override(getJettyConnector()); } + /** */ + private Handler loadExtensions() throws IgniteCheckedException { + HandlerList extsHnd = new HandlerList(); + + CommonUtils.loadService(IgniteRestExtension.class).forEach(exts::add); + + Set paths = new HashSet<>(); + + paths.add(IGNITE_CMD_PATH); + + for (IgniteRestExtension ext : exts) { + ctx.resource().injectGeneric(ext); + + ServletContextHandler extCtx = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + + if (ctx.security().enabled()) + extCtx.addFilter(new FilterHolder(new AuthenticationFilter(ctx)), "/*", EnumSet.of(DispatcherType.REQUEST)); + + try { + ext.configure(extCtx); + } + catch (Exception e) { + throw new IgniteCheckedException("Failed to configure REST extension: " + ext.getClass().getName(), e); + } + + A.ensure(!extCtx.isContextPathDefault(), "The context path must be configured: " + ext.getClass().getName()); + A.ensure(paths.add(extCtx.getContextPath()), "Duplicate REST context path: " + extCtx.getContextPath()); + + extsHnd.addHandler(extCtx); + + if (log.isInfoEnabled()) + log.info("Configured REST extension: " + ext.getClass().getName()); + } + + return extsHnd; + } + /** * Checks that the only connector configured for the current jetty instance * and returns it. @@ -388,10 +452,22 @@ private void stopJetty() { } } + /** {@inheritDoc} */ + @Override public void onProcessorStart() { + try { + U.startLifecycleAware(exts); + } + catch (IgniteCheckedException e) { + throw new IgniteException("Failed to start REST extensions.", e); + } + } + /** {@inheritDoc} */ @Override public void stop() { stopJetty(); + U.stopLifecycleAware(log, exts); + httpSrv = null; jettyHnd = null; diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtension.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtension.java new file mode 100644 index 0000000000000..275cac62c2274 --- /dev/null +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtension.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.processors.rest.protocols.http.jetty; + +import org.eclipse.jetty.servlet.ServletContextHandler; + +/** + * Extension point for registering custom HTTP REST endpoints in the Jetty REST protocol. + *

+ * Each extension is configured within an isolated {@link ServletContextHandler} instance + * managed by the Ignite REST subsystem. + * Extensions are discovered using Java {@link java.util.ServiceLoader}. + *

+ * Implementations are responsible for: + *

    + *
  • Configuring the servlet context path via + * {@link ServletContextHandler#setContextPath(String)}.
  • + *
  • Registering servlets, filters, and related HTTP components.
  • + *
+ *

+ * Context paths must be unique across all registered extensions. + *

+ * Authentication and other common infrastructure are configured by Ignite. + */ +public interface IgniteRestExtension { + /** + * Configures the REST extension. + * + * @param ctx Servlet context handler dedicated to this extension. + * @throws Exception If configuration failed. + */ + void configure(ServletContextHandler ctx) throws Exception; +} diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/WelcomeHandler.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/WelcomeHandler.java new file mode 100644 index 0000000000000..8efa5fe1507cf --- /dev/null +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/WelcomeHandler.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.processors.rest.protocols.http.jetty; + +import java.io.IOException; +import java.io.InputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.ignite.IgniteLogger; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; + +/** + * Handles welcome page. + */ +public class WelcomeHandler extends AbstractHandler { + /** Default page. */ + private final byte[] dfltPage; + + /** Favicon. */ + private final byte[] favicon; + + /** Logo. */ + private final byte[] logo; + + /** */ + private final IgniteLogger log; + + /** */ + public WelcomeHandler(IgniteLogger log) { + this.log = log; + + favicon = loadResource("ignite-rest-http/favicon.ico"); + dfltPage = loadResource("ignite-rest-http/rest.html"); + logo = loadResource("ignite-rest-http/logo.svg"); + } + + /** {@inheritDoc} */ + @Override public void handle(String target, Request req, HttpServletRequest srvReq, HttpServletResponse res) throws IOException { + if (dfltPage == null || favicon == null || logo == null) { + res.setStatus(HttpServletResponse.SC_NOT_FOUND); + req.setHandled(true); + + return; + } + + if (target.startsWith("/favicon.ico")) { + res.setContentType("image/x-icon"); + res.getOutputStream().write(favicon); + } + else if (target.startsWith("/logo.svg")) { + res.setContentType("image/svg+xml"); + res.getOutputStream().write(logo); + } + else { + res.setContentType("text/html; charset=utf-8"); + res.getOutputStream().write(dfltPage); + } + + res.getOutputStream().flush(); + + res.setStatus(HttpServletResponse.SC_OK); + req.setHandled(true); + } + + /** */ + private byte[] loadResource(String path) { + try (InputStream in = getClass().getClassLoader().getResourceAsStream(path)) { + return in != null ? in.readAllBytes() : null; + } catch (IOException e) { + log.warning("Failed to load REST resource: {}", path, e); + + return null; + } + } +} diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/rest.html b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/rest.html deleted file mode 100644 index 10c5fc1ab6f87..0000000000000 --- a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/rest.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - Apache Ignite - In-Memory Database and Caching Platform - - - - -
-
-

- - Ignite - In-Memory Database and Caching Platform - -

-

-

REST API

- Ignite REST API supports - external connectivity to Ignite via REST over HTTP. It comes in handy whenever Ignite Java API is not - available directly, but it is still needed to execute Ignite tasks or retrieve cached data. For example, - you can conveniently use Ignite REST API over HTTP from other non-JVM languages, such as Ruby, PHP or Python, - or any other language, whenever local instance of Ignite is not available. -

- Note that PHP REST example is included with Ignite distribution. -

- All REST HTTP commands have the following format: http://1.2.3.4:8080/ignite?cmd=CMD&..., where - 'cmd' is the name of the command followed by other command parameters. Every command may have - different parameters, some of which may be mandatory and some optional. The commands parameters may be - passed either via HTTP GET or POST, whichever one is preferred. -

-
- - diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/favicon.ico b/modules/rest-http/src/main/resources/ignite-rest-http/favicon.ico similarity index 100% rename from modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/favicon.ico rename to modules/rest-http/src/main/resources/ignite-rest-http/favicon.ico diff --git a/modules/rest-http/src/main/resources/ignite-rest-http/logo.svg b/modules/rest-http/src/main/resources/ignite-rest-http/logo.svg new file mode 100644 index 0000000000000..ab96d1dc99e8d --- /dev/null +++ b/modules/rest-http/src/main/resources/ignite-rest-http/logo.svg @@ -0,0 +1 @@ + diff --git a/modules/rest-http/src/main/resources/ignite-rest-http/rest.html b/modules/rest-http/src/main/resources/ignite-rest-http/rest.html new file mode 100644 index 0000000000000..99db51ad0ae22 --- /dev/null +++ b/modules/rest-http/src/main/resources/ignite-rest-http/rest.html @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + Apache Ignite - REST API + + + + + + +
+ + +

REST API

+ +

+ Ignite REST API supports external connectivity to Ignite via REST over HTTP. + It comes in handy whenever Ignite Java API is not available directly, but it + is still needed to execute Ignite tasks or retrieve cached data. +

+ +

+ For example, you can conveniently use Ignite REST API over HTTP from other + non-JVM languages, such as Ruby, PHP, Python, or any other language, + whenever a local instance of Ignite is not available. +

+ +

+ You can find more detailed information about all available REST API commands, + parameters, and examples in the official documentation: + + Apache Ignite — REST API + . +

+ +

Note that PHP REST example is included with Ignite distribution.

+ +

+ All REST HTTP commands have the following format: + http://1.2.3.4:8080/ignite?cmd=CMD&..., where + cmd is the name of the command followed by other command parameters. +

+ +

+ Every command may have different parameters, some of which may be mandatory + and some optional. The command parameters may be passed either via HTTP GET or POST. +

+
+ + From c0c073ebe38876699b576b2ac6c53b29cdc7d483 Mon Sep 17 00:00:00 2001 From: Nikita Amelchev Date: Fri, 15 May 2026 14:04:35 +0300 Subject: [PATCH 2/2] IGNITE-28670 Add support for custom REST HTTP endpoint extensions --- .../http/jetty/GridJettyRestProtocol.java | 5 +- .../protocols/http/jetty/GridRestSuite.java | 3 +- .../http/jetty/IgniteRestExtensionTest.java | 106 ++++++++++++++++++ .../jetty/RestProcessorAuthorizationTest.java | 81 ++++++++----- .../http/jetty/RestSetupSimpleTest.java | 69 +++++++----- ...t.protocols.http.jetty.IgniteRestExtension | 2 + 6 files changed, 210 insertions(+), 56 deletions(-) create mode 100644 modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtensionTest.java create mode 100644 modules/rest-http/src/test/resources/META-INF/services/org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtension diff --git a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java index c132c4201ed46..14d67adf1e9e4 100644 --- a/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java +++ b/modules/rest-http/src/main/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridJettyRestProtocol.java @@ -87,6 +87,9 @@ public class GridJettyRestProtocol extends GridRestProtocolAdapter { } } + /** Default Jetty port. */ + public static final String DFLT_JETTY_PORT = "8080"; + /** Object mapper class name. */ private static final String IGNITE_OBJECT_MAPPER = "org.apache.ignite.internal.jackson.IgniteObjectMapper"; @@ -280,7 +283,7 @@ private void loadJettyConfiguration(@Nullable URL cfgUrl) throws IgniteCheckedEx httpCfg.setSendServerVersion(true); httpCfg.setSendDateHeader(true); - String srvPortStr = System.getProperty(IGNITE_JETTY_PORT, "8080"); + String srvPortStr = System.getProperty(IGNITE_JETTY_PORT, DFLT_JETTY_PORT); int srvPort; diff --git a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridRestSuite.java b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridRestSuite.java index 06371c494a03f..564edd21074a7 100644 --- a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridRestSuite.java +++ b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/GridRestSuite.java @@ -25,7 +25,8 @@ @RunWith(Suite.class) @Suite.SuiteClasses({ RestProcessorAuthorizationTest.class, - RestSetupSimpleTest.class + RestSetupSimpleTest.class, + IgniteRestExtensionTest.class }) public class GridRestSuite { } diff --git a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtensionTest.java b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtensionTest.java new file mode 100644 index 0000000000000..86d54956b766f --- /dev/null +++ b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/IgniteRestExtensionTest.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.processors.rest.protocols.http.jetty; + +import java.io.IOException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.ignite.Ignite; +import org.apache.ignite.configuration.ConnectorConfiguration; +import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.IgniteVersionUtils; +import org.apache.ignite.internal.util.typedef.T2; +import org.apache.ignite.plugin.security.SecurityException; +import org.apache.ignite.resources.IgniteInstanceResource; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.Test; + +import static org.apache.ignite.cluster.ClusterState.INACTIVE; +import static org.apache.ignite.internal.processors.rest.protocols.http.jetty.RestSetupSimpleTest.execute; +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertThat; + +/** */ +public class IgniteRestExtensionTest extends GridCommonAbstractTest { + /** {@inheritDoc} */ + @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { + return super.getConfiguration(igniteInstanceName) + .setConnectorConfiguration(new ConnectorConfiguration()); + } + + /** */ + @Test + public void test() throws Exception { + startGrid(); + + assertThat(execute("/ignite", new T2<>("cmd", "version")), + containsString(IgniteVersionUtils.VER_STR)); + + assertThat(execute("/ext1/help"), + containsString("Extension 1.")); + + assertThat(execute("/ext2/help"), + containsString("Extension 2.")); + } + + /** */ + public static class TestRestExtension1 implements IgniteRestExtension { + @IgniteInstanceResource + private Ignite ignite; + + /** {@inheritDoc} */ + @Override public void configure(ServletContextHandler ctx) { + ctx.setContextPath("/ext1"); + + ctx.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + res.getWriter().print("Extension 1."); + } + }), "/help"); + + ctx.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + try { + ignite.cluster().state(INACTIVE); + } + catch (SecurityException e) { + res.setStatus(HttpServletResponse.SC_FORBIDDEN); + res.getWriter().print("Authorization failed."); + } + } + }), "/deactivate"); + } + } + + /** */ + public static class TestRestExtension2 implements IgniteRestExtension { + /** {@inheritDoc} */ + @Override public void configure(ServletContextHandler ctx) { + ctx.setContextPath("/ext2"); + + ctx.addServlet(new ServletHolder(new HttpServlet() { + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + res.getWriter().print("Extension 2."); + } + }), "/help"); + } + } +} diff --git a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestProcessorAuthorizationTest.java b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestProcessorAuthorizationTest.java index 961fd602dd421..81177027b56f3 100644 --- a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestProcessorAuthorizationTest.java +++ b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestProcessorAuthorizationTest.java @@ -17,16 +17,10 @@ package org.apache.ignite.internal.processors.rest.protocols.http.jetty; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLConnection; import java.security.Permissions; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ignite.cluster.ClusterState; import org.apache.ignite.internal.GridKernalContext; @@ -41,19 +35,25 @@ import org.apache.ignite.internal.processors.security.impl.TestSecurityProcessor; import org.apache.ignite.internal.util.lang.GridTuple3; import org.apache.ignite.internal.util.typedef.F; +import org.apache.ignite.internal.util.typedef.T2; import org.apache.ignite.plugin.PluginProvider; import org.apache.ignite.plugin.security.SecurityException; import org.apache.ignite.plugin.security.SecurityPermission; import org.junit.Test; +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; import static org.apache.ignite.cluster.ClusterState.ACTIVE; import static org.apache.ignite.internal.processors.cache.CacheGetRemoveSkipStoreTest.TEST_CACHE; import static org.apache.ignite.internal.processors.rest.GridRestCommand.CLUSTER_ACTIVATE; import static org.apache.ignite.internal.processors.rest.GridRestCommand.CLUSTER_SET_STATE; import static org.apache.ignite.internal.processors.rest.GridRestCommand.DESTROY_CACHE; import static org.apache.ignite.internal.processors.rest.GridRestCommand.GET_OR_CREATE_CACHE; +import static org.apache.ignite.internal.processors.rest.protocols.http.jetty.RestSetupSimpleTest.execute; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.ALL_PERMISSIONS; import static org.apache.ignite.plugin.security.SecurityPermissionSetBuilder.NO_PERMISSIONS; +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertThat; /** * Tests REST processor authorization commands GET_OR_CREATE_CACHE / DESTROY_CACHE. @@ -113,7 +113,7 @@ public void testCacheCreateDestroyPermission() throws Exception { assertNull(ignite.cache(TEST_CACHE)); - executeCommand(LOGIN, GET_OR_CREATE_CACHE, F.asMap("cacheName", TEST_CACHE)); + executeCommand(LOGIN, GET_OR_CREATE_CACHE, new T2<>("cacheName", TEST_CACHE)); GridTuple3 ctx = authorizationCtxList.get(0); @@ -125,7 +125,7 @@ public void testCacheCreateDestroyPermission() throws Exception { authorizationCtxList.clear(); - executeCommand(LOGIN, DESTROY_CACHE, F.asMap("cacheName", TEST_CACHE)); + executeCommand(LOGIN, DESTROY_CACHE, new T2<>("cacheName", TEST_CACHE)); ctx = authorizationCtxList.get(0); @@ -143,13 +143,13 @@ public void testClusterStateChange() throws Exception { assertEquals(ClusterState.INACTIVE, ignite.cluster().state()); - GridRestResponse res = executeCommand(LOGIN_NO_PERMISSIONS, CLUSTER_SET_STATE, F.asMap("state", ACTIVE.name())); + GridRestResponse res = executeCommand(LOGIN_NO_PERMISSIONS, CLUSTER_SET_STATE, new T2<>("state", ACTIVE.name())); assertEquals(GridRestResponse.STATUS_SECURITY_CHECK_FAILED, res.getSuccessStatus()); assertEquals(ClusterState.INACTIVE, ignite.cluster().state()); - res = executeCommand(LOGIN, CLUSTER_SET_STATE, F.asMap("state", ACTIVE.name())); + res = executeCommand(LOGIN, CLUSTER_SET_STATE, new T2<>("state", ACTIVE.name())); assertEquals(GridRestResponse.STATUS_SUCCESS, res.getSuccessStatus()); @@ -163,41 +163,70 @@ public void testOldClusterStateChange() throws Exception { assertEquals(ClusterState.INACTIVE, ignite.cluster().state()); - GridRestResponse res = executeCommand(LOGIN_NO_PERMISSIONS, CLUSTER_ACTIVATE, Collections.emptyMap()); + GridRestResponse res = executeCommand(LOGIN_NO_PERMISSIONS, CLUSTER_ACTIVATE); assertEquals(GridRestResponse.STATUS_SECURITY_CHECK_FAILED, res.getSuccessStatus()); assertEquals(ClusterState.INACTIVE, ignite.cluster().state()); - res = executeCommand(LOGIN, CLUSTER_ACTIVATE, Collections.emptyMap()); + res = executeCommand(LOGIN, CLUSTER_ACTIVATE); assertEquals(GridRestResponse.STATUS_SUCCESS, res.getSuccessStatus()); assertEquals(ACTIVE, ignite.cluster().state()); } + /** @throws Exception if failed. */ + @Test + public void testRestExtension() throws Exception { + IgniteEx ignite = startGrid(0); + + assertThat(execute(SC_UNAUTHORIZED, "/ext1/help"), + containsString("Missing or invalid authentication token (maybe expired session)")); + + String sesToken = authenticate(LOGIN_NO_PERMISSIONS); + + assertThat(execute("/ext1/help", new T2<>("sessionToken", sesToken)), + containsString("Extension 1.")); + + assertThat(execute(SC_FORBIDDEN, "/ext1/deactivate", new T2<>("sessionToken", sesToken)), + containsString("Authorization failed.")); + + sesToken = authenticate(LOGIN); + + execute("/ext1/deactivate", new T2<>("sessionToken", sesToken)); + + assertFalse(ignite.cluster().state().active()); + } + + /** @return Session token. */ + private String authenticate(String login) throws Exception { + String res = execute("/ignite", + new T2<>("cmd", "authenticate"), + new T2<>("ignite.login", login), + new T2<>("ignite.password", PWD)); + + return new ObjectMapper().readTree(res).get("sessionToken").asText(); + } + /** */ + @SafeVarargs private GridRestResponse executeCommand( String login, GridRestCommand cmd, - Map params - ) throws IOException { - StringBuilder addr = new StringBuilder("http://localhost:8080/ignite?cmd=").append(cmd.key()) - .append("&ignite.login=").append(login) - .append("&ignite.password=").append(PWD); - - for (Map.Entry e : params.entrySet()) - addr.append("&").append(e.getKey()).append("=").append(e.getValue()); - - URL url = new URL(addr.toString()); + T2... params + ) throws Exception { + T2[] allParams = new T2[params.length + 3]; - URLConnection conn = url.openConnection(); + allParams[0] = new T2<>("cmd", cmd.key()); + allParams[1] = new T2<>("ignite.login", login); + allParams[2] = new T2<>("ignite.password", PWD); - conn.connect(); + System.arraycopy(params, 0, allParams, 3, params.length); - assertEquals(200, ((HttpURLConnection)conn).getResponseCode()); + String res = execute("/ignite", allParams); - return new ObjectMapper().readValue(conn.getInputStream(), GridRestResponse.class); + return new ObjectMapper().readValue(res, GridRestResponse.class); } /** {@inheritDoc} */ diff --git a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestSetupSimpleTest.java b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestSetupSimpleTest.java index 95b2d0068aa8e..59265a499a723 100644 --- a/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestSetupSimpleTest.java +++ b/modules/rest-http/src/test/java/org/apache/ignite/internal/processors/rest/protocols/http/jetty/RestSetupSimpleTest.java @@ -17,57 +17,70 @@ package org.apache.ignite.internal.processors.rest.protocols.http.jetty; -import java.io.InputStreamReader; -import java.net.URL; -import java.net.URLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.Map; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ignite.configuration.ConnectorConfiguration; import org.apache.ignite.configuration.IgniteConfiguration; +import org.apache.ignite.internal.util.GridStringBuilder; +import org.apache.ignite.internal.util.typedef.T2; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static org.apache.ignite.internal.processors.rest.protocols.http.jetty.GridJettyRestProtocol.DFLT_JETTY_PORT; + /** * Integration test for Grid REST functionality; Jetty is under the hood. */ public class RestSetupSimpleTest extends GridCommonAbstractTest { - /** Jetty port. */ - private static final int JETTY_PORT = 8080; - /** {@inheritDoc} */ @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { - IgniteConfiguration configuration = super.getConfiguration(igniteInstanceName); + return super.getConfiguration(igniteInstanceName) + .setConnectorConfiguration(new ConnectorConfiguration()); + } + + /** Runs version command using GridJettyRestProtocol. */ + @Test + public void testVersionCommand() throws Exception { + startGrid(); - configuration.setConnectorConfiguration(new ConnectorConfiguration()); + String res = execute("/ignite", new T2<>("cmd", "version")); - return configuration; + Map val = new ObjectMapper().readValue(res, new TypeReference<>() {}); + + log.info("Version command response is: " + val); + + assertTrue(val.containsKey("response")); + assertEquals(0, val.get("successStatus")); } - /** {@inheritDoc} */ - @Override protected void beforeTestsStarted() throws Exception { - startGrid(0); + /** */ + @SafeVarargs + public static String execute(String path, T2... params) throws Exception { + return execute(SC_OK, path, params); } - /** - * Runs version command using GridJettyRestProtocol. - */ - @Test - public void testVersionCommand() throws Exception { - URLConnection conn = new URL("http://localhost:" + JETTY_PORT + "/ignite?cmd=version").openConnection(); + /** */ + @SafeVarargs + public static String execute(int expCode, String path, T2... params) throws Exception { + GridStringBuilder url = new GridStringBuilder("http://localhost:" + DFLT_JETTY_PORT + path + "?"); + + for (T2 p : params) + url.a(p.get1()).a("=").a(p.get2()).a("&"); - conn.connect(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url.toString())) + .build(); - try (InputStreamReader streamReader = new InputStreamReader(conn.getInputStream())) { - ObjectMapper objMapper = new ObjectMapper(); - Map myMap = objMapper.readValue(streamReader, - new TypeReference>() { - }); + HttpResponse res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()); - log.info("Version command response is: " + myMap); + assertEquals(expCode, res.statusCode()); - assertTrue(myMap.containsKey("response")); - assertEquals(0, myMap.get("successStatus")); - } + return res.body(); } } diff --git a/modules/rest-http/src/test/resources/META-INF/services/org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtension b/modules/rest-http/src/test/resources/META-INF/services/org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtension new file mode 100644 index 0000000000000..5952f2022c62e --- /dev/null +++ b/modules/rest-http/src/test/resources/META-INF/services/org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtension @@ -0,0 +1,2 @@ +org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtensionTest$TestRestExtension1 +org.apache.ignite.internal.processors.rest.protocols.http.jetty.IgniteRestExtensionTest$TestRestExtension2