This adds many improvements, new features and contains bugfixes.
Signed-off-by: Jan N. Klug <github@klug.nrw>
## Thing Configuration
-| parameter | optional | default | description |
-|-------------------|----------|---------|-------------|
-| `baseURL` | no | - | The base URL for this thing. Can be extended in channel-configuration. |
-| `refresh` | no | 30 | Time in seconds between two refresh calls for the channels of this thing. |
-| `timeout` | no | 3000 | Timeout for HTTP requests in ms. |
-| `bufferSize` | no | 2048 | The buffer size for the response data (in kB). |
-| `delay` | no | 0 | Delay between two requests in ms (advanced parameter). |
-| `username` | yes | - | Username for authentication (advanced parameter). |
-| `password` | yes | - | Password for authentication (advanced parameter). |
-| `authMode` | no | BASIC | Authentication mode, `BASIC`, `BASIC_PREEMPTIVE` or `DIGEST` (advanced parameter). |
-| `stateMethod` | no | GET | Method used for requesting the state: `GET`, `PUT`, `POST`. |
-| `commandMethod` | no | GET | Method used for sending commands: `GET`, `PUT`, `POST`. |
-| `contentType` | yes | - | MIME content-type of the command requests. Only used for `PUT` and `POST`. |
-| `encoding` | yes | - | Encoding to be used if no encoding is found in responses (advanced parameter). |
-| `headers` | yes | - | Additional headers that are sent along with the request. Format is "header=value". Multiple values can be stored as `headers="key1=value1", "key2=value2", "key3=value3",`. When using text based configuration include at minimum 2 headers to avoid parsing errors.|
-| `ignoreSSLErrors` | no | false | If set to true ignores invalid SSL certificate errors. This is potentially dangerous.|
-
-_Note:_ Optional "no" means that you have to configure a value unless a default is provided and you are ok with that setting.
+| parameter | optional | default | description |
+|-----------------------|----------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `baseURL` | no | - | The base URL (including protocol `http://` or `https://`) for this thing. Can be extended in channel-configuration. |
+| `refresh` | no | 30 | Time in seconds between two refresh calls for the channels of this thing. |
+| `timeout` | no | 3000 | Timeout for HTTP requests in ms. |
+| `bufferSize` | no | 2048 | The buffer size for the response data (in kB). |
+| `delay` | no | 0 | Delay between two requests in ms (advanced parameter). |
+| `username` | yes | - | Username for authentication (advanced parameter). |
+| `password` | yes | - | Password for authentication (advanced parameter). Also used for the authentication token when using `TOKEN` authentication. |
+| `authMode` | no | BASIC | Authentication mode, `BASIC`, `BASIC_PREEMPTIVE`, `TOKEN` or `DIGEST` (advanced parameter). |
+| `stateMethod` | no | GET | Method used for requesting the state: `GET`, `PUT`, `POST`. |
+| `commandMethod` | no | GET | Method used for sending commands: `GET`, `PUT`, `POST`. |
+| `contentType` | yes | - | MIME content-type of the command requests. Only used for `PUT` and `POST`. |
+| `encoding` | yes | - | Encoding to be used if no encoding is found in responses (advanced parameter). |
+| `headers` | yes | - | Additional headers that are sent along with the request. Format is "header=value". Multiple values can be stored as `headers="key1=value1", "key2=value2", "key3=value3",` |
+| `ignoreSSLErrors` | no | false | If set to true, ignores invalid SSL certificate errors. This is potentially dangerous. |
+| `strictErrorHandling` | no | false | If set to true, thing status is changed depending on last request result (failed = `OFFLINE`). Failed requests result in `UNDEF` for channel values. |
+| `userAgent` | yes | (yes ) | Sets a custom user agent (default is "Jetty/version", e.g. "Jetty/9.4.20.v20190813"). |
+
+_Note:_ Optional "no" means that you have to configure a value unless a default is provided, and you are ok with that setting.
_Note:_ The `BASIC_PREEMPTIVE` mode adds basic authentication headers even if the server did not request authentication.
This is dangerous and might be misused.
_Note:_ If you rate-limit requests by using the `delay` parameter you have to make sure that the time between two refreshes is larger than the time needed for one refresh cycle.
-**Attention:** `baseUrl` (and `stateExtension`/`commandExtension`) should not normally use escaping (e.g. `%22` instead of `"` or `%2c` instead of `,`).
+**Attention:** `baseUrl` (and `stateExtension`/`commandExtension`) should not use escaping (e.g. `%22` instead of `"` or `%2c` instead of `,`).
URLs are properly escaped by the binding itself before the request is sent.
Using escaped strings in URL parameters may lead to problems with the formatting (see below).
-In certain scenarios you may need to manually escape your URL, for example if you need to include an escaped `=` (`%3D`) in this scenario include `%%3D` in the URL to preserve the `%` during formatting, and set the parameter `escapedUrl` to true on the channel.
-
## Channels
+The thing has two channels of type `request-date-time` which provide the timestamp of the last successful (`last-success`) and last failed (`last-failure`) request.
+
+Additionally, the thing can be extended with data channels.
Each item type has its own channel-type.
Depending on the channel-type, channels have different configuration options.
All channel-types (except `image`) have `stateExtension`, `commandExtension`, `stateTransformation`, `commandTransformation` and `mode` parameters.
-The `image` channel-type supports `stateExtension`, `stateContent` and `escapedUrl` only.
-
-| parameter | optional | default | description |
-|-------------------------|----------|-------------|-------------|
-| `stateExtension` | yes | - | Appended to the `baseURL` for requesting states. |
-| `commandExtension` | yes | - | Appended to the `baseURL` for sending commands. If empty, same as `stateExtension`. |
-| `stateTransformation` | yes | - | One or more transformation applied to received values before updating channel. |
-| `commandTransformation` | yes | - | One or more transformation applied to channel value before sending to a remote. |
-| `escapedUrl` | yes | - | This specifies whether the URL is already escaped. |
-| `stateContent` | yes | - | Content for state requests (if method is `PUT` or `POST`) |
+The `image` channel-type supports `stateExtension` only.
+
+| parameter | optional | default | description |
+|-------------------------|----------|-------------|-----------------------------------------------------------------------------------------------------------|
+| `stateExtension` | yes | - | Appended to the `baseURL` for requesting states. |
+| `commandExtension` | yes | - | Appended to the `baseURL` for sending commands. If empty, same as `stateExtension`. |
+| `stateTransformation ` | yes | - | One or more transformation applied to received values before updating channel. |
+| `commandTransformation` | yes | - | One or more transformation applied to channel value before sending to a remote. |
+| `stateContent` | yes | - | Content for state requests (if method is `PUT` or `POST`) |
| `mode` | no | `READWRITE` | Mode this channel is allowed to operate. `READONLY` means receive state, `WRITEONLY` means send commands. |
-Transformations need to be specified in the same format as
Some channels have additional parameters.
When concatenating the `baseURL` and `stateExtension` or `commandExtension` the binding checks if a proper URL part separator (`/`, `&` or `?`) is present and adds a `/` if missing.
### `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`.
### `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$`)
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
```
```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" ]
}
```
<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>
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
*/
@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";
}
*/
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;
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;
*/
@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);
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
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;
*/
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;
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;
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;
* @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;
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());
}
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));
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
* @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);
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());
}
channels.put(channelUID, itemValueConverter);
- if (channelConfig.mode != HttpChannelMode.WRITEONLY) {
+ if (channelConfig.mode != ChannelMode.WRITEONLY) {
// we need a key consisting of stateContent and URL, only if both are equal, we can use the same cache
String key = channelConfig.stateContent + "$" + stateUrl;
channelUrls.put(channelUID, key);
- urlHandlers
- .computeIfAbsent(key,
- k -> new RefreshingUrlCache(scheduler, rateLimitedHttpClient, stateUrl,
- channelConfig.escapedUrl, config, channelConfig.stateContent))
+ Objects.requireNonNull(urlHandlers.computeIfAbsent(key,
+ k -> new RefreshingUrlCache(scheduler, rateLimitedHttpClient, stateUrl, config,
+ channelConfig.stateContent, config.contentType, this)))
.addConsumer(itemValueConverter::process);
}
StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
- .withReadOnly(channelConfig.mode == HttpChannelMode.READONLY).build().toStateDescription();
+ .withReadOnly(channelConfig.mode == ChannelMode.READONLY).build().toStateDescription();
if (stateDescription != null) {
// if the state description is not available, we don't need to add it
httpDynamicStateDescriptionProvider.setDescription(channelUID, stateDescription);
}
}
- private void sendHttpValue(String commandUrl, boolean escapedUrl, String command) {
- sendHttpValue(commandUrl, escapedUrl, command, false);
+ @Override
+ public void onHttpError(@Nullable String message) {
+ updateState(CHANNEL_LAST_FAILURE, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
+ if (config.strictErrorHandling) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ Objects.requireNonNullElse(message, ""));
+ }
+ }
+
+ @Override
+ public void onHttpSuccess() {
+ updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
+ updateStatus(ThingStatus.ONLINE);
}
- private void sendHttpValue(String commandUrl, boolean escapedUrl, String command, boolean isRetry) {
+ private void sendHttpValue(String commandUrl, String command) {
+ sendHttpValue(commandUrl, command, false);
+ }
+
+ private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
try {
// format URL
- String url = String.format(commandUrl, new Date(), command);
- URI uri = escapedUrl ? new URI(url) : Util.uriFromString(url);
+ URI uri = Util.uriFromString(String.format(commandUrl, new Date(), command));
// build request
- Request request = httpClient.newRequest(uri).timeout(config.timeout, TimeUnit.MILLISECONDS)
- .method(config.commandMethod);
- if (config.commandMethod != HttpMethod.GET) {
- final String contentType = config.contentType;
- if (contentType != null) {
- request.content(new StringContentProvider(command), contentType);
- } else {
- request.content(new StringContentProvider(command));
- }
- }
-
- config.headers.forEach(header -> {
- String[] keyValuePair = header.split("=", 2);
- if (keyValuePair.length == 2) {
- request.header(keyValuePair[0], keyValuePair[1]);
- } else {
- logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header);
- }
- });
-
- if (logger.isTraceEnabled()) {
- logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request));
- }
-
- CompletableFuture<@Nullable Content> f = new CompletableFuture<>();
- f.exceptionally(e -> {
- if (e instanceof HttpAuthException) {
- if (isRetry) {
- logger.warn("Retry after authentication failure failed again for '{}', failing here", uri);
- } else {
- AuthenticationStore authStore = httpClient.getAuthenticationStore();
- Authentication.Result authResult = authStore.findAuthenticationResult(uri);
- if (authResult != null) {
- authStore.removeAuthenticationResult(authResult);
- logger.debug("Cleared authentication result for '{}', retrying immediately", uri);
- sendHttpValue(commandUrl, escapedUrl, command, true);
- } else {
- logger.warn("Could not find authentication result for '{}', failing here", uri);
+ rateLimitedHttpClient.newPriorityRequest(uri, config.commandMethod, command, config.contentType)
+ .thenAccept(request -> {
+ request.timeout(config.timeout, TimeUnit.MILLISECONDS);
+ config.getHeaders().forEach(request::header);
+
+ CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>();
+ responseContentFuture.exceptionally(t -> {
+ if (t instanceof HttpAuthException) {
+ if (isRetry || !rateLimitedHttpClient.reAuth(uri)) {
+ logger.warn(
+ "Retry after authentication failure failed again for '{}', failing here",
+ uri);
+ onHttpError("Authentication failed");
+ } else {
+ sendHttpValue(commandUrl, command, true);
+ }
+ }
+ return null;
+ });
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request));
}
- }
- }
- return null;
- });
- request.send(new HttpResponseListener(f, null, config.bufferSize));
+
+ request.send(new HttpResponseListener(responseContentFuture, null, config.bufferSize, this));
+ });
} catch (IllegalArgumentException | URISyntaxException | MalformedURLException e) {
logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
}
}
}
- 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);
}
}
* 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());
}
}
public enum HttpAuthMode {
BASIC_PREEMPTIVE,
BASIC,
- DIGEST
+ DIGEST,
+ TOKEN
}
*/
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.
* @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);
- }
- }
}
+++ /dev/null
-/**
- * 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
-}
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.
*/
@NonNullByDefault
public class HttpThingConfig {
+ private final Logger logger = LoggerFactory.getLogger(HttpThingConfig.class);
+
public String baseURL = "";
public int refresh = 30;
public int timeout = 3000;
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;
+ }
}
+++ /dev/null
-/**
- * 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);
- }
-}
+++ /dev/null
-/**
- * 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
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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();
- }
-}
+++ /dev/null
-/**
- * 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");
- }
-}
+++ /dev/null
-/**
- * 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);
-}
+++ /dev/null
-/**
- * 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();
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
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;
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;
@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;
/**
* @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
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;
}
}
--- /dev/null
+/**
+ * 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link HttpStatusListener} is an interface for reporting HTTP request states
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public interface HttpStatusListener {
+ /**
+ * report an error
+ *
+ * @param message optional error message
+ */
+ void onHttpError(@Nullable String message);
+
+ /**
+ * report a successful request
+ */
+ void onHttpSuccess();
+}
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
@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;
*/
public void shutdown() {
stopProcessJob();
- requestQueue.forEach(queueEntry -> queueEntry.future.completeExceptionally(new CancellationException()));
+ requestQueue.forEach(RequestQueueEntry::cancel);
}
/**
/**
* 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;
* @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) {
}
}
+ /**
+ * 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;
}
*/
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());
+ }
}
}
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;
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;
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);
// format URL
try {
- String url = String.format(this.url, new Date());
- URI uri = escapedUrl ? new URI(url) : Util.uriFromString(url);
+ URI uri = Util.uriFromString(String.format(this.url, new Date()));
logger.trace("Requesting refresh (retry={}) from '{}' with timeout {}ms", isRetry, uri, timeout);
- httpClient.newRequest(uri, httpMethod, httpContent).thenAccept(request -> {
+ httpClient.newRequest(uri, httpMethod, httpContent, httpContentType).thenAccept(request -> {
request.timeout(timeout, TimeUnit.MILLISECONDS);
-
- headers.forEach(header -> {
- String[] keyValuePair = header.split("=", 2);
- if (keyValuePair.length == 2) {
- request.header(keyValuePair[0].trim(), keyValuePair[1].trim());
- } else {
- logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header);
- }
- });
-
- CompletableFuture<@Nullable Content> response = new CompletableFuture<>();
- response.exceptionally(e -> {
- if (e instanceof HttpAuthException) {
- if (isRetry) {
- logger.warn("Retry after authentication failure failed again for '{}', failing here", uri);
+ headers.forEach(request::header);
+
+ CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>();
+ responseContentFuture.exceptionally(t -> {
+ if (t instanceof HttpAuthException) {
+ if (isRetry || !httpClient.reAuth(uri)) {
+ logger.debug("Authentication failed for '{}', retry={}", uri, isRetry);
+ httpStatusListener.onHttpError("Authentication failed");
} else {
- AuthenticationStore authStore = httpClient.getAuthenticationStore();
- Authentication.Result authResult = authStore.findAuthenticationResult(uri);
- if (authResult != null) {
- authStore.removeAuthenticationResult(authResult);
- logger.debug("Cleared authentication result for '{}', retrying immediately", uri);
- refresh(true);
- } else {
- logger.warn("Could not find authentication result for '{}', failing here", uri);
- }
+ refresh(true);
}
}
return null;
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);
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) {
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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;
- }
-}
+++ /dev/null
-/**
- * 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 + "'}";
- }
-}
+++ /dev/null
-/**
- * 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);
-}
+++ /dev/null
-/**
- * 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 org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- * The {@link ValueTransformationProvider} allows to retrieve a transformation service by name
- *
- * @author Jan N. Klug - Initial contribution
- */
-@NonNullByDefault
-public interface ValueTransformationProvider {
-
- /**
- *
- * @param pattern A transformation pattern, starting with the transformation service
- * * name, followed by a colon and the transformation itself.
- * @return
- */
- ValueTransformation getValueTransformation(@Nullable String pattern);
-}
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</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>
<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>
<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>
--- /dev/null
+<?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>
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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();
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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());
+ }
+}
+++ /dev/null
-/**
- * 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());
- }
-}
+++ /dev/null
-/**
- * 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());
- }
-}
+++ /dev/null
-# to run through all code-branches
-org.slf4j.simpleLogger.log.org.openhab.binding.http=trace