[http] Improve binding (#16282)

This adds many improvements, new features and contains bugfixes.

Signed-off-by: Jan N. Klug <github@klug.nrw>
This commit is contained in:
J-N-K 2024-01-14 22:08:11 +01:00 committed by GitHub
parent 1c67114daf
commit 0fe8d79c9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1101 additions and 2091 deletions

View File

@ -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.|
| 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:_ 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.
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. |
| `escapedUrl` | yes | - | This specifies whether the URL is already escaped. |
| `stateContent` | yes | - | Content for state requests (if method is `PUT` or `POST`) |
| 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" ]
}
```

View File

@ -14,4 +14,47 @@
<name>openHAB Add-ons :: Bundles :: HTTP Binding</name>
<properties>
<jetty.version>9.4.52.v20230823</jetty.version>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlet</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlets</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-proxy</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-webapp</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<!-- testing, we need to exclude and declare jetty bundles because the declared transitive dependency 9.2.28 is too old -->
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock</artifactId>
<version>2.27.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -14,6 +14,7 @@ package org.openhab.binding.http.internal;
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 @@ import org.openhab.core.thing.ThingTypeUID;
*/
@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";
}

View File

@ -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.NonNullByDefault;
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 @@ import org.slf4j.LoggerFactory;
*/
@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<ThingTypeUID> 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 class HttpHandlerFactory extends BaseThingHandlerFactory
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;

View File

@ -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 java.util.function.Function;
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.Thing;
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 @@ import org.slf4j.LoggerFactory;
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class HttpThingHandler extends BaseThingHandler {
public class HttpThingHandler extends BaseThingHandler implements HttpStatusListener {
private static final Set<Character> 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<String, RefreshingUrlCache> urlHandlers = new HashMap<>();
private final Map<ChannelUID, ItemValueConverter> channels = new HashMap<>();
private final Map<ChannelUID, ChannelHandler> channels = new HashMap<>();
private final Map<ChannelUID, String> 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 class HttpThingHandler extends BaseThingHandler {
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 class HttpThingHandler extends BaseThingHandler {
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 class HttpThingHandler extends BaseThingHandler {
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 class HttpThingHandler extends BaseThingHandler {
* @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 @@ public class HttpThingHandler extends BaseThingHandler {
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 @@ public class HttpThingHandler extends BaseThingHandler {
}
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, ""));
}
}
private void sendHttpValue(String commandUrl, boolean escapedUrl, String command, boolean isRetry) {
@Override
public void onHttpSuccess() {
updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
updateStatus(ThingStatus.ONLINE);
}
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));
}
}
rateLimitedHttpClient.newPriorityRequest(uri, config.commandMethod, command, config.contentType)
.thenAccept(request -> {
request.timeout(config.timeout, TimeUnit.MILLISECONDS);
config.getHeaders().forEach(request::header);
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);
}
});
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));
}
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);
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 @@ public class HttpThingHandler extends BaseThingHandler {
}
}
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<String, State> 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);
}
}

View File

@ -54,13 +54,14 @@ public class Util {
* 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());
}
}

View File

@ -23,5 +23,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
public enum HttpAuthMode {
BASIC_PREEMPTIVE,
BASIC,
DIGEST
DIGEST,
TOKEN
}

View File

@ -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 @@ import org.openhab.core.types.State;
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
public class HttpChannelConfig {
private final Map<String, State> stringStateMap = new HashMap<>();
private final Map<Command, @Nullable String> 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);
}
}
}

View File

@ -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
}

View File

@ -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 @@ import org.eclipse.jetty.http.HttpMethod;
*/
@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<String> headers = new ArrayList<>();
public String userAgent = "";
public Map<String, String> getHeaders() {
Map<String, String> 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;
}
}

View File

@ -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<State> updateState;
private final Consumer<Command> postCommand;
private final @Nullable Consumer<String> sendHttpValue;
private final ValueTransformation stateTransformations;
private final ValueTransformation commandTransformations;
protected HttpChannelConfig channelConfig;
public AbstractTransformingItemConverter(Consumer<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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<String> 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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> sendHttpValue, ValueTransformation stateTransformations,
ValueTransformation commandTransformations, HttpChannelConfig channelConfig);
}
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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
}
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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;
}
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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;
}
}

View File

@ -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<String, State> toState;
public GenericItemConverter(Function<String, State> toState, Consumer<State> updateState,
Consumer<Command> postCommand, @Nullable Consumer<String> 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();
}
}

View File

@ -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<State> updateState;
public ImageItemConverter(Consumer<State> 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");
}
}

View File

@ -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);
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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();
}
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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;
}
}

View File

@ -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<State> updateState, Consumer<Command> postCommand,
@Nullable Consumer<String> 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;
}
}

View File

@ -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;
}
}

View File

@ -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.api.Result;
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 @@ import org.slf4j.LoggerFactory;
@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 class HttpResponseListener extends BufferingResponseListener {
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;
}
}

View File

@ -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 message optional error message
*/
void onHttpError(@Nullable String message);
/**
*
* @param pattern A transformation pattern, starting with the transformation service
* * name, followed by a colon and the transformation itself.
* @return
* report a successful request
*/
ValueTransformation getValueTransformation(@Nullable String pattern);
void onHttpSuccess();
}

View File

@ -24,10 +24,13 @@ import java.util.concurrent.TimeUnit;
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 @@ import org.eclipse.jetty.http.HttpMethod;
@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<RequestQueueEntry> requestQueue = new LinkedBlockingQueue<>(MAX_QUEUE_SIZE);
private final LinkedBlockingQueue<RequestQueueEntry> priorityRequestQueue = new LinkedBlockingQueue<>(
MAX_QUEUE_SIZE);
private @Nullable ScheduledFuture<?> processJob;
@ -55,7 +62,7 @@ public class RateLimitedHttpClient {
*/
public void shutdown() {
stopProcessJob();
requestQueue.forEach(queueEntry -> queueEntry.future.completeExceptionally(new CancellationException()));
requestQueue.forEach(RequestQueueEntry::cancel);
}
/**
@ -77,7 +84,7 @@ public class RateLimitedHttpClient {
/**
* 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 class RateLimitedHttpClient {
* @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<Request> newRequest(URI finalUrl, HttpMethod method, String content) {
public CompletableFuture<Request> 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<Request> newPriorityRequest(URI finalUrl, HttpMethod method, String content,
@Nullable String contentType) {
return queueRequest(finalUrl, method, content, contentType, priorityRequestQueue);
}
private CompletableFuture<Request> queueRequest(URI finalUrl, HttpMethod method, String content,
@Nullable String contentType, LinkedBlockingQueue<RequestQueueEntry> queue) {
// if no delay is set, return a completed CompletableFuture
CompletableFuture<Request> 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 @@ public class RateLimitedHttpClient {
}
}
/**
* 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<Request> future;
private final URI finalUrl;
private final HttpMethod method;
private final String content;
private final @Nullable String contentType;
private final CompletableFuture<Request> future;
public RequestQueueEntry(URI finalUrl, HttpMethod method, String content, CompletableFuture<Request> future) {
public RequestQueueEntry(URI finalUrl, HttpMethod method, String content, @Nullable String contentType,
CompletableFuture<Request> future) {
this.finalUrl = finalUrl;
this.method = method;
this.content = content;
this.contentType = contentType;
this.future = future;
}
@ -149,10 +205,21 @@ public class RateLimitedHttpClient {
*/
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());
}
}
}

View File

@ -16,7 +16,7 @@ import java.net.MalformedURLException;
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 java.util.function.Consumer;
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<Consumer<Content>> consumers = ConcurrentHashMap.newKeySet();
private final List<String> headers;
private final Set<Consumer<@Nullable ChannelHandlerContent>> consumers = ConcurrentHashMap.newKeySet();
private final Map<String, String> 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 @@ public class RefreshingUrlCache {
// 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(request::header);
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);
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 @@ public class RefreshingUrlCache {
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 class RefreshingUrlCache {
logger.trace("Stopped refresh task for URL '{}'", url);
}
public void addConsumer(Consumer<Content> consumer) {
public void addConsumer(Consumer<@Nullable ChannelHandlerContent> consumer) {
consumers.add(consumer);
}
public Optional<Content> get() {
final Content content = lastContent;
if (content == null) {
return Optional.empty();
} else {
return Optional.of(content);
}
public Optional<ChannelHandlerContent> get() {
return Optional.ofNullable(lastContent);
}
private void processResult(@Nullable Content content) {
if (content != null) {
for (Consumer<Content> 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) {

View File

@ -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<ValueTransformation> transformations;
public CascadedValueTransformationImpl(String transformationString,
Function<String, @Nullable TransformationService> transformationServiceSupplier) {
List<ValueTransformation> 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<String> apply(String value) {
Optional<String> valueOptional = Optional.of(value);
// process all transformations
for (ValueTransformation transformation : transformations) {
valueOptional = valueOptional.flatMap(transformation::apply);
}
return valueOptional;
}
}

View File

@ -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<String> apply(String value) {
return Optional.of(value);
}
/**
* get the static value transformation for identity
*
* @return
*/
public static ValueTransformation getInstance() {
return NO_OP_VALUE_TRANSFORMATION;
}
}

View File

@ -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<String, @Nullable TransformationService> 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<String, @Nullable TransformationService> 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<String> 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 + "'}";
}
}

View File

@ -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<String> apply(String value);
}

View File

@ -6,6 +6,6 @@
<type>binding</type>
<name>HTTP Binding</name>
<description>This is the binding for retrieving and processing HTTP resources.</description>
<connection>local</connection>
<connection>hybrid</connection>
</addon:addon>

View File

@ -7,13 +7,11 @@
<config-description uri="channel-type:http:channel-config">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving values. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending values. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -25,13 +23,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -53,13 +44,11 @@
<config-description uri="channel-type:http:channel-config-color">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving values.</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -71,13 +60,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -131,13 +113,11 @@
<config-description uri="channel-type:http:channel-config-contact">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -149,13 +129,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -185,13 +158,11 @@
<config-description uri="channel-type:http:channel-config-dimmer">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -203,13 +174,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -256,12 +220,6 @@
<description>This value is added to the base URL configured in the thing for retrieving values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL and stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -272,13 +230,11 @@
<config-description uri="channel-type:http:channel-config-number">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -290,13 +246,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -323,13 +272,11 @@
<config-description uri="channel-type:http:channel-config-player">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -341,13 +288,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -393,13 +333,11 @@
<config-description uri="channel-type:http:channel-config-rollershutter">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values Chain multiple transformations with the mathematical
intersection character "∩"..</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -411,13 +349,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>
@ -455,13 +386,11 @@
<config-description uri="channel-type:http:channel-config-switch">
<parameter name="stateTransformation" type="text">
<label>State Transformation</label>
<description>Transformation pattern used when receiving values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when receiving value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="commandTransformation" type="text">
<label>Command Transformation</label>
<description>Transformation pattern used when sending values. Chain multiple transformations with the mathematical
intersection character "∩".</description>
<description>Transformation pattern used when sending value. Multiple transformation can be chained using "∩".</description>
</parameter>
<parameter name="stateExtension" type="text">
<label>State URL Extension</label>
@ -473,13 +402,6 @@
<description>This value is added to the base URL configured in the thing for sending values.</description>
<advanced>true</advanced>
</parameter>
<parameter name="escapedUrl" type="boolean">
<label>Escaped URL</label>
<description>This specifies whether the URL is already escaped. Applies to the base URL, commandExtension and
stateExtension.</description>
<advanced>true</advanced>
<default>false</default>
</parameter>
<parameter name="stateContent" type="text">
<label>State Content</label>
<description>Content for state request (only used if method is POST/PUT)</description>

View File

@ -9,6 +9,19 @@
<label>HTTP URL Thing</label>
<description>Represents a base URL and all associated requests.</description>
<channels>
<channel typeId="request-date-time" id="last-failure">
<label>Last Failure</label>
</channel>
<channel typeId="request-date-time" id="last-success">
<label>Last Success</label>
</channel>
</channels>
<properties>
<property name="thingTypeVersion">2</property>
</properties>
<config-description>
<parameter name="baseURL" type="text" required="true">
<label>Base URL</label>
@ -44,7 +57,7 @@
</parameter>
<parameter name="password" type="text">
<label>Password</label>
<description>Basic Authentication password</description>
<description>Authentication password or token</description>
<context>password</context>
<advanced>true</advanced>
</parameter>
@ -54,6 +67,7 @@
<option value="BASIC">Basic Authentication</option>
<option value="BASIC_PREEMPTIVE">Preemptive Basic Authentication</option>
<option value="DIGEST">Digest Authentication</option>
<option value="TOKEN">Token/Bearer Authentication</option>
</options>
<default>BASIC</default>
<limitToOptions>true</limitToOptions>
@ -112,9 +126,20 @@
<default>false</default>
<advanced>true</advanced>
</parameter>
<parameter name="userAgent" type="text">
<label>User Agent</label>
<description>Sets a custom user agent (default is "Jetty/version", e.g. "Jetty/9.4.20.v20190813").</description>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<channel-type id="request-date-time">
<item-type>DateTime</item-type>
<label>Dummy</label>
<state readOnly="true" pattern="%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS"/>
</channel-type>
<channel-type id="color">
<item-type>Color</item-type>
<label>Color Channel</label>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
<thing-type uid="http:url">
<instruction-set targetVersion="1">
<add-channel id="lastSuccess">
<type>http:requestDateTime</type>
<label>Last Success</label>
</add-channel>
<add-channel id="lastFailure">
<type>http:requestDateTime</type>
<label>Last Failure</label>
</add-channel>
</instruction-set>
<instruction-set targetVersion="2">
<remove-channel id="lastSuccess"/>
<remove-channel id="lastFailure"/>
<add-channel id="last-success">
<type>http:request-date-time</type>
<label>Last Success</label>
</add-channel>
<add-channel id="last-failure">
<type>http:request-date-time</type>
<label>Last Failure</label>
</add-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>

View File

@ -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();
}
}

View File

@ -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<Response> 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<Response> doLimitTest(int setDelay, List<Boolean> 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<Request> 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();
}
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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<String, State> fcn) {
return new GenericItemConverter(fcn, this::updateState, this::postCommand, this::sendHttpValue,
NoOpValueTransformation.getInstance(), NoOpValueTransformation.getInstance(), new HttpChannelConfig());
}
}

View File

@ -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());
}
}

View File

@ -1,2 +0,0 @@
# to run through all code-branches
org.slf4j.simpleLogger.log.org.openhab.binding.http=trace