diff --git a/README.md b/README.md index d96b2ef..692093a 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,14 @@ Four token types are exposed via `POST /token` today. The underlying STS library | API `type` | STS class / subclass | Description | REST API | |---|---|---|:---:| -| `TOP_UP` | 0 / 0 | Transfer electricity credit (kWh) | **Yes** | +| `TOP_UP_KWH` | 0 / 0 | Transfer electricity credit (kWh) | **Yes** | | `CLEAR_CREDIT` | 2 / 1 | Clear existing credit on meter | **Yes** | | `CLEAR_TAMPER` | 2 / 5 | Clear tamper condition | **Yes** | | `SET_POWER_LIMIT` | 2 / 0 | Set maximum power limit | **Yes** | +`TOP_UP` is still accepted as a **deprecated** wire alias for `TOP_UP_KWH` (same path) for older +callers. Prefer `TOP_UP_KWH` for new integrations. + Tokens are generated using the **Standard Transfer Algorithm (STA / EA07)** via the [Bouncy Castle](https://www.bouncycastle.org/) cryptographic library. To add a new token type, see [Adding a token type](CONTRIBUTING.md#adding-a-token-type) in `CONTRIBUTING.md`. @@ -37,7 +40,7 @@ POST /token ──► TokenController ▼ dispatches to matching strategy TokenStrategy (interface) │ - ├── TransferElectricityCreditStrategy (TOP_UP) + ├── TransferElectricityCreditStrategy (TOP_UP_KWH) ├── ClearCreditStrategy (CLEAR_CREDIT) ├── ClearTamperStrategy (CLEAR_TAMPER) └── SetMaximumPowerLimitStrategy (SET_POWER_LIMIT) @@ -259,11 +262,11 @@ Generates a prepayment token. | Field | Type | Required | Description | |---|---|---|---| -| `type` | `string` | Yes | Token type: `TOP_UP`, `CLEAR_CREDIT`, `CLEAR_TAMPER`, `SET_POWER_LIMIT` | +| `type` | `string` | Yes | Token type: `TOP_UP_KWH`, `CLEAR_CREDIT`, `CLEAR_TAMPER`, `SET_POWER_LIMIT` (deprecated alias: `TOP_UP` → `TOP_UP_KWH`) | | `issueDate` | `string` | Yes | ISO 8601 datetime (see note below) | | `randomNumber` | `integer` | Yes | STS 4-bit RND field — **must be 0–15** (see note below) | | `decoderKey` | `string` | Yes | Meter decoder key as a hexadecimal string (16 hex chars = 8 bytes) | -| `kwh` | `number` | For `TOP_UP` | Amount of electricity credit in kWh | +| `kwh` | `number` | For `TOP_UP_KWH` | Amount of electricity credit in kWh (also required when using deprecated `TOP_UP`) | | `powerLimit` | `integer` | For `SET_POWER_LIMIT` | Maximum power limit value | > **`randomNumber` — STS protocol constraint** @@ -285,13 +288,13 @@ Generates a prepayment token. > are allowed. Any time-zone offset is **ignored**; the date and time fields are > interpreted as **UTC** for TID calculation, independent of the server's timezone. -**Example — TOP_UP** +**Example — TOP_UP_KWH** ```bash curl -X POST http://localhost:8080/token \ -H "Content-Type: application/json" \ -d '{ - "type": "TOP_UP", + "type": "TOP_UP_KWH", "issueDate": "2024-03-15T10:30:00", "randomNumber": 3, "decoderKey": "XXXXXXXXXXXXXXXX", diff --git a/docs/capabilities.md b/docs/capabilities.md index e0efe89..d629df5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -12,13 +12,17 @@ This document maps **IEC 62055-41 (STS)** token types to what exists in the code All generators on the live path use the **Standard Transfer Algorithm (STA / EA07)** with a caller-supplied decoder key. +**Deprecated wire alias:** `TOP_UP` is still accepted on `POST /token` and is normalized to +`TOP_UP_KWH` at deserialize time (same strategy / generator path). Prefer `TOP_UP_KWH` for new +callers; remove `TOP_UP` once older servers have been updated. + --- ## Token generation and decode | API `type` | STS class / subclass | Description | Generate | Decode | REST API | |---|---|---|:---:|:---:|:---:| -| `TOP_UP` | 0 / 0 | Transfer electricity credit (kWh) | Yes | — | **Yes** | +| `TOP_UP_KWH` | 0 / 0 | Transfer electricity credit (kWh) | Yes | — | **Yes** | | — | 0 / 1 | Transfer water credit | Yes | — | No | | — | 0 / 2 | Transfer gas credit | Yes | — | No | | — | 0 / 3 | Time token | No | No | No | diff --git a/pom.xml b/pom.xml index e972534..23f3a1b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ co.nxtgrid nxt-sts - 1.0.1 + 1.0.2 org.springframework.boot diff --git a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java index e2b1cb3..855a4c8 100644 --- a/src/main/java/co/nxtgrid/api/StsExceptionHandler.java +++ b/src/main/java/co/nxtgrid/api/StsExceptionHandler.java @@ -78,7 +78,8 @@ private static String messageForMalformedJson(HttpMessageNotReadableException ex Throwable cause = ex.getCause(); if (cause instanceof InvalidFormatException invalidFormat) { if (invalidFormat.getTargetType() == TokenType.class) { - return "type must be one of: TOP_UP, CLEAR_CREDIT, CLEAR_TAMPER, SET_POWER_LIMIT"; + return "type must be one of: TOP_UP_KWH, TOP_UP (deprecated alias), " + + "CLEAR_CREDIT, CLEAR_TAMPER, SET_POWER_LIMIT"; } if (invalidFormat.getTargetType() == LocalDateTime.class || isField(invalidFormat, "issueDate")) { return ISSUE_DATE_FORMAT_MESSAGE; diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java index 7a9d73d..ac55a9a 100644 --- a/src/main/java/co/nxtgrid/api/TokenRequest.java +++ b/src/main/java/co/nxtgrid/api/TokenRequest.java @@ -25,7 +25,12 @@ public class TokenRequest { @Pattern(regexp = "^[0-9A-Fa-f]{16}$", message = "decoderKey must be exactly 16 hex characters") private String decoderKey; - @Schema(description = "STS token type to generate", example = "TOP_UP") + @Schema( + description = "STS token type to generate. Prefer TOP_UP_KWH; TOP_UP is a deprecated " + + "alias for the same electricity kWh credit token.", + example = "TOP_UP_KWH", + allowableValues = { "TOP_UP_KWH", "TOP_UP", "CLEAR_CREDIT", "CLEAR_TAMPER", "SET_POWER_LIMIT" } + ) @NotNull private TokenType type; @@ -55,7 +60,8 @@ public class TokenRequest { private Integer randomNumber; @Schema( - description = "Amount of electricity credit in kWh. Required when type is TOP_UP. Must be zero or greater.", + description = "Amount of electricity credit in kWh. Required when type is TOP_UP_KWH " + + "(or deprecated alias TOP_UP). Must be zero or greater.", example = "0.5", minimum = "0" ) @@ -70,10 +76,10 @@ public class TokenRequest { @PositiveOrZero(message = "powerLimit must be zero or greater") private Long powerLimit; - @AssertTrue(message = "kwh is required for TOP_UP") + @AssertTrue(message = "kwh is required for TOP_UP_KWH") @JsonIgnore public boolean isKwhValidForType() { - return type != TokenType.TOP_UP || kwh != null; + return type != TokenType.TOP_UP_KWH || kwh != null; } @AssertTrue(message = "powerLimit is required for SET_POWER_LIMIT") diff --git a/src/main/java/co/nxtgrid/api/TokenType.java b/src/main/java/co/nxtgrid/api/TokenType.java index 07ddf6c..04f2e85 100644 --- a/src/main/java/co/nxtgrid/api/TokenType.java +++ b/src/main/java/co/nxtgrid/api/TokenType.java @@ -1,8 +1,18 @@ package co.nxtgrid.api; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * REST {@code type} values for {@code POST /token}. + * + *

Electricity kWh credit is {@link #TOP_UP_KWH}. The wire value {@code TOP_UP} is still + * accepted for older callers and is normalized to {@link #TOP_UP_KWH} at deserialize time + * (see {@link TokenTypeDeserializer}). + */ +@JsonDeserialize(using = TokenTypeDeserializer.class) public enum TokenType { - /** Class 0 — transfers electricity credit to the meter. */ - TOP_UP, + /** Class 0 / subclass 0 — transfer electricity credit in kWh. */ + TOP_UP_KWH, /** Class 2 — clears existing credit balance on the meter. */ CLEAR_CREDIT, /** Class 2 — clears the tamper condition flag on the meter. */ diff --git a/src/main/java/co/nxtgrid/api/TokenTypeDeserializer.java b/src/main/java/co/nxtgrid/api/TokenTypeDeserializer.java new file mode 100644 index 0000000..0d9da95 --- /dev/null +++ b/src/main/java/co/nxtgrid/api/TokenTypeDeserializer.java @@ -0,0 +1,33 @@ +package co.nxtgrid.api; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; + +/** + * Deserializes {@link TokenType} from the JSON {@code type} string. + * + *

Accepts deprecated wire value {@code TOP_UP} and maps it to + * {@link TokenType#TOP_UP_KWH} immediately. + */ +public class TokenTypeDeserializer extends JsonDeserializer { + + @Override + public TokenType deserialize(JsonParser parser, DeserializationContext context) throws IOException { + String value = parser.getValueAsString(); + if (value == null) { + return null; + } + if ("TOP_UP".equals(value) || "TOP_UP_KWH".equals(value)) { + return TokenType.TOP_UP_KWH; + } + try { + return TokenType.valueOf(value); + } catch (IllegalArgumentException ex) { + throw InvalidFormatException.from(parser, "Invalid TokenType value", value, TokenType.class); + } + } +} diff --git a/src/main/java/co/nxtgrid/strategy/TransferElectricityCreditStrategy.java b/src/main/java/co/nxtgrid/strategy/TransferElectricityCreditStrategy.java index 127f760..7cf3514 100644 --- a/src/main/java/co/nxtgrid/strategy/TransferElectricityCreditStrategy.java +++ b/src/main/java/co/nxtgrid/strategy/TransferElectricityCreditStrategy.java @@ -25,7 +25,7 @@ public class TransferElectricityCreditStrategy implements TokenStrategy { @Override public boolean supports(TokenType type) { - return TokenType.TOP_UP == type; + return TokenType.TOP_UP_KWH == type; } @Override diff --git a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java index f0309c3..da436dc 100644 --- a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java +++ b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java @@ -15,7 +15,8 @@ @AutoConfigureMockMvc class TokenControllerValidationTest { - private static final String VALID_TOP_UP = """ + /** Deprecated wire alias — still accepted for older callers. */ + private static final String VALID_TOP_UP_ALIAS = """ { "type": "TOP_UP", "issueDate": "2024-03-15T10:30:00", @@ -25,6 +26,16 @@ class TokenControllerValidationTest { } """; + private static final String VALID_TOP_UP_KWH = """ + { + "type": "TOP_UP_KWH", + "issueDate": "2024-03-15T10:30:00", + "randomNumber": 3, + "decoderKey": "0123456789ABCDEF", + "kwh": 0.5 + } + """; + @Autowired private MockMvc mockMvc; @@ -70,7 +81,10 @@ void rejectsUnknownTokenType() throws Exception { .andExpect(status().isBadRequest()) .andExpect( jsonPath("$.error") - .value("type must be one of: TOP_UP, CLEAR_CREDIT, CLEAR_TAMPER, SET_POWER_LIMIT") + .value( + "type must be one of: TOP_UP_KWH, TOP_UP (deprecated alias), " + + "CLEAR_CREDIT, CLEAR_TAMPER, SET_POWER_LIMIT" + ) ); } @@ -155,7 +169,7 @@ void rejectsMissingKwhForTopUp() throws Exception { ) ) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("kwh is required for TOP_UP")); + .andExpect(jsonPath("$.error").value("kwh is required for TOP_UP_KWH")); } @Test @@ -274,9 +288,19 @@ void rejectsMalformedIssueDate() throws Exception { } @Test - void acceptsValidRequest() throws Exception { + void acceptsTopUpKwh() throws Exception { + mockMvc.perform( + post("/token").contentType(MediaType.APPLICATION_JSON).content(VALID_TOP_UP_KWH) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isString()) + .andExpect(jsonPath("$.token").isNotEmpty()); + } + + @Test + void acceptsDeprecatedTopUpAlias() throws Exception { mockMvc.perform( - post("/token").contentType(MediaType.APPLICATION_JSON).content(VALID_TOP_UP) + post("/token").contentType(MediaType.APPLICATION_JSON).content(VALID_TOP_UP_ALIAS) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.token").isString()) diff --git a/src/test/java/co/nxtgrid/TokenStrategyIntegrationTest.java b/src/test/java/co/nxtgrid/TokenStrategyIntegrationTest.java index 01921a0..41d66e0 100644 --- a/src/test/java/co/nxtgrid/TokenStrategyIntegrationTest.java +++ b/src/test/java/co/nxtgrid/TokenStrategyIntegrationTest.java @@ -17,12 +17,30 @@ class TokenStrategyIntegrationTest { private static final String DECODER_KEY = "0123456789ABCDEF"; private static final String ISSUE_DATE = "2024-03-15T10:30:00"; + private static final String EXPECTED_TOP_UP_KWH_TOKEN = "58627975513348563046"; @Autowired private MockMvc mockMvc; @Test - void topUp_producesExpectedToken() throws Exception { + void topUpKwh_producesExpectedToken() throws Exception { + postToken( + """ + { + "type": "TOP_UP_KWH", + "issueDate": "%s", + "randomNumber": 3, + "decoderKey": "%s", + "kwh": 0.5 + } + """.formatted(ISSUE_DATE, DECODER_KEY) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value(EXPECTED_TOP_UP_KWH_TOKEN)); + } + + @Test + void topUpDeprecatedAlias_producesSameTokenAsTopUpKwh() throws Exception { postToken( """ { @@ -35,7 +53,7 @@ void topUp_producesExpectedToken() throws Exception { """.formatted(ISSUE_DATE, DECODER_KEY) ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.token").value("58627975513348563046")); + .andExpect(jsonPath("$.token").value(EXPECTED_TOP_UP_KWH_TOKEN)); } @Test