diff --git a/bundles/org.openhab.binding.http/README.md b/bundles/org.openhab.binding.http/README.md index b2388246fec31..ef0995ebb6aec 100644 --- a/bundles/org.openhab.binding.http/README.md +++ b/bundles/org.openhab.binding.http/README.md @@ -9,24 +9,26 @@ It can be extended with different channels. ## Thing Configuration -| parameter | optional | default | description | -|-------------------|----------|---------|-------------| -| `baseURL` | no | - | The base URL for this thing. Can be extended in channel-configuration. | -| `refresh` | no | 30 | Time in seconds between two refresh calls for the channels of this thing. | -| `timeout` | no | 3000 | Timeout for HTTP requests in ms. | -| `bufferSize` | no | 2048 | The buffer size for the response data (in kB). | -| `delay` | no | 0 | Delay between two requests in ms (advanced parameter). | -| `username` | yes | - | Username for authentication (advanced parameter). | -| `password` | yes | - | Password for authentication (advanced parameter). | -| `authMode` | no | BASIC | Authentication mode, `BASIC`, `BASIC_PREEMPTIVE` or `DIGEST` (advanced parameter). | -| `stateMethod` | no | GET | Method used for requesting the state: `GET`, `PUT`, `POST`. | -| `commandMethod` | no | GET | Method used for sending commands: `GET`, `PUT`, `POST`. | -| `contentType` | yes | - | MIME content-type of the command requests. Only used for `PUT` and `POST`. | -| `encoding` | yes | - | Encoding to be used if no encoding is found in responses (advanced parameter). | -| `headers` | yes | - | Additional headers that are sent along with the request. Format is "header=value". Multiple values can be stored as `headers="key1=value1", "key2=value2", "key3=value3",`. When using text based configuration include at minimum 2 headers to avoid parsing errors.| -| `ignoreSSLErrors` | no | false | If set to true ignores invalid SSL certificate errors. This is potentially dangerous.| - -_Note:_ Optional "no" means that you have to configure a value unless a default is provided and you are ok with that setting. +| parameter | optional | default | description | +|-----------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `baseURL` | no | - | The base URL (including protocol `http://` or `https://`) for this thing. Can be extended in channel-configuration. | +| `refresh` | no | 30 | Time in seconds between two refresh calls for the channels of this thing. | +| `timeout` | no | 3000 | Timeout for HTTP requests in ms. | +| `bufferSize` | no | 2048 | The buffer size for the response data (in kB). | +| `delay` | no | 0 | Delay between two requests in ms (advanced parameter). | +| `username` | yes | - | Username for authentication (advanced parameter). | +| `password` | yes | - | Password for authentication (advanced parameter). Also used for the authentication token when using `TOKEN` authentication. | +| `authMode` | no | BASIC | Authentication mode, `BASIC`, `BASIC_PREEMPTIVE`, `TOKEN` or `DIGEST` (advanced parameter). | +| `stateMethod` | no | GET | Method used for requesting the state: `GET`, `PUT`, `POST`. | +| `commandMethod` | no | GET | Method used for sending commands: `GET`, `PUT`, `POST`. | +| `contentType` | yes | - | MIME content-type of the command requests. Only used for `PUT` and `POST`. | +| `encoding` | yes | - | Encoding to be used if no encoding is found in responses (advanced parameter). | +| `headers` | yes | - | Additional headers that are sent along with the request. Format is "header=value". Multiple values can be stored as `headers="key1=value1", "key2=value2", "key3=value3",` | +| `ignoreSSLErrors` | no | false | If set to true, ignores invalid SSL certificate errors. This is potentially dangerous. | +| `strictErrorHandling` | no | false | If set to true, thing status is changed depending on last request result (failed = `OFFLINE`). Failed requests result in `UNDEF` for channel values. | +| `userAgent` | yes | (yes ) | Sets a custom user agent (default is "Jetty/version", e.g. "Jetty/9.4.20.v20190813"). | + +_Note:_ Optional "no" means that you have to configure a value unless a default is provided, and you are ok with that setting. _Note:_ The `BASIC_PREEMPTIVE` mode adds basic authentication headers even if the server did not request authentication. This is dangerous and might be misused. @@ -35,30 +37,29 @@ Authentication might fail if redirections are involved as headers are stripper p _Note:_ If you rate-limit requests by using the `delay` parameter you have to make sure that the time between two refreshes is larger than the time needed for one refresh cycle. -**Attention:** `baseUrl` (and `stateExtension`/`commandExtension`) should not normally use escaping (e.g. `%22` instead of `"` or `%2c` instead of `,`). +**Attention:** `baseUrl` (and `stateExtension`/`commandExtension`) should not use escaping (e.g. `%22` instead of `"` or `%2c` instead of `,`). URLs are properly escaped by the binding itself before the request is sent. Using escaped strings in URL parameters may lead to problems with the formatting (see below). -In certain scenarios you may need to manually escape your URL, for example if you need to include an escaped `=` (`%3D`) in this scenario include `%%3D` in the URL to preserve the `%` during formatting, and set the parameter `escapedUrl` to true on the channel. - ## Channels +The thing has two channels of type `request-date-time` which provide the timestamp of the last successful (`last-success`) and last failed (`last-failure`) request. + +Additionally, the thing can be extended with data channels. Each item type has its own channel-type. Depending on the channel-type, channels have different configuration options. All channel-types (except `image`) have `stateExtension`, `commandExtension`, `stateTransformation`, `commandTransformation` and `mode` parameters. -The `image` channel-type supports `stateExtension`, `stateContent` and `escapedUrl` only. - -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `stateExtension` | yes | - | Appended to the `baseURL` for requesting states. | -| `commandExtension` | yes | - | Appended to the `baseURL` for sending commands. If empty, same as `stateExtension`. | -| `stateTransformation` | yes | - | One or more transformation applied to received values before updating channel. | -| `commandTransformation` | yes | - | One or more transformation applied to channel value before sending to a remote. | -| `escapedUrl` | yes | - | This specifies whether the URL is already escaped. | -| `stateContent` | yes | - | Content for state requests (if method is `PUT` or `POST`) | +The `image` channel-type supports `stateExtension` only. + +| parameter | optional | default | description | +|-------------------------|----------|-------------|-----------------------------------------------------------------------------------------------------------| +| `stateExtension` | yes | - | Appended to the `baseURL` for requesting states. | +| `commandExtension` | yes | - | Appended to the `baseURL` for sending commands. If empty, same as `stateExtension`. | +| `stateTransformation ` | yes | - | One or more transformation applied to received values before updating channel. | +| `commandTransformation` | yes | - | One or more transformation applied to channel value before sending to a remote. | +| `stateContent` | yes | - | Content for state requests (if method is `PUT` or `POST`) | | `mode` | no | `READWRITE` | Mode this channel is allowed to operate. `READONLY` means receive state, `WRITEONLY` means send commands. | -Transformations need to be specified in the same format as Some channels have additional parameters. When concatenating the `baseURL` and `stateExtension` or `commandExtension` the binding checks if a proper URL part separator (`/`, `&` or `?`) is present and adds a `/` if missing. @@ -80,41 +81,41 @@ The same mechanism works for commands (`commandTransformation`) for outgoing val ### `color` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `onValue` | yes | - | A special value that represents `ON` | -| `offValue` | yes | - | A special value that represents `OFF` | -| `increaseValue` | yes | - | A special value that represents `INCREASE` | -| `decreaseValue` | yes | - | A special value that represents `DECREASE` | -| `step` | no | 1 | The amount the brightness is increased/decreased on `INCREASE`/`DECREASE` | -| `colorMode` | no | RGB | Mode for color values: `RGB` or `HSB` | +| parameter | optional | default | description | +|-----------------|----------|---------|---------------------------------------------------------------------------| +| `onValue` | yes | - | A special value that represents `ON` | +| `offValue` | yes | - | A special value that represents `OFF` | +| `increaseValue` | yes | - | A special value that represents `INCREASE` | +| `decreaseValue` | yes | - | A special value that represents `DECREASE` | +| `step` | no | 1 | The amount the brightness is increased/decreased on `INCREASE`/`DECREASE` | +| `colorMode` | no | RGB | Mode for color values: `RGB` or `HSB` | All values that are not `onValue`, `offValue`, `increaseValue`, `decreaseValue` are interpreted as color value (according to the color mode) in the format `r,g,b` or `h,s,v`. ### `contact` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `openValue` | no | - | A special value that represents `OPEN` | -| `closedValue` | no | - | A special value that represents `CLOSED` | +| parameter | optional | default | description | +|---------------|----------|---------|------------------------------------------| +| `openValue` | no | - | A special value that represents `OPEN` | +| `closedValue` | no | - | A special value that represents `CLOSED` | ### `dimmer` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `onValue` | yes | - | A special value that represents `ON` | -| `offValue` | yes | - | A special value that represents `OFF` | -| `increaseValue` | yes | - | A special value that represents `INCREASE` | -| `decreaseValue` | yes | - | A special value that represents `DECREASE` | -| `step` | no | 1 | The amount the brightness is increased/decreased on `INCREASE`/`DECREASE` | +| parameter | optional | default | description | +|-----------------|----------|---------|---------------------------------------------------------------------------| +| `onValue` | yes | - | A special value that represents `ON` | +| `offValue` | yes | - | A special value that represents `OFF` | +| `increaseValue` | yes | - | A special value that represents `INCREASE` | +| `decreaseValue` | yes | - | A special value that represents `DECREASE` | +| `step` | no | 1 | The amount the brightness is increased/decreased on `INCREASE`/`DECREASE` | All values that are not `onValue`, `offValue`, `increaseValue`, `decreaseValue` are interpreted as brightness 0-100% and need to be numeric only. ### `number` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `unit` | yes | - | The unit label for this channel | +| parameter | optional | default | description | +|-----------|----------|---------|---------------------------------| +| `unit` | yes | - | The unit label for this channel | `number` channels can be used for `DecimalType` or `QuantityType` values. If a unit is given in the `unit` parameter, the binding tries to create a `QuantityType` state before updating the channel, if no unit is present, it creates a `DecimalType`. @@ -122,38 +123,38 @@ Please note that incompatible units (e.g. `°C` for a `Number:Density` item) wil ### `player` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `play` | yes | - | A special value that represents `PLAY` | -| `pause` | yes | - | A special value that represents `PAUSE` | -| `next` | yes | - | A special value that represents `NEXT` | -| `previous` | yes | - | A special value that represents `PREVIOUS` | -| `fastforward` | yes | - | A special value that represents `FASTFORWARD` | -| `rewind` | yes | - | A special value that represents `REWIND` | +| parameter | optional | default | description | +|--------------------|----------|---------|-----------------------------------------------| +| `playValue` | yes | - | A special value that represents `PLAY` | +| `pauseValue` | yes | - | A special value that represents `PAUSE` | +| `nextValue` | yes | - | A special value that represents `NEXT` | +| `previousValue` | yes | - | A special value that represents `PREVIOUS` | +| `fastforwardValue` | yes | - | A special value that represents `FASTFORWARD` | +| `rewindValue` | yes | - | A special value that represents `REWIND` | ### `rollershutter` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `upValue` | yes | - | A special value that represents `UP` | -| `downValue` | yes | - | A special value that represents `DOWN` | -| `stopValue` | yes | - | A special value that represents `STOP` | -| `moveValue` | yes | - | A special value that represents `MOVE` | +| parameter | optional | default | description | +|-------------|----------|---------|----------------------------------------| +| `upValue` | yes | - | A special value that represents `UP` | +| `downValue` | yes | - | A special value that represents `DOWN` | +| `stopValue` | yes | - | A special value that represents `STOP` | +| `moveValue` | yes | - | A special value that represents `MOVE` | All values that are not `upValue`, `downValue`, `stopValue`, `moveValue` are interpreted as position 0-100% and need to be numeric only. - + ### `switch` -| parameter | optional | default | description | -|-------------------------|----------|-------------|-------------| -| `onValue` | no | - | A special value that represents `ON` | -| `offValue` | no | - | A special value that represents `OFF` | +| parameter | optional | default | description | +|------------|----------|---------|---------------------------------------| +| `onValue` | no | - | A special value that represents `ON` | +| `offValue` | no | - | A special value that represents `OFF` | **Note:** Special values need to be exact matches, i.e. no leading or trailing characters and comparison is case-sensitive. ## URL Formatting -After concatenation of the `baseURL` and the `commandExtension` or the `stateExtension` (if provided) the URL is formatted using the [java.util.Formatter](https://docs.oracle.com/javase/6/docs/api/java/util/Formatter.html). +After concatenation of the `baseURL` and the `commandExtension` or the `stateExtension` (if provided) the URL is formatted using the [java.util.Formatter](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Formatter.html). The URL is used as format string and two parameters are added: - the current date (referenced as `%1$`) @@ -163,13 +164,13 @@ After the parameter reference the format needs to be appended. See the link above for more information about the available format parameters (e.g. to use the string representation, you need to append `s` to the reference, for a timestamp `t`). When sending an OFF command on 2020-07-06, the URL -```text -http://www.domain.org/home/lights/23871/?status=%2$s&date=%1$tY-%1$tm-%1$td ``` +http://www.domain.org/home/lights/23871/?status=%2$s&date=%1$tY-%1$tm-%1$td +``` is transformed to -```text +``` http://www.domain.org/home/lights/23871/?status=OFF&date=2020-07-06 ``` @@ -179,10 +180,10 @@ http://www.domain.org/home/lights/23871/?status=OFF&date=2020-07-06 ```java Thing http:url:foo "Foo" [ - baseURL="https://example.com/api/v1/metadata-api/web/metadata", + baseURL="https://example.com/api/v1/metadata-api/web/metadata", headers="key1=value1", "key2=value2", "key3=value3", refresh=15] { Channels: - Type string : text "Text" [ stateTransformation="JSONPATH:$.metadata.data" ] + Type string : text "Text" [ stateTransformation="JSONPATH:$.metadata.data" ] } ``` diff --git a/bundles/org.openhab.binding.http/pom.xml b/bundles/org.openhab.binding.http/pom.xml index a325ad38096d2..eeb82d145edfe 100644 --- a/bundles/org.openhab.binding.http/pom.xml +++ b/bundles/org.openhab.binding.http/pom.xml @@ -14,4 +14,47 @@ openHAB Add-ons :: Bundles :: HTTP Binding + + 9.4.52.v20230823 + + + + + org.eclipse.jetty + jetty-server + ${jetty.version} + test + + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + test + + + org.eclipse.jetty + jetty-servlets + ${jetty.version} + test + + + org.eclipse.jetty + jetty-proxy + ${jetty.version} + test + + + org.eclipse.jetty + jetty-webapp + ${jetty.version} + test + + + + com.github.tomakehurst + wiremock + 2.27.2 + test + + diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpBindingConstants.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpBindingConstants.java index 28e6724976ed8..eb2fae7e04646 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpBindingConstants.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpBindingConstants.java @@ -14,6 +14,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.type.ChannelTypeUID; /** * The {@link HttpBindingConstants} class defines common constants, which are @@ -23,8 +24,12 @@ */ @NonNullByDefault public class HttpBindingConstants { - - public static final String BINDING_ID = "http"; + private static final String BINDING_ID = "http"; public static final ThingTypeUID THING_TYPE_URL = new ThingTypeUID(BINDING_ID, "url"); + + public static final ChannelTypeUID REQUEST_DATE_TIME_CHANNELTYPE_UID = new ChannelTypeUID(BINDING_ID, + "request-date-time"); + public static final String CHANNEL_LAST_SUCCESS = "last-success"; + public static final String CHANNEL_LAST_FAILURE = "last-failure"; } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpHandlerFactory.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpHandlerFactory.java index 4ca427d8ee29f..401d9c5dcbf9c 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpHandlerFactory.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpHandlerFactory.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.http.internal; -import static org.openhab.binding.http.internal.HttpBindingConstants.*; +import static org.openhab.binding.http.internal.HttpBindingConstants.THING_TYPE_URL; import java.util.Set; @@ -20,17 +20,13 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.util.ssl.SslContextFactory; -import org.openhab.binding.http.internal.transform.CascadedValueTransformationImpl; -import org.openhab.binding.http.internal.transform.NoOpValueTransformation; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.binding.http.internal.transform.ValueTransformationProvider; +import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; -import org.openhab.core.transform.TransformationHelper; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -46,8 +42,7 @@ */ @NonNullByDefault @Component(configurationPid = "binding.http", service = ThingHandlerFactory.class) -public class HttpHandlerFactory extends BaseThingHandlerFactory - implements ValueTransformationProvider, HttpClientProvider { +public class HttpHandlerFactory extends BaseThingHandlerFactory implements HttpClientProvider { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_URL); private final Logger logger = LoggerFactory.getLogger(HttpHandlerFactory.class); @@ -55,22 +50,27 @@ public class HttpHandlerFactory extends BaseThingHandlerFactory private final HttpClient insecureClient; private final HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider; + private final TimeZoneProvider timeZoneProvider; @Activate public HttpHandlerFactory(@Reference HttpClientFactory httpClientFactory, - @Reference HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider) { - this.secureClient = httpClientFactory.createHttpClient(BINDING_ID + "-secure", new SslContextFactory.Client()); - this.insecureClient = httpClientFactory.createHttpClient(BINDING_ID + "-insecure", - new SslContextFactory.Client(true)); + @Reference HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider, + @Reference TimeZoneProvider timeZoneProvider) { + this.secureClient = new HttpClient(new SslContextFactory.Client()); + this.insecureClient = new HttpClient(new SslContextFactory.Client(true)); + // clear user agent, this needs to be set later in the thing configuration as additional header + this.secureClient.setUserAgentField(null); + this.insecureClient.setUserAgentField(null); try { this.secureClient.start(); this.insecureClient.start(); } catch (Exception e) { // catching exception is necessary due to the signature of HttpClient.start() - logger.warn("Failed to start insecure http client: {}", e.getMessage()); - throw new IllegalStateException("Could not create insecure HttpClient"); + logger.warn("Failed to start http client: {}", e.getMessage()); + throw new IllegalStateException("Could not create HttpClient", e); } this.httpDynamicStateDescriptionProvider = httpDynamicStateDescriptionProvider; + this.timeZoneProvider = timeZoneProvider; } @Deactivate @@ -94,21 +94,12 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_URL.equals(thingTypeUID)) { - return new HttpThingHandler(thing, this, this, httpDynamicStateDescriptionProvider); + return new HttpThingHandler(thing, this, httpDynamicStateDescriptionProvider, timeZoneProvider); } return null; } - @Override - public ValueTransformation getValueTransformation(@Nullable String pattern) { - if (pattern == null || pattern.isEmpty()) { - return NoOpValueTransformation.getInstance(); - } - return new CascadedValueTransformationImpl(pattern, - name -> TransformationHelper.getTransformationService(bundleContext, name)); - } - @Override public HttpClient getSecureClient() { return secureClient; diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpThingHandler.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpThingHandler.java index db7926da93f53..4ed71b5a72727 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpThingHandler.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/HttpThingHandler.java @@ -12,13 +12,20 @@ */ package org.openhab.binding.http.internal; +import static org.openhab.binding.http.internal.HttpBindingConstants.CHANNEL_LAST_FAILURE; +import static org.openhab.binding.http.internal.HttpBindingConstants.CHANNEL_LAST_SUCCESS; +import static org.openhab.binding.http.internal.HttpBindingConstants.REQUEST_DATE_TIME_CHANNELTYPE_UID; + import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.time.Instant; import java.util.Base64; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -26,33 +33,18 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.AuthenticationStore; -import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.util.BasicAuthentication; import org.eclipse.jetty.client.util.DigestAuthentication; -import org.eclipse.jetty.client.util.StringContentProvider; -import org.eclipse.jetty.http.HttpMethod; import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.config.HttpChannelMode; import org.openhab.binding.http.internal.config.HttpThingConfig; -import org.openhab.binding.http.internal.converter.AbstractTransformingItemConverter; -import org.openhab.binding.http.internal.converter.ColorItemConverter; -import org.openhab.binding.http.internal.converter.DimmerItemConverter; -import org.openhab.binding.http.internal.converter.FixedValueMappingItemConverter; -import org.openhab.binding.http.internal.converter.GenericItemConverter; -import org.openhab.binding.http.internal.converter.ImageItemConverter; -import org.openhab.binding.http.internal.converter.ItemValueConverter; -import org.openhab.binding.http.internal.converter.NumberItemConverter; -import org.openhab.binding.http.internal.converter.PlayerItemConverter; -import org.openhab.binding.http.internal.converter.RollershutterItemConverter; -import org.openhab.binding.http.internal.http.Content; import org.openhab.binding.http.internal.http.HttpAuthException; import org.openhab.binding.http.internal.http.HttpResponseListener; +import org.openhab.binding.http.internal.http.HttpStatusListener; import org.openhab.binding.http.internal.http.RateLimitedHttpClient; import org.openhab.binding.http.internal.http.RefreshingUrlCache; -import org.openhab.binding.http.internal.transform.ValueTransformationProvider; +import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.PointType; import org.openhab.core.library.types.StringType; @@ -62,6 +54,19 @@ import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.generic.ChannelHandler; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; +import org.openhab.core.thing.binding.generic.ChannelMode; +import org.openhab.core.thing.binding.generic.ChannelTransformation; +import org.openhab.core.thing.binding.generic.converter.ColorChannelHandler; +import org.openhab.core.thing.binding.generic.converter.DimmerChannelHandler; +import org.openhab.core.thing.binding.generic.converter.FixedValueMappingChannelHandler; +import org.openhab.core.thing.binding.generic.converter.GenericChannelHandler; +import org.openhab.core.thing.binding.generic.converter.ImageChannelHandler; +import org.openhab.core.thing.binding.generic.converter.NumberChannelHandler; +import org.openhab.core.thing.binding.generic.converter.PlayerChannelHandler; +import org.openhab.core.thing.binding.generic.converter.RollershutterChannelHandler; +import org.openhab.core.thing.internal.binding.generic.converter.AbstractTransformingChannelHandler; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; @@ -77,35 +82,33 @@ * @author Jan N. Klug - Initial contribution */ @NonNullByDefault -public class HttpThingHandler extends BaseThingHandler { +public class HttpThingHandler extends BaseThingHandler implements HttpStatusListener { private static final Set URL_PART_DELIMITER = Set.of('/', '?', '&'); private final Logger logger = LoggerFactory.getLogger(HttpThingHandler.class); - private final ValueTransformationProvider valueTransformationProvider; private final HttpClientProvider httpClientProvider; - private HttpClient httpClient; - private RateLimitedHttpClient rateLimitedHttpClient; + private final RateLimitedHttpClient rateLimitedHttpClient; private final HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider; + private final TimeZoneProvider timeZoneProvider; private HttpThingConfig config = new HttpThingConfig(); private final Map urlHandlers = new HashMap<>(); - private final Map channels = new HashMap<>(); + private final Map channels = new HashMap<>(); private final Map channelUrls = new HashMap<>(); public HttpThingHandler(Thing thing, HttpClientProvider httpClientProvider, - ValueTransformationProvider valueTransformationProvider, - HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider) { + HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider, + TimeZoneProvider timeZoneProvider) { super(thing); this.httpClientProvider = httpClientProvider; - this.httpClient = httpClientProvider.getSecureClient(); - this.rateLimitedHttpClient = new RateLimitedHttpClient(httpClient, scheduler); - this.valueTransformationProvider = valueTransformationProvider; + this.rateLimitedHttpClient = new RateLimitedHttpClient(httpClientProvider.getSecureClient(), scheduler); this.httpDynamicStateDescriptionProvider = httpDynamicStateDescriptionProvider; + this.timeZoneProvider = timeZoneProvider; } @Override public void handleCommand(ChannelUID channelUID, Command command) { - ItemValueConverter itemValueConverter = channels.get(channelUID); + ChannelHandler itemValueConverter = channels.get(channelUID); if (itemValueConverter == null) { logger.warn("Cannot find channel implementation for channel {}.", channelUID); return; @@ -117,7 +120,11 @@ public void handleCommand(ChannelUID channelUID, Command command) { RefreshingUrlCache refreshingUrlCache = urlHandlers.get(key); if (refreshingUrlCache != null) { try { - refreshingUrlCache.get().ifPresent(itemValueConverter::process); + refreshingUrlCache.get().ifPresentOrElse(itemValueConverter::process, () -> { + if (config.strictErrorHandling) { + itemValueConverter.process(null); + } + }); } catch (IllegalArgumentException | IllegalStateException e) { logger.warn("Failed processing REFRESH command for channel {}: {}", channelUID, e.getMessage()); } @@ -144,40 +151,68 @@ public void initialize() { return; } + // check protocol is set + if (!config.baseURL.startsWith("http://") && !config.baseURL.startsWith("https://")) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "baseURL is invalid: protocol not defined."); + return; + } + // check SSL handling and initialize client if (config.ignoreSSLErrors) { logger.info("Using the insecure client for thing '{}'.", thing.getUID()); - httpClient = httpClientProvider.getInsecureClient(); + rateLimitedHttpClient.setHttpClient(httpClientProvider.getInsecureClient()); } else { logger.info("Using the secure client for thing '{}'.", thing.getUID()); - httpClient = httpClientProvider.getSecureClient(); + rateLimitedHttpClient.setHttpClient(httpClientProvider.getSecureClient()); } - rateLimitedHttpClient.setHttpClient(httpClient); rateLimitedHttpClient.setDelay(config.delay); - int channelCount = thing.getChannels().size(); - if (channelCount * config.delay > config.refresh * 1000) { + int urlHandlerCount = urlHandlers.size(); + if (urlHandlerCount * config.delay > config.refresh * 1000) { // this should prevent the rate limit queue from filling up - config.refresh = (channelCount * config.delay) / 1000 + 1; + config.refresh = (urlHandlerCount * config.delay) / 1000 + 1; logger.warn( "{} channels in thing {} with a delay of {} incompatible with the configured refresh time. Refresh-Time increased to the minimum of {}", - channelCount, thing.getUID(), config.delay, config.refresh); + urlHandlerCount, thing.getUID(), config.delay, config.refresh); } // remove empty headers config.headers.removeIf(String::isBlank); // configure authentication - if (!config.username.isEmpty()) { - try { - AuthenticationStore authStore = httpClient.getAuthenticationStore(); - URI uri = new URI(config.baseURL); + try { + AuthenticationStore authStore = rateLimitedHttpClient.getAuthenticationStore(); + URI uri = new URI(config.baseURL); + + // clear old auths if available + Authentication.Result authResult = authStore.findAuthenticationResult(uri); + if (authResult != null) { + authStore.removeAuthenticationResult(authResult); + } + for (String authType : List.of("Basic", "Digest")) { + Authentication authentication = authStore.findAuthentication(authType, uri, Authentication.ANY_REALM); + if (authentication != null) { + authStore.removeAuthentication(authentication); + } + } + + if (!config.username.isEmpty() || !config.password.isEmpty()) { switch (config.authMode) { case BASIC_PREEMPTIVE: config.headers.add("Authorization=Basic " + Base64.getEncoder() .encodeToString((config.username + ":" + config.password).getBytes())); logger.debug("Preemptive Basic Authentication configured for thing '{}'", thing.getUID()); break; + case TOKEN: + if (!config.password.isEmpty()) { + config.headers.add("Authorization=Bearer " + config.password); + logger.debug("Token/Bearer Authentication configured for thing '{}'", thing.getUID()); + } else { + logger.warn("Token/Bearer Authentication configured for thing '{}' but token is empty!", + thing.getUID()); + } + break; case BASIC: authStore.addAuthentication(new BasicAuthentication(uri, Authentication.ANY_REALM, config.username, config.password)); @@ -192,18 +227,16 @@ public void initialize() { logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode, thing.getUID()); } - } catch (URISyntaxException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "failed to create authentication: baseUrl is invalid"); + } else { + logger.debug("No authentication configured for thing '{}'", thing.getUID()); } - } else { - logger.debug("No authentication configured for thing '{}'", thing.getUID()); + } catch (URISyntaxException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Cannot create URI from baseUrl."); } - // create channels thing.getChannels().forEach(this::createChannel); - updateStatus(ThingStatus.ONLINE); + updateStatus(ThingStatus.UNKNOWN); } @Override @@ -229,6 +262,10 @@ public void dispose() { * @param channel a thing channel */ private void createChannel(Channel channel) { + if (REQUEST_DATE_TIME_CHANNELTYPE_UID.equals(channel.getChannelTypeUID())) { + // do not generate refreshUrls for lastSuccess / lastFailure channels + return; + } ChannelUID channelUID = channel.getUID(); HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class); @@ -242,45 +279,46 @@ private void createChannel(Channel channel) { return; } - ItemValueConverter itemValueConverter; + ChannelHandler itemValueConverter; switch (acceptedItemType) { case "Color": - itemValueConverter = createItemConverter(ColorItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(ColorChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "DateTime": - itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, + itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, DateTimeType::new); break; case "Dimmer": - itemValueConverter = createItemConverter(DimmerItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(DimmerChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "Contact": case "Switch": - itemValueConverter = createItemConverter(FixedValueMappingItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(FixedValueMappingChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "Image": - itemValueConverter = new ImageItemConverter(state -> updateState(channelUID, state)); + itemValueConverter = new ImageChannelHandler(state -> updateState(channelUID, state)); break; case "Location": - itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, PointType::new); + itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, PointType::new); break; case "Number": - itemValueConverter = createItemConverter(NumberItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(NumberChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "Player": - itemValueConverter = createItemConverter(PlayerItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(PlayerChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "Rollershutter": - itemValueConverter = createItemConverter(RollershutterItemConverter::new, commandUrl, channelUID, + itemValueConverter = createChannelHandler(RollershutterChannelHandler::new, commandUrl, channelUID, channelConfig); break; case "String": - itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, StringType::new); + itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, + StringType::new); break; default: logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType()); @@ -288,80 +326,75 @@ private void createChannel(Channel channel) { } channels.put(channelUID, itemValueConverter); - if (channelConfig.mode != HttpChannelMode.WRITEONLY) { + if (channelConfig.mode != ChannelMode.WRITEONLY) { // we need a key consisting of stateContent and URL, only if both are equal, we can use the same cache String key = channelConfig.stateContent + "$" + stateUrl; channelUrls.put(channelUID, key); - urlHandlers - .computeIfAbsent(key, - k -> new RefreshingUrlCache(scheduler, rateLimitedHttpClient, stateUrl, - channelConfig.escapedUrl, config, channelConfig.stateContent)) + Objects.requireNonNull(urlHandlers.computeIfAbsent(key, + k -> new RefreshingUrlCache(scheduler, rateLimitedHttpClient, stateUrl, config, + channelConfig.stateContent, config.contentType, this))) .addConsumer(itemValueConverter::process); } StateDescription stateDescription = StateDescriptionFragmentBuilder.create() - .withReadOnly(channelConfig.mode == HttpChannelMode.READONLY).build().toStateDescription(); + .withReadOnly(channelConfig.mode == ChannelMode.READONLY).build().toStateDescription(); if (stateDescription != null) { // if the state description is not available, we don't need to add it httpDynamicStateDescriptionProvider.setDescription(channelUID, stateDescription); } } - private void sendHttpValue(String commandUrl, boolean escapedUrl, String command) { - sendHttpValue(commandUrl, escapedUrl, command, false); + @Override + public void onHttpError(@Nullable String message) { + updateState(CHANNEL_LAST_FAILURE, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone()))); + if (config.strictErrorHandling) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + Objects.requireNonNullElse(message, "")); + } + } + + @Override + public void onHttpSuccess() { + updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone()))); + updateStatus(ThingStatus.ONLINE); } - private void sendHttpValue(String commandUrl, boolean escapedUrl, String command, boolean isRetry) { + private void sendHttpValue(String commandUrl, String command) { + sendHttpValue(commandUrl, command, false); + } + + private void sendHttpValue(String commandUrl, String command, boolean isRetry) { try { // format URL - String url = String.format(commandUrl, new Date(), command); - URI uri = escapedUrl ? new URI(url) : Util.uriFromString(url); + URI uri = Util.uriFromString(String.format(commandUrl, new Date(), command)); // build request - Request request = httpClient.newRequest(uri).timeout(config.timeout, TimeUnit.MILLISECONDS) - .method(config.commandMethod); - if (config.commandMethod != HttpMethod.GET) { - final String contentType = config.contentType; - if (contentType != null) { - request.content(new StringContentProvider(command), contentType); - } else { - request.content(new StringContentProvider(command)); - } - } - - config.headers.forEach(header -> { - String[] keyValuePair = header.split("=", 2); - if (keyValuePair.length == 2) { - request.header(keyValuePair[0], keyValuePair[1]); - } else { - logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header); - } - }); - - if (logger.isTraceEnabled()) { - logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request)); - } - - CompletableFuture<@Nullable Content> f = new CompletableFuture<>(); - f.exceptionally(e -> { - if (e instanceof HttpAuthException) { - if (isRetry) { - logger.warn("Retry after authentication failure failed again for '{}', failing here", uri); - } else { - AuthenticationStore authStore = httpClient.getAuthenticationStore(); - Authentication.Result authResult = authStore.findAuthenticationResult(uri); - if (authResult != null) { - authStore.removeAuthenticationResult(authResult); - logger.debug("Cleared authentication result for '{}', retrying immediately", uri); - sendHttpValue(commandUrl, escapedUrl, command, true); - } else { - logger.warn("Could not find authentication result for '{}', failing here", uri); + rateLimitedHttpClient.newPriorityRequest(uri, config.commandMethod, command, config.contentType) + .thenAccept(request -> { + request.timeout(config.timeout, TimeUnit.MILLISECONDS); + config.getHeaders().forEach(request::header); + + CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>(); + responseContentFuture.exceptionally(t -> { + if (t instanceof HttpAuthException) { + if (isRetry || !rateLimitedHttpClient.reAuth(uri)) { + logger.warn( + "Retry after authentication failure failed again for '{}', failing here", + uri); + onHttpError("Authentication failed"); + } else { + sendHttpValue(commandUrl, command, true); + } + } + return null; + }); + + if (logger.isTraceEnabled()) { + logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request)); } - } - } - return null; - }); - request.send(new HttpResponseListener(f, null, config.bufferSize)); + + request.send(new HttpResponseListener(responseContentFuture, null, config.bufferSize, this)); + }); } catch (IllegalArgumentException | URISyntaxException | MalformedURLException e) { logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage()); } @@ -380,18 +413,18 @@ private String concatenateUrlParts(String baseUrl, @Nullable String extension) { } } - private ItemValueConverter createItemConverter(AbstractTransformingItemConverter.Factory factory, String commandUrl, + private ChannelHandler createChannelHandler(AbstractTransformingChannelHandler.Factory factory, String commandUrl, ChannelUID channelUID, HttpChannelConfig channelConfig) { return factory.create(state -> updateState(channelUID, state), command -> postCommand(channelUID, command), - command -> sendHttpValue(commandUrl, channelConfig.escapedUrl, command), - valueTransformationProvider.getValueTransformation(channelConfig.stateTransformation), - valueTransformationProvider.getValueTransformation(channelConfig.commandTransformation), channelConfig); + command -> sendHttpValue(commandUrl, command), + new ChannelTransformation(channelConfig.stateTransformation), + new ChannelTransformation(channelConfig.commandTransformation), channelConfig); } - private ItemValueConverter createGenericItemConverter(String commandUrl, ChannelUID channelUID, + private ChannelHandler createGenericChannelHandler(String commandUrl, ChannelUID channelUID, HttpChannelConfig channelConfig, Function toState) { - AbstractTransformingItemConverter.Factory factory = (state, command, value, stateTrans, commandTrans, - config) -> new GenericItemConverter(toState, state, command, value, stateTrans, commandTrans, config); - return createItemConverter(factory, commandUrl, channelUID, channelConfig); + AbstractTransformingChannelHandler.Factory factory = (state, command, value, stateTrans, commandTrans, + config) -> new GenericChannelHandler(toState, state, command, value, stateTrans, commandTrans, config); + return createChannelHandler(factory, commandUrl, channelUID, channelConfig); } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/Util.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/Util.java index 36b46ef8bec60..b0c6ff69e9a69 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/Util.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/Util.java @@ -54,13 +54,14 @@ public static String requestToLogString(Request request) { * create an URI from a string, escaping all necessary characters * * @param s the URI as unescaped string - * @return URI correspondign to the input string - * @throws MalformedURLException - * @throws URISyntaxException + * @return URI corresponding to the input string + * @throws MalformedURLException if parameter is not an URL + * @throws URISyntaxException if parameter could not be converted to an URI */ public static URI uriFromString(String s) throws MalformedURLException, URISyntaxException { URL url = new URL(s); - return new URI(url.getProtocol(), url.getUserInfo(), IDN.toASCII(url.getHost()), url.getPort(), url.getPath(), - url.getQuery(), url.getRef()); + URI uri = new URI(url.getProtocol(), url.getUserInfo(), IDN.toASCII(url.getHost()), url.getPort(), + url.getPath(), url.getQuery(), url.getRef()); + return URI.create(uri.toASCIIString()); } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpAuthMode.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpAuthMode.java index 61849a778e104..d9013d7a036f7 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpAuthMode.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpAuthMode.java @@ -23,5 +23,6 @@ public enum HttpAuthMode { BASIC_PREEMPTIVE, BASIC, - DIGEST + DIGEST, + TOKEN } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelConfig.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelConfig.java index 40454712b9291..c9bf745cb8c99 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelConfig.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelConfig.java @@ -12,23 +12,9 @@ */ package org.openhab.binding.http.internal.config; -import java.math.BigDecimal; -import java.util.HashMap; -import java.util.Map; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.converter.ColorItemConverter; -import org.openhab.core.library.types.IncreaseDecreaseType; -import org.openhab.core.library.types.NextPreviousType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.OpenClosedType; -import org.openhab.core.library.types.PlayPauseType; -import org.openhab.core.library.types.RewindFastforwardType; -import org.openhab.core.library.types.StopMoveType; -import org.openhab.core.library.types.UpDownType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; +import org.openhab.core.thing.binding.generic.ChannelValueConverterConfig; /** * The {@link HttpChannelConfig} class contains fields mapping channel configuration parameters. @@ -36,107 +22,11 @@ * @author Jan N. Klug - Initial contribution */ @NonNullByDefault -public class HttpChannelConfig { - private final Map stringStateMap = new HashMap<>(); - private final Map commandStringMap = new HashMap<>(); - private boolean initialized = false; +public class HttpChannelConfig extends ChannelValueConverterConfig { public @Nullable String stateExtension; public @Nullable String commandExtension; public @Nullable String stateTransformation; public @Nullable String commandTransformation; public String stateContent = ""; - public boolean escapedUrl = false; - - public HttpChannelMode mode = HttpChannelMode.READWRITE; - - // number - public @Nullable String unit; - - // switch, dimmer, color - public @Nullable String onValue; - public @Nullable String offValue; - - // dimmer, color - public BigDecimal step = BigDecimal.ONE; - public @Nullable String increaseValue; - public @Nullable String decreaseValue; - - // color - public ColorItemConverter.ColorMode colorMode = ColorItemConverter.ColorMode.RGB; - - // contact - public @Nullable String openValue; - public @Nullable String closedValue; - - // rollershutter - public @Nullable String upValue; - public @Nullable String downValue; - public @Nullable String stopValue; - public @Nullable String moveValue; - - // player - public @Nullable String playValue; - public @Nullable String pauseValue; - public @Nullable String nextValue; - public @Nullable String previousValue; - public @Nullable String rewindValue; - public @Nullable String fastforwardValue; - - /** - * maps a command to a user-defined string - * - * @param command the command to map - * @return a string or null if no mapping found - */ - public @Nullable String commandToFixedValue(Command command) { - if (!initialized) { - createMaps(); - } - - return commandStringMap.get(command); - } - - /** - * maps a user-defined string to a state - * - * @param string the string to map - * @return the state or null if no mapping found - */ - public @Nullable State fixedValueToState(String string) { - if (!initialized) { - createMaps(); - } - - return stringStateMap.get(string); - } - - private void createMaps() { - addToMaps(this.onValue, OnOffType.ON); - addToMaps(this.offValue, OnOffType.OFF); - addToMaps(this.openValue, OpenClosedType.OPEN); - addToMaps(this.closedValue, OpenClosedType.CLOSED); - addToMaps(this.upValue, UpDownType.UP); - addToMaps(this.downValue, UpDownType.DOWN); - - commandStringMap.put(IncreaseDecreaseType.INCREASE, increaseValue); - commandStringMap.put(IncreaseDecreaseType.DECREASE, decreaseValue); - commandStringMap.put(StopMoveType.STOP, stopValue); - commandStringMap.put(StopMoveType.MOVE, moveValue); - commandStringMap.put(PlayPauseType.PLAY, playValue); - commandStringMap.put(PlayPauseType.PAUSE, pauseValue); - commandStringMap.put(NextPreviousType.NEXT, nextValue); - commandStringMap.put(NextPreviousType.PREVIOUS, previousValue); - commandStringMap.put(RewindFastforwardType.REWIND, rewindValue); - commandStringMap.put(RewindFastforwardType.FASTFORWARD, fastforwardValue); - - initialized = true; - } - - private void addToMaps(@Nullable String value, State state) { - if (value != null) { - commandStringMap.put((Command) state, value); - stringStateMap.put(value, state); - } - } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelMode.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelMode.java deleted file mode 100644 index 579c84b2bb30a..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpChannelMode.java +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.config; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link HttpChannelMode} enum defines control modes for channels - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public enum HttpChannelMode { - READONLY, - READWRITE, - WRITEONLY -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpThingConfig.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpThingConfig.java index 9f03d5c62bbd4..4c72b72359618 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpThingConfig.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/config/HttpThingConfig.java @@ -13,10 +13,16 @@ package org.openhab.binding.http.internal.config; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.util.Jetty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The {@link HttpThingConfig} class contains fields mapping thing configuration parameters. @@ -25,6 +31,8 @@ */ @NonNullByDefault public class HttpThingConfig { + private final Logger logger = LoggerFactory.getLogger(HttpThingConfig.class); + public String baseURL = ""; public int refresh = 30; public int timeout = 3000; @@ -43,7 +51,26 @@ public class HttpThingConfig { public @Nullable String contentType = null; public boolean ignoreSSLErrors = false; + public boolean strictErrorHandling = false; // ArrayList is required as implementation because list may be modified later public ArrayList headers = new ArrayList<>(); + public String userAgent = ""; + + public Map getHeaders() { + Map headersMap = new HashMap<>(); + // add user agent first, in case it is also defined in the headers, it'll be overwritten + headersMap.put(HttpHeader.USER_AGENT.asString(), + userAgent.isBlank() ? "Jetty/" + Jetty.VERSION : userAgent.trim()); + headers.forEach(header -> { + String[] keyValuePair = header.split("=", 2); + if (keyValuePair.length == 2) { + headersMap.put(keyValuePair[0].trim(), keyValuePair[1].trim()); + } else { + logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header); + } + }); + + return headersMap; + } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/AbstractTransformingItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/AbstractTransformingItemConverter.java deleted file mode 100644 index 376150eaacb31..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/AbstractTransformingItemConverter.java +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.config.HttpChannelMode; -import org.openhab.binding.http.internal.http.Content; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; - -/** - * The {@link AbstractTransformingItemConverter} is a base class for an item converter with transformations - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public abstract class AbstractTransformingItemConverter implements ItemValueConverter { - private final Consumer updateState; - private final Consumer postCommand; - private final @Nullable Consumer sendHttpValue; - private final ValueTransformation stateTransformations; - private final ValueTransformation commandTransformations; - - protected HttpChannelConfig channelConfig; - - public AbstractTransformingItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - this.updateState = updateState; - this.postCommand = postCommand; - this.sendHttpValue = sendHttpValue; - this.stateTransformations = stateTransformations; - this.commandTransformations = commandTransformations; - this.channelConfig = channelConfig; - } - - @Override - public void process(Content content) { - if (channelConfig.mode != HttpChannelMode.WRITEONLY) { - stateTransformations.apply(content.getAsString()).ifPresent(transformedValue -> { - Command command = toCommand(transformedValue); - if (command != null) { - postCommand.accept(command); - } else { - updateState.accept(toState(transformedValue)); - } - }); - } else { - throw new IllegalStateException("Write-only channel"); - } - } - - @Override - public void send(Command command) { - Consumer sendHttpValue = this.sendHttpValue; - if (sendHttpValue != null && channelConfig.mode != HttpChannelMode.READONLY) { - commandTransformations.apply(toString(command)).ifPresent(sendHttpValue); - } else { - throw new IllegalStateException("Read-only channel"); - } - } - - /** - * check if this converter received a value that needs to be sent as command - * - * @param value the value - * @return the command or null - */ - protected abstract @Nullable Command toCommand(String value); - - /** - * convert the received value to a state - * - * @param value the value - * @return the state that represents the value of UNDEF if conversion failed - */ - protected abstract State toState(String value); - - /** - * convert a command to a string - * - * @param command the command - * @return the string representation of the command - */ - protected abstract String toString(Command command); - - @FunctionalInterface - public interface Factory { - ItemValueConverter create(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig); - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ColorItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ColorItemConverter.java deleted file mode 100644 index c320a2bed5e87..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ColorItemConverter.java +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.math.BigDecimal; -import java.util.function.Consumer; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.library.types.HSBType; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link ColorItemConverter} implements {@link org.openhab.core.library.items.ColorItem} conversions - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class ColorItemConverter extends AbstractTransformingItemConverter { - private static final BigDecimal BYTE_FACTOR = BigDecimal.valueOf(2.55); - private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); - private static final Pattern TRIPLE_MATCHER = Pattern.compile("(\\d+),(\\d+),(\\d+)"); - - private State state = UnDefType.UNDEF; - - public ColorItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - this.channelConfig = channelConfig; - } - - @Override - protected @Nullable Command toCommand(String value) { - return null; - } - - @Override - public String toString(Command command) { - String string = channelConfig.commandToFixedValue(command); - if (string != null) { - return string; - } - - if (command instanceof HSBType newState) { - state = newState; - return hsbToString(newState); - } else if (command instanceof PercentType percentCommand && state instanceof HSBType hsb) { - HSBType newState = new HSBType(hsb.getHue(), hsb.getSaturation(), percentCommand); - state = newState; - return hsbToString(newState); - } - - throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); - } - - @Override - public State toState(String string) { - State newState = UnDefType.UNDEF; - if (string.equals(channelConfig.onValue)) { - if (state instanceof HSBType hsb) { - newState = new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.HUNDRED); - } else { - newState = HSBType.WHITE; - } - } else if (string.equals(channelConfig.offValue)) { - if (state instanceof HSBType hsb) { - newState = new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.ZERO); - } else { - newState = HSBType.BLACK; - } - } else if (string.equals(channelConfig.increaseValue) && state instanceof HSBType hsb) { - BigDecimal newBrightness = hsb.getBrightness().toBigDecimal().add(channelConfig.step); - if (HUNDRED.compareTo(newBrightness) < 0) { - newBrightness = HUNDRED; - } - newState = new HSBType(hsb.getHue(), hsb.getSaturation(), new PercentType(newBrightness)); - } else if (string.equals(channelConfig.decreaseValue) && state instanceof HSBType hsb) { - BigDecimal newBrightness = hsb.getBrightness().toBigDecimal().subtract(channelConfig.step); - if (BigDecimal.ZERO.compareTo(newBrightness) > 0) { - newBrightness = BigDecimal.ZERO; - } - newState = new HSBType(hsb.getHue(), hsb.getSaturation(), new PercentType(newBrightness)); - } else { - Matcher matcher = TRIPLE_MATCHER.matcher(string); - if (matcher.matches()) { - switch (channelConfig.colorMode) { - case RGB: - int r = Integer.parseInt(matcher.group(1)); - int g = Integer.parseInt(matcher.group(2)); - int b = Integer.parseInt(matcher.group(3)); - newState = HSBType.fromRGB(r, g, b); - break; - case HSB: - newState = new HSBType(string); - break; - } - } - } - - state = newState; - return newState; - } - - private String hsbToString(HSBType state) { - switch (channelConfig.colorMode) { - case RGB: - PercentType[] rgb = state.toRGB(); - return String.format("%1$d,%2$d,%3$d", rgb[0].toBigDecimal().multiply(BYTE_FACTOR).intValue(), - rgb[1].toBigDecimal().multiply(BYTE_FACTOR).intValue(), - rgb[2].toBigDecimal().multiply(BYTE_FACTOR).intValue()); - case HSB: - return state.toString(); - } - throw new IllegalStateException("Invalid colorMode setting"); - } - - public enum ColorMode { - RGB, - HSB - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/DimmerItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/DimmerItemConverter.java deleted file mode 100644 index 40f13b91accaa..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/DimmerItemConverter.java +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.math.BigDecimal; -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link DimmerItemConverter} implements {@link org.openhab.core.library.items.DimmerItem} conversions - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class DimmerItemConverter extends AbstractTransformingItemConverter { - private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); - - private State state = UnDefType.UNDEF; - - public DimmerItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - this.channelConfig = channelConfig; - } - - @Override - protected @Nullable Command toCommand(String value) { - return null; - } - - @Override - public String toString(Command command) { - String string = channelConfig.commandToFixedValue(command); - if (string != null) { - return string; - } - - if (command instanceof PercentType percentCommand) { - return percentCommand.toString(); - } - - throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); - } - - @Override - public State toState(String string) { - State newState = UnDefType.UNDEF; - - if (string.equals(channelConfig.onValue)) { - newState = PercentType.HUNDRED; - } else if (string.equals(channelConfig.offValue)) { - newState = PercentType.ZERO; - } else if (string.equals(channelConfig.increaseValue) && state instanceof PercentType brightnessState) { - BigDecimal newBrightness = brightnessState.toBigDecimal().add(channelConfig.step); - if (HUNDRED.compareTo(newBrightness) < 0) { - newBrightness = HUNDRED; - } - newState = new PercentType(newBrightness); - } else if (string.equals(channelConfig.decreaseValue) && state instanceof PercentType brightnessState) { - BigDecimal newBrightness = brightnessState.toBigDecimal().subtract(channelConfig.step); - if (BigDecimal.ZERO.compareTo(newBrightness) > 0) { - newBrightness = BigDecimal.ZERO; - } - newState = new PercentType(newBrightness); - } else { - try { - BigDecimal value = new BigDecimal(string); - if (value.compareTo(PercentType.HUNDRED.toBigDecimal()) > 0) { - value = PercentType.HUNDRED.toBigDecimal(); - } - if (value.compareTo(PercentType.ZERO.toBigDecimal()) < 0) { - value = PercentType.ZERO.toBigDecimal(); - } - newState = new PercentType(value); - } catch (IllegalArgumentException e) { - // ignore - } - } - - state = newState; - return newState; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/FixedValueMappingItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/FixedValueMappingItemConverter.java deleted file mode 100644 index b03cf27d63376..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/FixedValueMappingItemConverter.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link FixedValueMappingItemConverter} implements mapping conversions for different item-types - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class FixedValueMappingItemConverter extends AbstractTransformingItemConverter { - - public FixedValueMappingItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - } - - @Override - protected @Nullable Command toCommand(String value) { - return null; - } - - @Override - public String toString(Command command) { - String value = channelConfig.commandToFixedValue(command); - if (value != null) { - return value; - } - - throw new IllegalArgumentException( - "Command type '" + command.toString() + "' not supported or mapping not defined."); - } - - @Override - public State toState(String string) { - State state = channelConfig.fixedValueToState(string); - - return state != null ? state : UnDefType.UNDEF; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/GenericItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/GenericItemConverter.java deleted file mode 100644 index f2b7211eb7f30..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/GenericItemConverter.java +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; -import java.util.function.Function; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link GenericItemConverter} implements simple conversions for different item types - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class GenericItemConverter extends AbstractTransformingItemConverter { - private final Function toState; - - public GenericItemConverter(Function toState, Consumer updateState, - Consumer postCommand, @Nullable Consumer sendHttpValue, - ValueTransformation stateTransformations, ValueTransformation commandTransformations, - HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - this.toState = toState; - } - - @Override - protected State toState(String value) { - try { - return toState.apply(value); - } catch (IllegalArgumentException e) { - return UnDefType.UNDEF; - } - } - - @Override - protected @Nullable Command toCommand(String value) { - return null; - } - - @Override - protected String toString(Command command) { - return command.toString(); - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ImageItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ImageItemConverter.java deleted file mode 100644 index 3462fd844fdeb..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ImageItemConverter.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.http.internal.http.Content; -import org.openhab.core.library.types.RawType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; - -/** - * The {@link ImageItemConverter} implements {@link org.openhab.core.library.items.ImageItem} conversions - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class ImageItemConverter implements ItemValueConverter { - private final Consumer updateState; - - public ImageItemConverter(Consumer updateState) { - this.updateState = updateState; - } - - @Override - public void process(Content content) { - String mediaType = content.getMediaType(); - updateState.accept( - new RawType(content.getRawContent(), mediaType != null ? mediaType : RawType.DEFAULT_MIME_TYPE)); - } - - @Override - public void send(Command command) { - throw new IllegalStateException("Read-only channel"); - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ItemValueConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ItemValueConverter.java deleted file mode 100644 index 18ab4228ac0f0..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/ItemValueConverter.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.http.internal.http.Content; -import org.openhab.core.types.Command; - -/** - * The {@link ItemValueConverter} defines the interface for converting received content to item state and converting - * comannds to sending value - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public interface ItemValueConverter { - - /** - * called to process a given content for this channel - * - * @param content content of the HTTP request - */ - void process(Content content); - - /** - * called to send a command to this channel - * - * @param command - */ - void send(Command command); -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/NumberItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/NumberItemConverter.java deleted file mode 100644 index ef0f2315d7ff7..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/NumberItemConverter.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.QuantityType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link NumberItemConverter} implements {@link org.openhab.core.library.items.NumberItem} conversions - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class NumberItemConverter extends AbstractTransformingItemConverter { - - public NumberItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - } - - @Override - protected @Nullable Command toCommand(String value) { - return null; - } - - @Override - protected State toState(String value) { - String trimmedValue = value.trim(); - if (!trimmedValue.isEmpty()) { - try { - if (channelConfig.unit != null) { - // we have a given unit - use that - return new QuantityType<>(trimmedValue + " " + channelConfig.unit); - } else { - try { - // try if we have a simple number - return new DecimalType(trimmedValue); - } catch (IllegalArgumentException e1) { - // not a plain number, maybe with unit? - return new QuantityType<>(trimmedValue); - } - } - } catch (IllegalArgumentException e) { - // finally failed - } - } - return UnDefType.UNDEF; - } - - @Override - protected String toString(Command command) { - return command.toString(); - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/PlayerItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/PlayerItemConverter.java deleted file mode 100644 index c7301887c164e..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/PlayerItemConverter.java +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.library.types.NextPreviousType; -import org.openhab.core.library.types.PlayPauseType; -import org.openhab.core.library.types.RewindFastforwardType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link PlayerItemConverter} implements {@link org.openhab.core.library.items.RollershutterItem} - * conversions - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class PlayerItemConverter extends AbstractTransformingItemConverter { - private final HttpChannelConfig channelConfig; - private @Nullable String lastCommand; // store last command to prevent duplicate commands - - public PlayerItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - this.channelConfig = channelConfig; - } - - @Override - public String toString(Command command) { - String string = channelConfig.commandToFixedValue(command); - if (string != null) { - return string; - } - - throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); - } - - @Override - protected @Nullable Command toCommand(String string) { - if (string.equals(lastCommand)) { - // only send commands once - return null; - } - lastCommand = string; - - if (string.equals(channelConfig.playValue)) { - return PlayPauseType.PLAY; - } else if (string.equals(channelConfig.pauseValue)) { - return PlayPauseType.PAUSE; - } else if (string.equals(channelConfig.nextValue)) { - return NextPreviousType.NEXT; - } else if (string.equals(channelConfig.previousValue)) { - return NextPreviousType.PREVIOUS; - } else if (string.equals(channelConfig.rewindValue)) { - return RewindFastforwardType.REWIND; - } else if (string.equals(channelConfig.fastforwardValue)) { - return RewindFastforwardType.FASTFORWARD; - } - - return null; - } - - @Override - public State toState(String string) { - if (string.equals(channelConfig.playValue)) { - return PlayPauseType.PLAY; - } else if (string.equals(channelConfig.pauseValue)) { - return PlayPauseType.PAUSE; - } else if (string.equals(channelConfig.rewindValue)) { - return RewindFastforwardType.REWIND; - } else if (string.equals(channelConfig.fastforwardValue)) { - return RewindFastforwardType.FASTFORWARD; - } - - return UnDefType.UNDEF; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/RollershutterItemConverter.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/RollershutterItemConverter.java deleted file mode 100644 index 01f85d9a81a51..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/converter/RollershutterItemConverter.java +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.math.BigDecimal; -import java.util.function.Consumer; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.ValueTransformation; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.library.types.StopMoveType; -import org.openhab.core.library.types.UpDownType; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link RollershutterItemConverter} implements {@link org.openhab.core.library.items.RollershutterItem} - * conversions - * - * @author Jan N. Klug - Initial contribution - */ - -@NonNullByDefault -public class RollershutterItemConverter extends AbstractTransformingItemConverter { - private final HttpChannelConfig channelConfig; - - public RollershutterItemConverter(Consumer updateState, Consumer postCommand, - @Nullable Consumer sendHttpValue, ValueTransformation stateTransformations, - ValueTransformation commandTransformations, HttpChannelConfig channelConfig) { - super(updateState, postCommand, sendHttpValue, stateTransformations, commandTransformations, channelConfig); - this.channelConfig = channelConfig; - } - - @Override - public String toString(Command command) { - String string = channelConfig.commandToFixedValue(command); - if (string != null) { - return string; - } - - if (command instanceof PercentType brightnessState) { - final String downValue = channelConfig.downValue; - final String upValue = channelConfig.upValue; - if (command.equals(PercentType.HUNDRED) && downValue != null) { - return downValue; - } else if (command.equals(PercentType.ZERO) && upValue != null) { - return upValue; - } else { - return brightnessState.toString(); - } - } - - throw new IllegalArgumentException("Command type '" + command.toString() + "' not supported"); - } - - @Override - protected @Nullable Command toCommand(String string) { - if (string.equals(channelConfig.upValue)) { - return UpDownType.UP; - } else if (string.equals(channelConfig.downValue)) { - return UpDownType.DOWN; - } else if (string.equals(channelConfig.moveValue)) { - return StopMoveType.MOVE; - } else if (string.equals(channelConfig.stopValue)) { - return StopMoveType.STOP; - } - - return null; - } - - @Override - public State toState(String string) { - try { - BigDecimal value = new BigDecimal(string); - if (value.compareTo(PercentType.HUNDRED.toBigDecimal()) > 0) { - return PercentType.HUNDRED; - } - if (value.compareTo(PercentType.ZERO.toBigDecimal()) < 0) { - return PercentType.ZERO; - } - return new PercentType(value); - } catch (NumberFormatException e) { - // ignore - } - - return UnDefType.UNDEF; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/Content.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/Content.java deleted file mode 100644 index a218ed913e3c8..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/Content.java +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.http; - -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * The {@link Content} defines the pre-processed response - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class Content { - private final byte[] rawContent; - private final Charset encoding; - private final @Nullable String mediaType; - - public Content(byte[] rawContent, String encoding, @Nullable String mediaType) { - this.rawContent = rawContent; - this.mediaType = mediaType; - - Charset finalEncoding = StandardCharsets.UTF_8; - try { - finalEncoding = Charset.forName(encoding); - } catch (IllegalArgumentException e) { - } - this.encoding = finalEncoding; - } - - public byte[] getRawContent() { - return rawContent; - } - - public String getAsString() { - return new String(rawContent, encoding); - } - - public @Nullable String getMediaType() { - return mediaType; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpResponseListener.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpResponseListener.java index 3386950197b32..81cf8b950ffde 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpResponseListener.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpResponseListener.java @@ -13,7 +13,6 @@ package org.openhab.binding.http.internal.http; import java.nio.charset.StandardCharsets; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -25,6 +24,7 @@ import org.eclipse.jetty.client.util.BufferingResponseListener; import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpStatus; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,8 @@ @NonNullByDefault public class HttpResponseListener extends BufferingResponseListener { private final Logger logger = LoggerFactory.getLogger(HttpResponseListener.class); - private final CompletableFuture<@Nullable Content> future; + private final CompletableFuture<@Nullable ChannelHandlerContent> future; + private final HttpStatusListener httpStatusListener; private final String fallbackEncoding; /** @@ -46,11 +47,12 @@ public class HttpResponseListener extends BufferingResponseListener { * @param fallbackEncoding a fallback encoding for the content (UTF-8 if null) * @param bufferSize the buffer size for the content in kB (default 2048 kB) */ - public HttpResponseListener(CompletableFuture<@Nullable Content> future, @Nullable String fallbackEncoding, - int bufferSize) { + public HttpResponseListener(CompletableFuture<@Nullable ChannelHandlerContent> future, + @Nullable String fallbackEncoding, int bufferSize, HttpStatusListener httpStatusListener) { super(bufferSize * 1024); this.future = future; this.fallbackEncoding = fallbackEncoding != null ? fallbackEncoding : StandardCharsets.UTF_8.name(); + this.httpStatusListener = httpStatusListener; } @Override @@ -60,31 +62,49 @@ public void onComplete(@NonNullByDefault({}) Result result) { logger.trace("Received from '{}': {}", result.getRequest().getURI(), responseToLogString(response)); } Request request = result.getRequest(); - if (result.isFailed()) { - logger.warn("Requesting '{}' (method='{}', content='{}') failed: {}", request.getURI(), request.getMethod(), - request.getContent(), result.getFailure().toString()); + if (response == null || (result.isFailed() && response.getStatus() != HttpStatus.UNAUTHORIZED_401)) { + logger.debug("Requesting '{}' (method='{}', content='{}') failed: {}", request.getURI(), + request.getMethod(), request.getContent(), result.getFailure().getMessage()); future.complete(null); - } else if (HttpStatus.isSuccess(response.getStatus())) { - String encoding = Objects.requireNonNullElse(getEncoding(), fallbackEncoding); - future.complete(new Content(getContent(), encoding, getMediaType())); + httpStatusListener.onHttpError(result.getFailure().getMessage()); } else { switch (response.getStatus()) { + case HttpStatus.OK_200: + case HttpStatus.CREATED_201: + case HttpStatus.ACCEPTED_202: + case HttpStatus.NON_AUTHORITATIVE_INFORMATION_203: + case HttpStatus.NO_CONTENT_204: + case HttpStatus.RESET_CONTENT_205: + case HttpStatus.PARTIAL_CONTENT_206: + case HttpStatus.MULTI_STATUS_207: + byte[] content = getContent(); + String encoding = getEncoding(); + if (content != null) { + future.complete(new ChannelHandlerContent(content, + encoding == null ? fallbackEncoding : encoding, getMediaType())); + } else { + future.complete(null); + } + httpStatusListener.onHttpSuccess(); + break; case HttpStatus.UNAUTHORIZED_401: logger.debug("Requesting '{}' (method='{}', content='{}') failed: Authorization error", request.getURI(), request.getMethod(), request.getContent()); future.completeExceptionally(new HttpAuthException()); break; default: - logger.warn("Requesting '{}' (method='{}', content='{}') failed: {} {}", request.getURI(), + logger.debug("Requesting '{}' (method='{}', content='{}') failed: {} {}", request.getURI(), request.getMethod(), request.getContent(), response.getStatus(), response.getReason()); - future.completeExceptionally(new IllegalStateException("Response - Code" + response.getStatus())); + future.complete(null); + httpStatusListener.onHttpError(response.getReason()); } } } private String responseToLogString(Response response) { - return "Code = {" + response.getStatus() + "}, Headers = {" + String logString = "Code = {" + response.getStatus() + "}, Headers = {" + response.getHeaders().stream().map(HttpField::toString).collect(Collectors.joining(", ")) + "}, Content = {" + getContentAsString() + "}"; + return logString; } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformationProvider.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpStatusListener.java similarity index 56% rename from bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformationProvider.java rename to bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpStatusListener.java index 8fe4764bbdbd6..72c5f8ef5a268 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformationProvider.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/HttpStatusListener.java @@ -10,24 +10,27 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.http.internal.transform; +package org.openhab.binding.http.internal.http; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; /** - * The {@link ValueTransformationProvider} allows to retrieve a transformation service by name + * The {@link HttpStatusListener} is an interface for reporting HTTP request states * * @author Jan N. Klug - Initial contribution */ @NonNullByDefault -public interface ValueTransformationProvider { - +public interface HttpStatusListener { /** + * report an error * - * @param pattern A transformation pattern, starting with the transformation service - * * name, followed by a colon and the transformation itself. - * @return + * @param message optional error message + */ + void onHttpError(@Nullable String message); + + /** + * report a successful request */ - ValueTransformation getValueTransformation(@Nullable String pattern); + void onHttpSuccess(); } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RateLimitedHttpClient.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RateLimitedHttpClient.java index 0a3bbcb146eb8..2c624b6069ac2 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RateLimitedHttpClient.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RateLimitedHttpClient.java @@ -24,10 +24,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.AuthenticationStore; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.util.StringContentProvider; import org.eclipse.jetty.http.HttpMethod; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The {@link RateLimitedHttpClient} is a wrapper for a Jetty HTTP client that limits the number of requests by delaying @@ -38,10 +41,14 @@ @NonNullByDefault public class RateLimitedHttpClient { private static final int MAX_QUEUE_SIZE = 1000; // maximum queue size + private final Logger logger = LoggerFactory.getLogger(RateLimitedHttpClient.class); + private HttpClient httpClient; private int delay = 0; // in ms private final ScheduledExecutorService scheduler; private final LinkedBlockingQueue requestQueue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE); + private final LinkedBlockingQueue priorityRequestQueue = new LinkedBlockingQueue<>( + MAX_QUEUE_SIZE); private @Nullable ScheduledFuture processJob; @@ -55,7 +62,7 @@ public RateLimitedHttpClient(HttpClient httpClient, ScheduledExecutorService sch */ public void shutdown() { stopProcessJob(); - requestQueue.forEach(queueEntry -> queueEntry.future.completeExceptionally(new CancellationException())); + requestQueue.forEach(RequestQueueEntry::cancel); } /** @@ -77,7 +84,7 @@ public void setDelay(int delay) { /** * Set the HTTP client * - * @param httpClient secure or insecure Jetty http client + * @param httpClient secure or insecure {@link HttpClient} */ public void setHttpClient(HttpClient httpClient) { this.httpClient = httpClient; @@ -89,31 +96,70 @@ public void setHttpClient(HttpClient httpClient) { * @param finalUrl the request URL * @param method http request method GET/PUT/POST * @param content the content (if method PUT/POST) - * @return a CompletableFuture that completes with the request + * @return a {@link CompletableFuture} that completes with the request */ - public CompletableFuture newRequest(URI finalUrl, HttpMethod method, String content) { + public CompletableFuture newRequest(URI finalUrl, HttpMethod method, String content, + @Nullable String contentType) { + return queueRequest(finalUrl, method, content, contentType, requestQueue); + } + + /** + * Create a new priority request (executed as next request) to the given URL respecting rate-limits + * + * @param finalUrl the request URL + * @param method http request method GET/PUT/POST + * @param content the content (if method PUT/POST) + * @return a {@link CompletableFuture} that completes with the request + */ + public CompletableFuture newPriorityRequest(URI finalUrl, HttpMethod method, String content, + @Nullable String contentType) { + return queueRequest(finalUrl, method, content, contentType, priorityRequestQueue); + } + + private CompletableFuture queueRequest(URI finalUrl, HttpMethod method, String content, + @Nullable String contentType, LinkedBlockingQueue queue) { // if no delay is set, return a completed CompletableFuture CompletableFuture future = new CompletableFuture<>(); - RequestQueueEntry queueEntry = new RequestQueueEntry(finalUrl, method, content, future); + RequestQueueEntry queueEntry = new RequestQueueEntry(finalUrl, method, content, contentType, future); if (delay == 0) { queueEntry.completeFuture(httpClient); } else { - if (!requestQueue.offer(queueEntry)) { + if (!queue.offer(queueEntry)) { future.completeExceptionally(new RejectedExecutionException("Maximum queue size exceeded.")); } + } return future; } /** - * Get the AuthenticationStore from the wrapped client + * Get the {@link AuthenticationStore} from the wrapped {@link HttpClient} * - * @return + * @return the AuthenticationStore of the client */ public AuthenticationStore getAuthenticationStore() { return httpClient.getAuthenticationStore(); } + /** + * Remove authentication result from the wrapped {@link HttpClient} and force re-auth + * + * @param uri the {@link URI} associated with the authentication result + * @return true if a result was found and cleared, false if not authenticated at all + */ + public boolean reAuth(URI uri) { + AuthenticationStore authStore = httpClient.getAuthenticationStore(); + Authentication.Result authResult = authStore.findAuthenticationResult(uri); + if (authResult != null) { + authStore.removeAuthenticationResult(authResult); + logger.debug("Cleared authentication result for '{}', retrying immediately", uri); + return true; + } else { + logger.warn("Could not find authentication result for '{}', failing here", uri); + return false; + } + } + private void stopProcessJob() { ScheduledFuture processJob = this.processJob; if (processJob != null) { @@ -122,23 +168,33 @@ private void stopProcessJob() { } } + /** + * Gets a request from either the priority queue or tge regular queue and creates the request + */ private void processQueue() { - RequestQueueEntry queueEntry = requestQueue.poll(); + RequestQueueEntry queueEntry = priorityRequestQueue.poll(); + if (queueEntry == null) { + // no entry in priorityRequestQueue, try the regular queue + queueEntry = requestQueue.poll(); + } if (queueEntry != null) { queueEntry.completeFuture(httpClient); } } private static class RequestQueueEntry { - private URI finalUrl; - private HttpMethod method; - private String content; - private CompletableFuture future; + private final URI finalUrl; + private final HttpMethod method; + private final String content; + private final @Nullable String contentType; + private final CompletableFuture future; - public RequestQueueEntry(URI finalUrl, HttpMethod method, String content, CompletableFuture future) { + public RequestQueueEntry(URI finalUrl, HttpMethod method, String content, @Nullable String contentType, + CompletableFuture future) { this.finalUrl = finalUrl; this.method = method; this.content = content; + this.contentType = contentType; this.future = future; } @@ -149,10 +205,21 @@ public RequestQueueEntry(URI finalUrl, HttpMethod method, String content, Comple */ public void completeFuture(HttpClient httpClient) { Request request = httpClient.newRequest(finalUrl).method(method); - if (method != HttpMethod.GET && !content.isEmpty()) { - request.content(new StringContentProvider(content)); + if ((method == HttpMethod.POST || method == HttpMethod.PUT) && !content.isEmpty()) { + if (contentType == null) { + request.content(new StringContentProvider(content)); + } else { + request.content(new StringContentProvider(content), contentType); + } } future.complete(request); } + + /** + * cancel this request and complete the future with a {@link CancellationException} + */ + public void cancel() { + future.completeExceptionally(new CancellationException()); + } } } diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RefreshingUrlCache.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RefreshingUrlCache.java index 35c1a239cfa12..d86fa1d798c20 100644 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RefreshingUrlCache.java +++ b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/http/RefreshingUrlCache.java @@ -16,7 +16,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.Date; -import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CancellationException; @@ -29,11 +29,10 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.client.api.Authentication; -import org.eclipse.jetty.client.api.AuthenticationStore; import org.eclipse.jetty.http.HttpMethod; import org.openhab.binding.http.internal.Util; import org.openhab.binding.http.internal.config.HttpThingConfig; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -48,29 +47,34 @@ public class RefreshingUrlCache { private final Logger logger = LoggerFactory.getLogger(RefreshingUrlCache.class); private final String url; - private final boolean escapedUrl; private final RateLimitedHttpClient httpClient; + private final boolean strictErrorHandling; private final int timeout; private final int bufferSize; private final @Nullable String fallbackEncoding; - private final Set> consumers = ConcurrentHashMap.newKeySet(); - private final List headers; + private final Set> consumers = ConcurrentHashMap.newKeySet(); + private final Map headers; private final HttpMethod httpMethod; private final String httpContent; + private final @Nullable String httpContentType; + private final HttpStatusListener httpStatusListener; private final ScheduledFuture future; - private @Nullable Content lastContent; + private @Nullable ChannelHandlerContent lastContent; public RefreshingUrlCache(ScheduledExecutorService executor, RateLimitedHttpClient httpClient, String url, - boolean escapedUrl, HttpThingConfig thingConfig, String httpContent) { + HttpThingConfig thingConfig, String httpContent, @Nullable String httpContentType, + HttpStatusListener httpStatusListener) { this.httpClient = httpClient; this.url = url; - this.escapedUrl = escapedUrl; + this.strictErrorHandling = thingConfig.strictErrorHandling; this.timeout = thingConfig.timeout; this.bufferSize = thingConfig.bufferSize; - this.headers = thingConfig.headers; this.httpMethod = thingConfig.stateMethod; + this.headers = thingConfig.getHeaders(); this.httpContent = httpContent; + this.httpContentType = httpContentType; + this.httpStatusListener = httpStatusListener; fallbackEncoding = thingConfig.encoding; future = executor.scheduleWithFixedDelay(this::refresh, 1, thingConfig.refresh, TimeUnit.SECONDS); @@ -89,37 +93,21 @@ private void refresh(boolean isRetry) { // format URL try { - String url = String.format(this.url, new Date()); - URI uri = escapedUrl ? new URI(url) : Util.uriFromString(url); + URI uri = Util.uriFromString(String.format(this.url, new Date())); logger.trace("Requesting refresh (retry={}) from '{}' with timeout {}ms", isRetry, uri, timeout); - httpClient.newRequest(uri, httpMethod, httpContent).thenAccept(request -> { + httpClient.newRequest(uri, httpMethod, httpContent, httpContentType).thenAccept(request -> { request.timeout(timeout, TimeUnit.MILLISECONDS); - - headers.forEach(header -> { - String[] keyValuePair = header.split("=", 2); - if (keyValuePair.length == 2) { - request.header(keyValuePair[0].trim(), keyValuePair[1].trim()); - } else { - logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header); - } - }); - - CompletableFuture<@Nullable Content> response = new CompletableFuture<>(); - response.exceptionally(e -> { - if (e instanceof HttpAuthException) { - if (isRetry) { - logger.warn("Retry after authentication failure failed again for '{}', failing here", uri); + headers.forEach(request::header); + + CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>(); + responseContentFuture.exceptionally(t -> { + if (t instanceof HttpAuthException) { + if (isRetry || !httpClient.reAuth(uri)) { + logger.debug("Authentication failed for '{}', retry={}", uri, isRetry); + httpStatusListener.onHttpError("Authentication failed"); } else { - AuthenticationStore authStore = httpClient.getAuthenticationStore(); - Authentication.Result authResult = authStore.findAuthenticationResult(uri); - if (authResult != null) { - authStore.removeAuthenticationResult(authResult); - logger.debug("Cleared authentication result for '{}', retrying immediately", uri); - refresh(true); - } else { - logger.warn("Could not find authentication result for '{}', failing here", uri); - } + refresh(true); } } return null; @@ -129,7 +117,8 @@ private void refresh(boolean isRetry) { logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request)); } - request.send(new HttpResponseListener(response, fallbackEncoding, bufferSize)); + request.send(new HttpResponseListener(responseContentFuture, fallbackEncoding, bufferSize, + httpStatusListener)); }).exceptionally(e -> { if (e instanceof CancellationException) { logger.debug("Request to URL {} was cancelled by thing handler.", uri); @@ -150,22 +139,17 @@ public void stop() { logger.trace("Stopped refresh task for URL '{}'", url); } - public void addConsumer(Consumer consumer) { + public void addConsumer(Consumer<@Nullable ChannelHandlerContent> consumer) { consumers.add(consumer); } - public Optional get() { - final Content content = lastContent; - if (content == null) { - return Optional.empty(); - } else { - return Optional.of(content); - } + public Optional get() { + return Optional.ofNullable(lastContent); } - private void processResult(@Nullable Content content) { - if (content != null) { - for (Consumer consumer : consumers) { + private void processResult(@Nullable ChannelHandlerContent content) { + if (content != null || strictErrorHandling) { + for (Consumer<@Nullable ChannelHandlerContent> consumer : consumers) { try { consumer.accept(content); } catch (IllegalArgumentException | IllegalStateException e) { diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/CascadedValueTransformationImpl.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/CascadedValueTransformationImpl.java deleted file mode 100644 index fa9b137798547..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/CascadedValueTransformationImpl.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.transform; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.transform.TransformationService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link CascadedValueTransformationImpl} implements {@link ValueTransformation} for a cascaded set of - * transformations - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class CascadedValueTransformationImpl implements ValueTransformation { - private final Logger logger = LoggerFactory.getLogger(CascadedValueTransformationImpl.class); - private final List transformations; - - public CascadedValueTransformationImpl(String transformationString, - Function transformationServiceSupplier) { - List transformations; - try { - transformations = Arrays.stream(transformationString.split("∩")).filter(s -> !s.isEmpty()) - .map(transformation -> new SingleValueTransformation(transformation, transformationServiceSupplier)) - .collect(Collectors.toList()); - } catch (IllegalArgumentException e) { - transformations = List.of(NoOpValueTransformation.getInstance()); - logger.warn("Transformation ignore, failed to parse {}: {}", transformationString, e.getMessage()); - } - this.transformations = transformations; - } - - @Override - public Optional apply(String value) { - Optional valueOptional = Optional.of(value); - - // process all transformations - for (ValueTransformation transformation : transformations) { - valueOptional = valueOptional.flatMap(transformation::apply); - } - - return valueOptional; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/NoOpValueTransformation.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/NoOpValueTransformation.java deleted file mode 100644 index 1d805d5f9286a..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/NoOpValueTransformation.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.transform; - -import java.util.Optional; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link NoOpValueTransformation} implements a no-op (identity) transformation - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class NoOpValueTransformation implements ValueTransformation { - private static final NoOpValueTransformation NO_OP_VALUE_TRANSFORMATION = new NoOpValueTransformation(); - - @Override - public Optional apply(String value) { - return Optional.of(value); - } - - /** - * get the static value transformation for identity - * - * @return - */ - public static ValueTransformation getInstance() { - return NO_OP_VALUE_TRANSFORMATION; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/SingleValueTransformation.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/SingleValueTransformation.java deleted file mode 100644 index 7b5043361092d..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/SingleValueTransformation.java +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.transform; - -import java.lang.ref.WeakReference; -import java.util.Optional; -import java.util.function.Function; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.transform.TransformationException; -import org.openhab.core.transform.TransformationService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * A transformation for a value used in {@link HttpChannel}. - * - * @author David Graeff - Initial contribution - * @author Jan N. Klug - adapted from MQTT binding to HTTP binding - */ -@NonNullByDefault -public class SingleValueTransformation implements ValueTransformation { - private final Logger logger = LoggerFactory.getLogger(SingleValueTransformation.class); - private final Function transformationServiceSupplier; - private WeakReference<@Nullable TransformationService> transformationService = new WeakReference<>(null); - private final String pattern; - private final String serviceName; - - /** - * Creates a new channel state transformer. - * - * @param pattern A transformation pattern, starting with the transformation service - * name, followed by a colon and the transformation itself. - * @param transformationServiceSupplier - */ - public SingleValueTransformation(String pattern, - Function transformationServiceSupplier) { - this.transformationServiceSupplier = transformationServiceSupplier; - int index = pattern.indexOf(':'); - if (index == -1) { - throw new IllegalArgumentException( - "The transformation pattern must consist of the type and the pattern separated by a colon"); - } - this.serviceName = pattern.substring(0, index).toUpperCase(); - this.pattern = pattern.substring(index + 1); - } - - @Override - public Optional apply(String value) { - TransformationService transformationService = this.transformationService.get(); - if (transformationService == null) { - transformationService = transformationServiceSupplier.apply(serviceName); - if (transformationService == null) { - logger.warn("Transformation service {} for pattern {} not found!", serviceName, pattern); - return Optional.empty(); - } - this.transformationService = new WeakReference<>(transformationService); - } - - try { - String result = transformationService.transform(pattern, value); - if (result == null) { - logger.debug("Transformation {} returned empty result when applied to {}.", this, value); - return Optional.empty(); - } - return Optional.of(result); - } catch (TransformationException e) { - logger.warn("Executing transformation {} failed: {}", this, e.getMessage()); - } - - return Optional.empty(); - } - - @Override - public String toString() { - return "ChannelStateTransformation{pattern='" + pattern + "', serviceName='" + serviceName + "'}"; - } -} diff --git a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformation.java b/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformation.java deleted file mode 100644 index c006972f31550..0000000000000 --- a/bundles/org.openhab.binding.http/src/main/java/org/openhab/binding/http/internal/transform/ValueTransformation.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.transform; - -import java.util.Optional; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link ValueTransformation} applies a set of transformations to a value - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public interface ValueTransformation { - - /** - * applies the value transformation to a value - * - * @param value The value - * @return Optional of string representing the transformed value (empty if transformation not present or failed) - */ - Optional apply(String value); -} diff --git a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/addon/addon.xml index 9b7f525097ae6..3c5f8cb785d0f 100644 --- a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/addon/addon.xml @@ -6,6 +6,6 @@ binding HTTP Binding This is the binding for retrieving and processing HTTP resources. - local + hybrid diff --git a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/config/config.xml index 127707608e168..82cd4456a9953 100644 --- a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/config/config.xml @@ -7,13 +7,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving values. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending values. Multiple transformation can be chained using "∩". @@ -25,13 +23,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -53,13 +44,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving values. - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -71,13 +60,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -131,13 +113,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -149,13 +129,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -185,13 +158,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -203,13 +174,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -256,12 +220,6 @@ This value is added to the base URL configured in the thing for retrieving values. true - - - This specifies whether the URL is already escaped. Applies to the base URL and stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -272,13 +230,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -290,13 +246,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -323,13 +272,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -341,13 +288,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -393,13 +333,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values Chain multiple transformations with the mathematical - intersection character "∩".. + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -411,13 +349,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) @@ -455,13 +386,11 @@ - Transformation pattern used when receiving values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when receiving value. Multiple transformation can be chained using "∩". - Transformation pattern used when sending values. Chain multiple transformations with the mathematical - intersection character "∩". + Transformation pattern used when sending value. Multiple transformation can be chained using "∩". @@ -473,13 +402,6 @@ This value is added to the base URL configured in the thing for sending values. true - - - This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and - stateExtension. - true - false - Content for state request (only used if method is POST/PUT) diff --git a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/thing/thing-types.xml index ff7917a97b24f..324c6603e8a7b 100644 --- a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/thing/thing-types.xml @@ -9,6 +9,19 @@ Represents a base URL and all associated requests. + + + + + + + + + + + 2 + + @@ -44,7 +57,7 @@ - Basic Authentication password + Authentication password or token password true @@ -54,6 +67,7 @@ + BASIC true @@ -112,9 +126,20 @@ false true + + + Sets a custom user agent (default is "Jetty/version", e.g. "Jetty/9.4.20.v20190813"). + true + + + DateTime + + + + Color diff --git a/bundles/org.openhab.binding.http/src/main/resources/OH-INF/update/instructions.xml b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/update/instructions.xml new file mode 100644 index 0000000000000..dbb30b11e746c --- /dev/null +++ b/bundles/org.openhab.binding.http/src/main/resources/OH-INF/update/instructions.xml @@ -0,0 +1,31 @@ + + + + + + + http:requestDateTime + + + + http:requestDateTime + + + + + + + + http:request-date-time + + + + http:request-date-time + + + + + + diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/AbstractWireMockTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/AbstractWireMockTest.java new file mode 100644 index 0000000000000..1d6f28f3108a8 --- /dev/null +++ b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/AbstractWireMockTest.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.configureFor; +import static com.github.tomakehurst.wiremock.client.WireMock.removeAllMappings; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.openhab.binding.http.internal.http.RateLimitedHttpClient; +import org.openhab.core.test.TestPortUtil; +import org.openhab.core.test.java.JavaTest; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer; + +/** + * The {@link AbstractWireMockTest} implements tests for the {@link RateLimitedHttpClient} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractWireMockTest extends JavaTest { + protected int port = 0; + protected @NonNullByDefault({}) WireMockServer wireMockServer; + protected @NonNullByDefault({}) HttpClient httpClient; + protected ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(4); + + @BeforeAll + public void initAll() throws Exception { + port = TestPortUtil.findFreePort(); + + wireMockServer = new WireMockServer(options().port(port).extensions(new ResponseTemplateTransformer(false))); + wireMockServer.start(); + + httpClient = new HttpClient(); + httpClient.start(); + + configureFor("localhost", port); + } + + @AfterEach + public void cleanUpTest() { + removeAllMappings(); + } + + @AfterAll + public void cleanUpAll() throws Exception { + wireMockServer.shutdown(); + scheduler.shutdown(); + httpClient.stop(); + } +} diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RateLimitedHttpClientTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RateLimitedHttpClientTest.java new file mode 100644 index 0000000000000..4c73389df2a7d --- /dev/null +++ b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RateLimitedHttpClientTest.java @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.anyOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.openhab.binding.http.internal.http.RateLimitedHttpClient; + +/** + * The {@link RateLimitedHttpClientTest} implements tests for the {@link RateLimitedHttpClient} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class RateLimitedHttpClientTest extends AbstractWireMockTest { + private static final String TEST_LOCATION = "/testlocation"; + private static final String TEST_CONTENT = "TESTCONTENT"; + + private List responses = new CopyOnWriteArrayList<>(); + + @AfterEach + public void cleanUpTest() { + responses.clear(); + super.cleanUpTest(); + } + + @Test + public void testWithoutLimit() { + doLimitTest(0, List.of(false, false)); + + // we except to receive the responses in the correct order + assertEquals(0, responses.get(0).seqNumber); + assertEquals(1, responses.get(1).seqNumber); + + // we expect a short delay between both requests, but less than 100ms + long msBetween = responses.get(1).time - responses.get(0).time; + assertThat((int) msBetween, allOf(greaterThanOrEqualTo(0), lessThan(100))); + } + + @Test + public void testWithLimit() { + doLimitTest(500, List.of(false, false)); + // we except to receive the responses in the correct order + assertEquals(0, responses.get(0).seqNumber); + assertEquals(1, responses.get(1).seqNumber); + + // we expect at least 500ms delay between both requests, but less than 500+100=600ms + long msBetween = responses.get(1).time - responses.get(0).time; + assertThat((int) msBetween, allOf(greaterThanOrEqualTo(500), lessThan(600))); + } + + @Test + public void testWithLimitAndPriority() { + doLimitTest(500, List.of(false, false, true)); + + // we expect to receive the responses of request 3 before request two, exact order of 1 and 3 depends on timing, + // so accept both + assertThat(responses.get(0).seqNumber, anyOf(equalTo(0), equalTo(2))); + assertThat(responses.get(1).seqNumber, anyOf(equalTo(0), equalTo(2))); + assertNotEquals(responses.get(1).seqNumber, responses.get(0).seqNumber); + assertEquals(1, responses.get(2).seqNumber); + + // we expect at least 2*500=1000ms delay between the first and last request, but less than 2*500+100=1100 ms + long msBetween = responses.get(2).time - responses.get(0).time; + assertThat((int) msBetween, allOf(greaterThanOrEqualTo(1000), lessThan(1100))); + } + + private List doLimitTest(int setDelay, List config) { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withBody(TEST_CONTENT))); + + RateLimitedHttpClient rateLimitedHttpClient = new RateLimitedHttpClient(httpClient, scheduler); + rateLimitedHttpClient.setDelay(setDelay); + + URI url = URI.create("http://localhost:" + port + TEST_LOCATION); + int seqNumber = 0; + + for (boolean priority : config) { + int nextSeqNumber = seqNumber++; + CompletableFuture requestFuture; + + if (priority) { + requestFuture = rateLimitedHttpClient.newPriorityRequest(url, HttpMethod.GET, "", null); + } else { + requestFuture = rateLimitedHttpClient.newRequest(url, HttpMethod.GET, "", null); + } + + requestFuture.thenAccept(request -> { + try { + responses.add(new Response(nextSeqNumber, request.send())); + } catch (Exception e) { + } + }); + } + + // wait until we got all results + waitForAssert(() -> assertEquals(config.size(), responses.size())); + rateLimitedHttpClient.shutdown(); + + return responses; + } + + private static class Response { + public final int seqNumber; + public final long time = System.currentTimeMillis(); + public final String content; + + public Response(int seqNumber, ContentResponse contentResponse) { + this.seqNumber = seqNumber; + this.content = contentResponse.getContentAsString(); + } + } +} diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RefreshingUrlCacheTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RefreshingUrlCacheTest.java new file mode 100644 index 0000000000000..e8aa8c165ab84 --- /dev/null +++ b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/RefreshingUrlCacheTest.java @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.http; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.util.Jetty; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.openhab.binding.http.internal.config.HttpThingConfig; +import org.openhab.binding.http.internal.http.HttpStatusListener; +import org.openhab.binding.http.internal.http.RateLimitedHttpClient; +import org.openhab.binding.http.internal.http.RefreshingUrlCache; +import org.openhab.core.thing.binding.generic.ChannelHandlerContent; + +/** + * The {@link RefreshingUrlCacheTest} implements tests for the {@link RefreshingUrlCache} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class RefreshingUrlCacheTest extends AbstractWireMockTest { + private static final String TEST_LOCATION = "/testlocation"; + private static final String TEST_CONTENT = "TESTCONTENT"; + + private @NonNullByDefault({}) RateLimitedHttpClient rateLimitedHttpClient; + private @NonNullByDefault({}) HttpThingConfig thingConfig; + private @NonNullByDefault({}) String url; + private @NonNullByDefault({}) HttpStatusListener statusListener; + + private final List<@Nullable ChannelHandlerContent> contentWrappers = new CopyOnWriteArrayList<>(); + + @BeforeEach + public void initTest() { + // this is usually done inside the HttpHandlerFactory when creating the clients + httpClient.setUserAgentField(null); + + // create a RateLimitedHttpClient + rateLimitedHttpClient = new RateLimitedHttpClient(httpClient, scheduler); + rateLimitedHttpClient.setDelay(0); + statusListener = mock(HttpStatusListener.class); + + // initialize thing config with some default values + thingConfig = new HttpThingConfig(); + thingConfig.baseURL = "http://localhost:" + port; + thingConfig.timeout = 500; + thingConfig.refresh = 1; + + url = thingConfig.baseURL + TEST_LOCATION; + } + + @AfterEach + public void cleanUpTest() { + rateLimitedHttpClient.shutdown(); + contentWrappers.clear(); + super.cleanUpTest(); + } + + @Test + public void testUpdateOnSuccessfulRequest() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withBody(TEST_CONTENT))); + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // wait until we got at least four results or timeout (after 10s) + waitForAssert(() -> assertEquals(4, contentWrappers.size())); + urlCache.stop(); + + // verify we did not have errors and the number of responses matches the number of success calls + verify(statusListener, never()).onHttpError(any()); + verify(statusListener, times(contentWrappers.size())).onHttpSuccess(); + + // assert all content equals the correct value + assertTrue(contentWrappers.stream().map(Objects::requireNonNull).map(ChannelHandlerContent::getAsString) + .allMatch(TEST_CONTENT::equals)); + } + + @Test + public void testNoUpdateOn404ErrorInNormalMode() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withStatus(404))); + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // verify we get at least two error reports in 3s + verify(statusListener, timeout(3000).atLeast(2)).onHttpError(any()); + verify(statusListener, never()).onHttpSuccess(); + urlCache.stop(); + + // assert all content equals the correct value + assertEquals(true, contentWrappers.isEmpty()); + } + + @Test + public void testNullUpdateOn404ErrorInStrictMode() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withStatus(404))); + thingConfig.strictErrorHandling = true; + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // verify we get at least two error reports in 3s + verify(statusListener, timeout(3000).atLeast(2)).onHttpError(any()); + verify(statusListener, never()).onHttpSuccess(); + urlCache.stop(); + + int totalErrorCalls = mockingDetails(statusListener).getInvocations().size(); + + // assert we have the same number of consumer calls as error calls and all are null + assertEquals(totalErrorCalls, contentWrappers.size()); + assertEquals(true, contentWrappers.stream().allMatch(Objects::isNull)); + } + + @Test + public void testNoUpdateOnRequestTimedOutInNormalMode() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withFixedDelay(1000).withStatus(200))); + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // verify we get at least two error reports in 3s + verify(statusListener, timeout(3000).atLeast(2)).onHttpError(any()); + verify(statusListener, never()).onHttpSuccess(); + urlCache.stop(); + + // assert all content equals the correct value + assertEquals(true, contentWrappers.isEmpty()); + } + + @Test + public void testNullUpdateOnRequestTimedOutInStrictMode() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse().withFixedDelay(1000).withStatus(200))); + thingConfig.strictErrorHandling = true; + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // verify we get at least two error reports in 3s + verify(statusListener, timeout(3000).atLeast(2)).onHttpError(any()); + verify(statusListener, never()).onHttpSuccess(); + urlCache.stop(); + + int totalErrorCalls = mockingDetails(statusListener).getInvocations().size(); + + // assert we have the same number of consumer calls as error calls and all are null + assertEquals(totalErrorCalls, contentWrappers.size()); + assertEquals(true, contentWrappers.stream().allMatch(Objects::isNull)); + } + + @Test + public void testAdditionalHeaderIsSentWithRequest() { + String testHeaderKey = "X-SMARTHOME"; + String testHeaderValue = "TESTVALUE"; + + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn(aResponse() + .withBody("{{request.headers." + testHeaderKey + "}}").withTransformers("response-template"))); + thingConfig.headers = new ArrayList<>(List.of(testHeaderKey + "=" + testHeaderValue)); + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // we need only one answer + waitForAssert(() -> assertFalse(contentWrappers.isEmpty())); + urlCache.stop(); + + String returnedHeaderValue = Objects.requireNonNull(contentWrappers.get(0)).getAsString(); + assertEquals(testHeaderValue, returnedHeaderValue); + } + + @Test + public void testUserAgentIsJettyWhenNotConfigured() { + stubFor(get(urlEqualTo(TEST_LOCATION)).willReturn( + aResponse().withBody("{{request.headers.User-Agent}}").withTransformers("response-template"))); + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // we need only one answer + waitForAssert(() -> assertFalse(contentWrappers.isEmpty())); + urlCache.stop(); + + String returnedHeaderValue = Objects.requireNonNull(contentWrappers.get(0)).getAsString(); + assertEquals("Jetty/" + Jetty.VERSION, returnedHeaderValue); + } + + @Test + public void testContentSentAlongWithPost() { + stubFor(post(urlEqualTo(TEST_LOCATION)) + .willReturn(aResponse().withBody("{{request.body}}").withTransformers("response-template"))); + thingConfig.stateMethod = HttpMethod.POST; + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // we need only one answer + waitForAssert(() -> assertFalse(contentWrappers.isEmpty())); + urlCache.stop(); + + String returnedBody = Objects.requireNonNull(contentWrappers.get(0)).getAsString(); + assertEquals(TEST_CONTENT, returnedBody); + } + + @Test + public void testDateIsFormattedInURL() { + stubFor(get(urlPathEqualTo(TEST_LOCATION)) + .willReturn(aResponse().withBody("{{request.query.date}}").withTransformers("response-template"))); + url += "?date=%1$tY-%1$tm-%1$td"; + + RefreshingUrlCache urlCache = getUrlCache(TEST_CONTENT); + + // we need only one answer + waitForAssert(() -> assertFalse(contentWrappers.isEmpty())); + urlCache.stop(); + + String returnedQueryValue = Objects.requireNonNull(contentWrappers.get(0)).getAsString(); + assertTrue(returnedQueryValue.matches("\\d{4}-\\d{2}-\\d{2}")); + } + + /** + * helper method to create a {@link RefreshingUrlCache} and add a test listener + * + * @param content HTTP content + * @return the cache object + */ + private RefreshingUrlCache getUrlCache(String content) { + RefreshingUrlCache urlCache = new RefreshingUrlCache(scheduler, rateLimitedHttpClient, url, thingConfig, + content, null, statusListener); + urlCache.addConsumer(contentWrappers::add); + + return urlCache; + } +} diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/UtilTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/UtilTest.java new file mode 100644 index 0000000000000..b68e799a6a21f --- /dev/null +++ b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/UtilTest.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.http; + +import java.net.MalformedURLException; +import java.net.URISyntaxException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openhab.binding.http.internal.Util; + +/** + * The {@link UtilTest} is a test class for URL encoding + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class UtilTest { + + @Test + public void uriUTF8InHostnameEncodeTest() throws MalformedURLException, URISyntaxException { + String s = "https://foöo.bar/zhu.html?str=zin&tzz=678"; + Assertions.assertEquals("https://xn--foo-tna.bar/zhu.html?str=zin&tzz=678", Util.uriFromString(s).toString()); + } + + @Test + public void uriUTF8InPathEncodeTest() throws MalformedURLException, URISyntaxException { + String s = "https://foo.bar/zül.html?str=zin"; + Assertions.assertEquals("https://foo.bar/z%C3%BCl.html?str=zin", Util.uriFromString(s).toString()); + } + + @Test + public void uriUTF8InQueryEncodeTest() throws MalformedURLException, URISyntaxException { + String s = "https://foo.bar/zil.html?str=zän"; + Assertions.assertEquals("https://foo.bar/zil.html?str=z%C3%A4n", Util.uriFromString(s).toString()); + } + + @Test + public void uriSpaceInPathEncodeTest() throws MalformedURLException, URISyntaxException { + String s = "https://foo.bar/z l.html?str=zun"; + Assertions.assertEquals("https://foo.bar/z%20l.html?str=zun", Util.uriFromString(s).toString()); + } + + @Test + public void uriSpaceInQueryEncodeTest() throws MalformedURLException, URISyntaxException { + String s = "https://foo.bar/zzl.html?str=z n"; + Assertions.assertEquals("https://foo.bar/zzl.html?str=z%20n", Util.uriFromString(s).toString()); + } +} diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/converter/ConverterTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/converter/ConverterTest.java deleted file mode 100644 index f77c6e2a63c9d..0000000000000 --- a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/converter/ConverterTest.java +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.converter; - -import java.util.function.Function; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.openhab.binding.http.internal.config.HttpChannelConfig; -import org.openhab.binding.http.internal.transform.NoOpValueTransformation; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.PointType; -import org.openhab.core.library.types.QuantityType; -import org.openhab.core.library.types.StringType; -import org.openhab.core.library.unit.SIUnits; -import org.openhab.core.library.unit.Units; -import org.openhab.core.types.Command; -import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; - -/** - * The {@link ConverterTest} is a test class for state converters - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -public class ConverterTest { - - @Test - public void numberItemConverter() { - NumberItemConverter converter = new NumberItemConverter(this::updateState, this::postCommand, - this::sendHttpValue, NoOpValueTransformation.getInstance(), NoOpValueTransformation.getInstance(), - new HttpChannelConfig()); - - // without unit - Assertions.assertEquals(new DecimalType(1234), converter.toState("1234")); - - // unit in transformation result - Assertions.assertEquals(new QuantityType<>(100, SIUnits.CELSIUS), converter.toState("100°C")); - - // no valid value - Assertions.assertEquals(UnDefType.UNDEF, converter.toState("W")); - Assertions.assertEquals(UnDefType.UNDEF, converter.toState("")); - } - - @Test - public void numberItemConverterWithUnit() { - HttpChannelConfig channelConfig = new HttpChannelConfig(); - channelConfig.unit = "W"; - NumberItemConverter converter = new NumberItemConverter(this::updateState, this::postCommand, - this::sendHttpValue, NoOpValueTransformation.getInstance(), NoOpValueTransformation.getInstance(), - channelConfig); - - // without unit - Assertions.assertEquals(new QuantityType<>(500, Units.WATT), converter.toState("500")); - - // no valid value - Assertions.assertEquals(UnDefType.UNDEF, converter.toState("100foo")); - Assertions.assertEquals(UnDefType.UNDEF, converter.toState("foo")); - Assertions.assertEquals(UnDefType.UNDEF, converter.toState("")); - } - - @Test - public void stringTypeConverter() { - GenericItemConverter converter = createConverter(StringType::new); - Assertions.assertEquals(new StringType("Test"), converter.toState("Test")); - } - - @Test - public void decimalTypeConverter() { - GenericItemConverter converter = createConverter(DecimalType::new); - Assertions.assertEquals(new DecimalType(15.6), converter.toState("15.6")); - } - - @Test - public void pointTypeConverter() { - GenericItemConverter converter = createConverter(PointType::new); - Assertions.assertEquals(new PointType(new DecimalType(51.1), new DecimalType(7.2), new DecimalType(100)), - converter.toState("51.1, 7.2, 100")); - } - - private void sendHttpValue(String value) { - } - - private void updateState(State state) { - } - - public void postCommand(Command command) { - } - - public GenericItemConverter createConverter(Function fcn) { - return new GenericItemConverter(fcn, this::updateState, this::postCommand, this::sendHttpValue, - NoOpValueTransformation.getInstance(), NoOpValueTransformation.getInstance(), new HttpChannelConfig()); - } -} diff --git a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/http/HttpResponseListenerTest.java b/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/http/HttpResponseListenerTest.java deleted file mode 100644 index d104c74585329..0000000000000 --- a/bundles/org.openhab.binding.http/src/test/java/org/openhab/binding/http/internal/http/HttpResponseListenerTest.java +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Copyright (c) 2010-2024 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.http.internal.http; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.io.UnsupportedEncodingException; -import java.nio.ByteBuffer; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.api.Response; -import org.eclipse.jetty.client.api.Result; -import org.eclipse.jetty.http.HttpFields; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpStatus; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -/** - * Unit tests for {@link HttpResponseListenerTest}. - * - * @author Corubba Smith - Initial contribution - */ -@NonNullByDefault -@ExtendWith(MockitoExtension.class) -public class HttpResponseListenerTest { - - private Request request = mock(Request.class); - private Response response = mock(Response.class); - - // ******** Common methods ******** // - - /** - * Run the given listener with the given result. - */ - private void run(HttpResponseListener listener, Result result) { - listener.onComplete(result); - } - - /** - * Return a default Result using the request- and response-mocks and no failure. - */ - private Result createResult() { - return new Result(request, response); - } - - /** - * Run the given listener with a default result. - */ - private void run(HttpResponseListener listener) { - run(listener, createResult()); - } - - /** - * Set the given payload as body of the response in the buffer of the given listener. - */ - private void setPayload(HttpResponseListener listener, byte[] payload) { - listener.onContent(null, ByteBuffer.wrap(payload)); - } - - /** - * Run a default listener with the given result and the given payload. - */ - private CompletableFuture<@Nullable Content> run(Result result, byte @Nullable [] payload) { - CompletableFuture<@Nullable Content> future = new CompletableFuture<>(); - HttpResponseListener listener = new HttpResponseListener(future, null, 1024 * 1024); - if (null != payload) { - setPayload(listener, payload); - } - run(listener, result); - return future; - } - - /** - * Run a default listener with the given result. - */ - private CompletableFuture<@Nullable Content> run(Result result) { - return run(result, null); - } - - /** - * Run a default listener with a default result and the given payload. - */ - private CompletableFuture<@Nullable Content> run(byte @Nullable [] payload) { - return run(createResult(), payload); - } - - /** - * Run a default listener with a default result. - */ - private CompletableFuture<@Nullable Content> run() { - return run(createResult()); - } - - @BeforeEach - void init() { - // required for the request trace - when(response.getHeaders()).thenReturn(new HttpFields()); - } - - // ******** Tests ******** // - - /** - * When an exception is thrown during the request phase, the future completes unexceptionally - * with no value. - */ - @Test - public void requestException() { - RuntimeException requestFailure = new RuntimeException("The request failed!"); - Result result = new Result(request, requestFailure, response); - - CompletableFuture<@Nullable Content> future = run(result); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - assertNull(future.join()); - } - - /** - * When an exception is thrown during the response phase, the future completes unexceptionally - * with no value. - */ - @Test - public void responseException() { - RuntimeException responseFailure = new RuntimeException("The response failed!"); - Result result = new Result(request, response, responseFailure); - - CompletableFuture<@Nullable Content> future = run(result); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - assertNull(future.join()); - } - - /** - * When the remote side does not send any payload, the future completes normally and contains a - * empty Content. - */ - @Test - public void okWithNoBody() { - when(response.getStatus()).thenReturn(HttpStatus.OK_200); - - CompletableFuture<@Nullable Content> future = run(); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - - Content content = future.join(); - assertNotNull(content); - assertNotNull(content.getRawContent()); - assertEquals(0, content.getRawContent().length); - assertNull(content.getMediaType()); - } - - /** - * When the remote side sends a payload, the future completes normally and contains a Content - * object with the payload. - */ - @Test - public void okWithBody() { - when(response.getStatus()).thenReturn(HttpStatus.OK_200); - - final String textPayload = "foobar"; - CompletableFuture<@Nullable Content> future = run(textPayload.getBytes()); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - - Content content = future.join(); - assertNotNull(content); - assertNotNull(content.getRawContent()); - assertEquals(textPayload, new String(content.getRawContent())); - assertNull(content.getMediaType()); - } - - /** - * When the remote side sends a payload and encoding header, the future completes normally - * and contains a Content object with the payload. The payload gets decoded using the encoding - * the remote sent. - */ - @Test - public void okWithEncodedBody() throws UnsupportedEncodingException { - final String encodingName = "UTF-16LE"; - final String fallbackEncodingName = "UTF-8"; - - CompletableFuture<@Nullable Content> future = new CompletableFuture<>(); - HttpResponseListener listener = new HttpResponseListener(future, fallbackEncodingName, 1024 * 1024); - - response.getHeaders().put(HttpHeader.CONTENT_TYPE, "text/plain; charset=" + encodingName); - when(response.getRequest()).thenReturn(request); - listener.onHeaders(response); - - final String textPayload = "漢字編碼方法"; - setPayload(listener, textPayload.getBytes(encodingName)); - - when(response.getStatus()).thenReturn(HttpStatus.OK_200); - run(listener); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - - Content content = future.join(); - assertNotNull(content); - assertNotNull(content.getRawContent()); - assertEquals(textPayload, new String(content.getRawContent(), encodingName)); - assertEquals(textPayload, content.getAsString()); - assertEquals("text/plain", content.getMediaType()); - } - - /** - * When the remote side sends a payload but no encoding, the future completes normally and - * contains a Content object with the payload. The payload gets decoded using the fallback - * encoding of the listener. - */ - @Test - public void okWithEncodedBodyFallback() throws UnsupportedEncodingException { - final String encodingName = "UTF-16BE"; - - CompletableFuture<@Nullable Content> future = new CompletableFuture<>(); - HttpResponseListener listener = new HttpResponseListener(future, encodingName, 1024 * 1024); - - final String textPayload = "汉字编码方法"; - setPayload(listener, textPayload.getBytes(encodingName)); - - when(response.getStatus()).thenReturn(HttpStatus.OK_200); - run(listener); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - - Content content = future.join(); - assertNotNull(content); - assertNotNull(content.getRawContent()); - assertEquals(textPayload, new String(content.getRawContent(), encodingName)); - assertEquals(textPayload, content.getAsString()); - assertNull(content.getMediaType()); - } - - /** - * When the remote side response with a HTTP/204 and no payload, the future completes normally - * and contains an empty Content. - */ - @Test - public void nocontent() { - when(response.getStatus()).thenReturn(HttpStatus.NO_CONTENT_204); - - CompletableFuture<@Nullable Content> future = run(); - - assertTrue(future.isDone()); - assertFalse(future.isCompletedExceptionally()); - - Content content = future.join(); - assertNotNull(content); - assertNotNull(content.getRawContent()); - assertEquals(0, content.getRawContent().length); - assertNull(content.getMediaType()); - } - - /** - * When the remote side response with a HTTP/401, the future completes exceptionally with a - * HttpAuthException. - */ - @Test - public void unauthorized() { - when(response.getStatus()).thenReturn(HttpStatus.UNAUTHORIZED_401); - - CompletableFuture<@Nullable Content> future = run(); - - assertTrue(future.isDone()); - assertTrue(future.isCompletedExceptionally()); - - @Nullable - CompletionException exceptionWrapper = assertThrows(CompletionException.class, () -> future.join()); - assertNotNull(exceptionWrapper); - - Throwable exception = exceptionWrapper.getCause(); - assertNotNull(exception); - assertTrue(exception instanceof HttpAuthException); - } - - /** - * When the remote side responds with anything we don't expect (in this case a HTTP/500), the - * future completes exceptionally with an IllegalStateException. - */ - @Test - public void unexpectedStatus() { - when(response.getStatus()).thenReturn(HttpStatus.INTERNAL_SERVER_ERROR_500); - - CompletableFuture<@Nullable Content> future = run(); - - assertTrue(future.isDone()); - assertTrue(future.isCompletedExceptionally()); - - @Nullable - CompletionException exceptionWrapper = assertThrows(CompletionException.class, () -> future.join()); - assertNotNull(exceptionWrapper); - - Throwable exception = exceptionWrapper.getCause(); - assertNotNull(exception); - assertTrue(exception instanceof IllegalStateException); - assertEquals("Response - Code500", exception.getMessage()); - } -} diff --git a/bundles/org.openhab.binding.http/src/test/resources/simplelogger.properties b/bundles/org.openhab.binding.http/src/test/resources/simplelogger.properties deleted file mode 100644 index 057011dda0f64..0000000000000 --- a/bundles/org.openhab.binding.http/src/test/resources/simplelogger.properties +++ /dev/null @@ -1,2 +0,0 @@ -# to run through all code-branches -org.slf4j.simpleLogger.log.org.openhab.binding.http=trace