Sitelet https://github.com/abanna/floci/commit/02385da37b1e14ba01938f408b8a5695218619bf
Skip to content

Commit 02385da

Browse files
feat(apigateway): support floci:override-id for v1 and v2 (floci-io#2045)
* feat(apigateway): support floci:override-id for v1 and v2 API IDs were generated randomly, so endpoint URLs changed on every recreate. v1 accepted an undocumented `_custom_id_` tag and echoed it back as a normal tag; v2 had no override at all. Other services already pin IDs through the reserved `floci:override-id` tag. Both CreateRestApi and CreateApi now accept `floci:override-id`, routed through ReservedTags.getOverride so the value is validated rather than used verbatim: non-blank, no whitespace or control characters, and no `/`, `?`, `#`, since those break the endpoint URL. Override keys are consumed rather than stored, so they no longer appear in the returned tags, and supplying either key to TagResource is rejected because an ID cannot change after creation. `_custom_id_` keeps working as a deprecated fallback and is now stripped the same way; `floci:override-id` wins when both are present so callers can set both during a migration. The key stays API Gateway specific: the shared strip and reject helpers are untouched, so KMS and Cognito still treat `_custom_id_` as an ordinary tag. Error code is BadRequestException, declared by CreateRestApi, CreateApi and TagResource on both services. The shared reject helper's ValidationException is declared by neither, which is why API Gateway gets its own guard rather than reusing it. Closes floci-io#1593 Co-authored-by: Marcel Herhold <herhold.marcel@gmail.com> * fix(apigateway): reject duplicate override ids with ConflictException CreateRestApi (v1) and CreateApi (v2) now return 409 ConflictException when the resolved API id already exists in the region, instead of silently overwriting the existing API, matching how KMS and Cognito treat duplicate override ids. --------- Co-authored-by: Marcel Herhold <herhold.marcel@gmail.com>
1 parent 86134e8 commit 02385da

8 files changed

Lines changed: 430 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **apigateway:** support the reserved `floci:override-id` tag on `CreateRestApi` (v1) and `CreateApi` (v2) to pin the generated API ID, unifying custom IDs with the tag KMS and Cognito already use. Override keys are stripped from the returned tags, validated (non-blank, no whitespace/control characters and no `/`, `?`, `#`) and rejected on `TagResource` with `BadRequestException`, which is what both services declare. The older API-Gateway-specific `_custom_id_` tag still works as a deprecated fallback and is now stripped too; `floci:override-id` wins when both are present. Creating an API whose override ID already exists in the region is rejected with `ConflictException` (409) instead of overwriting the existing API ([#1593](https://github.com/floci-io/floci/pull/1593))
13+
14+
### Changed
15+
16+
- **apigateway:** `_custom_id_` is no longer persisted in the tags returned for a REST API. It was previously echoed back as a normal tag.
17+
1018
## [1.5.34] - 2026-07-28
1119

1220
### Added

‎docs/services/api-gateway.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,38 @@
22

33
Floci supports both API Gateway v1 (REST APIs) and API Gateway v2 (HTTP APIs).
44

5+
## Custom API IDs
6+
7+
API IDs are generated randomly, which means endpoint URLs change every time you recreate an API. To pin
8+
one, pass the reserved `floci:override-id` tag on creation and Floci uses its value as the API ID. This
9+
works for both v1 (`CreateRestApi`) and v2 (`CreateApi`), and matches the tag other services such as KMS
10+
and Cognito already use.
11+
12+
```bash
13+
aws apigateway create-rest-api \
14+
--name my-api \
15+
--tags '{"floci:override-id":"my-fixed-id","env":"test"}' \
16+
--endpoint-url http://localhost:4566
17+
# the API is now reachable at the stable id "my-fixed-id"
18+
```
19+
20+
The override key is consumed rather than stored, so it never appears in the tags the API returns. Any
21+
other tags in the same request are kept. Because an ID cannot change after creation, supplying either
22+
override key to `TagResource` is rejected with `BadRequestException`.
23+
24+
Values must be non-blank and must not contain whitespace, control characters, or `/`, `?`, `#`, since
25+
those would break the endpoint URL. An invalid value is rejected with `BadRequestException`.
26+
27+
Creating a second API with an override ID that already exists in the region is rejected with
28+
`ConflictException` instead of overwriting the existing API, matching how KMS and Cognito treat
29+
duplicate override IDs.
30+
31+
> [!NOTE]
32+
> API Gateway previously used a `_custom_id_` tag for this. It still works so existing setups keep
33+
> running, and it is now stripped from the returned tags the same way, but it is deprecated: prefer
34+
> `floci:override-id`. If both are present, `floci:override-id` wins, which lets you set both during a
35+
> migration. The `_custom_id_` key is API Gateway specific and is not reserved for any other service.
36+
537
## API Gateway v1 (REST APIs) {#v1}
638

739
**Protocol:** REST JSON

‎src/main/java/io/github/hectorvent/floci/core/common/ReservedTags.java‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,18 @@ public final class ReservedTags {
1313
public static final String OVERRIDE_ID_KEY = RESERVED_PREFIX + "override-id";
1414
public static final String OVERRIDE_COGNITO_CLIENT_ID_KEY = RESERVED_PREFIX + "override-cognito-client-id";
1515
public static final String OVERRIDE_COGNITO_CLIENT_SECRET_KEY = RESERVED_PREFIX + "override-cognito-client-secret";
16+
17+
/**
18+
* API Gateway accepted a custom id through this key before {@link #OVERRIDE_ID_KEY} existed. It is
19+
* API Gateway specific and deprecated: still honored on create, but no longer persisted in the
20+
* resource tags. Other services never supported it and must not start reserving it.
21+
*/
22+
public static final String DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY = "_custom_id_";
23+
1624
private static final String INVALID_PARAMETER_EXCEPTION = "InvalidParameterException";
1725
private static final String VALIDATION_EXCEPTION = "ValidationException";
1826
private static final String TAG_EXCEPTION = "TagException";
27+
private static final String BAD_REQUEST_EXCEPTION = "BadRequestException";
1928
private static final String CONTROL_CHARACTER_ERROR_MESSAGE = "Override %s must not contain control characters.";
2029

2130
private ReservedTags() {
@@ -29,6 +38,21 @@ public static String extractOverrideUserPoolId(Map<String, String> tags) {
2938
return getOverride(tags, OVERRIDE_ID_KEY, ReservedTags::validateOverrideId, "Resource ID", INVALID_PARAMETER_EXCEPTION);
3039
}
3140

41+
/**
42+
* API Gateway v1 and v2 override id. {@link #OVERRIDE_ID_KEY} wins when both keys are present, so a
43+
* caller migrating off {@link #DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY} can set both during a rollout.
44+
* Uses {@code BadRequestException}, which is what CreateRestApi and CreateApi declare.
45+
*/
46+
public static String extractOverrideApiId(Map<String, String> tags) {
47+
String override = getOverride(tags, OVERRIDE_ID_KEY, ReservedTags::validateOverrideId,
48+
"Resource ID", BAD_REQUEST_EXCEPTION);
49+
if (override != null) {
50+
return override;
51+
}
52+
return getOverride(tags, DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, ReservedTags::validateOverrideId,
53+
"Resource ID", BAD_REQUEST_EXCEPTION);
54+
}
55+
3256
public static String extractOverrideCognitoClientId(Map<String, String> tags) {
3357
return getOverride(tags, OVERRIDE_COGNITO_CLIENT_ID_KEY, ReservedTags::validateOverrideId, "Cognito Client ID", INVALID_PARAMETER_EXCEPTION);
3458
}
@@ -50,6 +74,36 @@ public static Map<String, String> stripReservedTags(Map<String, String> tags) {
5074
return stripped;
5175
}
5276

77+
/**
78+
* Like {@link #stripReservedTags(Map)} but also drops API Gateway's deprecated custom-id key, so an
79+
* id override never survives into the tags the API returns.
80+
*/
81+
public static Map<String, String> stripApiGatewayReservedTags(Map<String, String> tags) {
82+
Map<String, String> stripped = stripReservedTags(tags);
83+
stripped.remove(DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY);
84+
return stripped;
85+
}
86+
87+
/**
88+
* API Gateway update-path guard. An id override only makes sense at create time, so both the
89+
* reserved keys and the deprecated custom-id key are rejected here, with API Gateway's declared
90+
* error code rather than the {@code ValidationException} the shared guard uses.
91+
*/
92+
public static void rejectApiGatewayReservedTagsOnUpdate(Map<String, String> tags) {
93+
if (tags == null) {
94+
return;
95+
}
96+
for (String key : tags.keySet()) {
97+
if (isReserved(key) || DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY.equals(key)) {
98+
throw new AwsException(
99+
BAD_REQUEST_EXCEPTION,
100+
"Reserved tag key " + key + " can only be supplied during resource creation.",
101+
400
102+
);
103+
}
104+
}
105+
}
106+
53107
public static void rejectReservedTagsOnUpdate(Map<String, String> tags) {
54108
if (tags == null) {
55109
return;

‎src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayService.java‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import io.github.hectorvent.floci.config.EmulatorConfig;
1616
import io.github.hectorvent.floci.core.common.AwsException;
17+
import io.github.hectorvent.floci.core.common.ReservedTags;
1718
import io.github.hectorvent.floci.core.storage.StorageBackend;
1819
import io.github.hectorvent.floci.core.storage.StorageFactory;
1920
import io.github.hectorvent.floci.services.apigateway.model.Account;
@@ -172,15 +173,19 @@ public RestApi createRestApi(String region, Map<String, Object> request) {
172173
Map<String, String> tags = request.get("tags") instanceof Map<?, ?> m
173174
? (Map<String, String>) m : new HashMap<>();
174175

175-
String customId = tags.get("_custom_id_");
176-
String apiId = (customId != null && !customId.isBlank()) ? customId : shortId(10);
176+
String customId = ReservedTags.extractOverrideApiId(tags);
177+
String apiId = customId != null ? customId : shortId(10);
178+
if (apiStore.get(apiKey(region, apiId)).isPresent()) {
179+
throw new AwsException("ConflictException",
180+
"REST API with id '" + apiId + "' already exists", 409);
181+
}
177182

178183
RestApi api = new RestApi();
179184
api.setId(apiId);
180185
api.setName(name);
181186
api.setDescription(description);
182187
api.setCreatedDate(System.currentTimeMillis() / 1000L);
183-
api.setTags(tags);
188+
api.setTags(ReservedTags.stripApiGatewayReservedTags(tags));
184189

185190
EndpointConfiguration endpointConfiguration = new EndpointConfiguration();
186191
if (request.get(EPC_KEY) instanceof Map<?, ?> epMap) {
@@ -1045,6 +1050,7 @@ public Map<String, String> getTags(String region, String apiId) {
10451050
}
10461051

10471052
public void tagResource(String region, String apiId, Map<String, String> tags) {
1053+
ReservedTags.rejectApiGatewayReservedTagsOnUpdate(tags);
10481054
RestApi api = getRestApi(region, apiId);
10491055
api.getTags().putAll(tags);
10501056
apiStore.put(apiKey(region, apiId), api);

‎src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2Service.java‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.github.hectorvent.floci.core.common.AwsArnUtils;
55
import io.github.hectorvent.floci.core.common.AwsException;
66
import io.github.hectorvent.floci.core.common.RegionResolver;
7+
import io.github.hectorvent.floci.core.common.ReservedTags;
78
import io.github.hectorvent.floci.core.storage.StorageBackend;
89
import io.github.hectorvent.floci.core.storage.StorageFactory;
910
import com.fasterxml.jackson.core.type.TypeReference;
@@ -81,8 +82,17 @@ public Api createApi(String region, Map<String, Object> request) {
8182
routeSelectionExpression = "${request.method} ${request.path}";
8283
}
8384

85+
@SuppressWarnings("unchecked")
86+
Map<String, String> tags = (Map<String, String>) request.get("tags");
87+
String overrideId = ReservedTags.extractOverrideApiId(tags);
88+
String apiId = overrideId != null ? overrideId : shortId(10);
89+
if (apiStore.get(apiKey(region, apiId)).isPresent()) {
90+
throw new AwsException("ConflictException",
91+
"API with id '" + apiId + "' already exists", 409);
92+
}
93+
8494
Api api = new Api();
85-
api.setApiId(shortId(10));
95+
api.setApiId(apiId);
8696
api.setName(name);
8797
api.setProtocolType(protocolType);
8898
api.setCreatedDate(System.currentTimeMillis());
@@ -96,10 +106,8 @@ public Api createApi(String region, Map<String, Object> request) {
96106
api.setApiEndpoint(String.format("https://%s.execute-api.%s.amazonaws.com", api.getApiId(), region));
97107
}
98108

99-
@SuppressWarnings("unchecked")
100-
Map<String, String> tags = (Map<String, String>) request.get("tags");
101109
if (tags != null) {
102-
api.setTags(tags);
110+
api.setTags(ReservedTags.stripApiGatewayReservedTags(tags));
103111
}
104112

105113
@SuppressWarnings("unchecked")
@@ -162,6 +170,7 @@ public Api updateApi(String region, String apiId, Map<String, Object> request) {
162170
if (request.containsKey("tags")) {
163171
@SuppressWarnings("unchecked")
164172
Map<String, String> tags = (Map<String, String>) request.get("tags");
173+
ReservedTags.rejectApiGatewayReservedTagsOnUpdate(tags);
165174
api.setTags(tags);
166175
}
167176
if (request.containsKey("corsConfiguration")) {
@@ -884,6 +893,7 @@ private String[] parseArn(String resourceArn) {
884893
}
885894

886895
public void tagResource(String resourceArn, Map<String, String> tags) {
896+
ReservedTags.rejectApiGatewayReservedTagsOnUpdate(tags);
887897
String[] parsed = parseArn(resourceArn);
888898
String region = parsed[0];
889899
String apiId = parsed[1];

‎src/test/java/io/github/hectorvent/floci/core/common/ReservedTagsTest.java‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,116 @@ void rejectReservedTagsOnUpdateRejectsReservedTags() {
8484
assertEquals("ValidationException", exception.getErrorCode());
8585
}
8686

87+
// ──────────────────────────── API Gateway override id ────────────────────────────
88+
89+
@Test
90+
void extractOverrideApiIdReturnsNullForNullInput() {
91+
assertNull(ReservedTags.extractOverrideApiId(null));
92+
}
93+
94+
@Test
95+
void extractOverrideApiIdReturnsNullWhenNeitherKeyPresent() {
96+
assertNull(ReservedTags.extractOverrideApiId(Map.of("env", "test")));
97+
}
98+
99+
@Test
100+
void extractOverrideApiIdReadsReservedOverrideKey() {
101+
assertEquals("my-api", ReservedTags.extractOverrideApiId(
102+
Map.of(ReservedTags.OVERRIDE_ID_KEY, "my-api", "env", "test")));
103+
}
104+
105+
@Test
106+
void extractOverrideApiIdFallsBackToDeprecatedCustomIdKey() {
107+
assertEquals("legacy-api", ReservedTags.extractOverrideApiId(
108+
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api")));
109+
}
110+
111+
@Test
112+
void extractOverrideApiIdPrefersReservedKeyOverDeprecatedKey() {
113+
assertEquals("winner", ReservedTags.extractOverrideApiId(Map.of(
114+
ReservedTags.OVERRIDE_ID_KEY, "winner",
115+
ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "loser")));
116+
}
117+
118+
@Test
119+
void extractOverrideApiIdRejectsBlankValueWithApiGatewayErrorCode() {
120+
AwsException exception = assertThrows(
121+
AwsException.class,
122+
() -> ReservedTags.extractOverrideApiId(Map.of(ReservedTags.OVERRIDE_ID_KEY, " "))
123+
);
124+
125+
assertEquals("BadRequestException", exception.getErrorCode());
126+
assertEquals(400, exception.getHttpStatus());
127+
}
128+
129+
@Test
130+
void extractOverrideApiIdRejectsUnsupportedCharacters() {
131+
for (String bad : new String[] {"has/slash", "has?query", "has#fragment"}) {
132+
AwsException exception = assertThrows(
133+
AwsException.class,
134+
() -> ReservedTags.extractOverrideApiId(Map.of(ReservedTags.OVERRIDE_ID_KEY, bad)),
135+
"expected rejection for " + bad
136+
);
137+
assertEquals("BadRequestException", exception.getErrorCode());
138+
}
139+
}
140+
141+
@Test
142+
void extractOverrideApiIdValidatesTheDeprecatedKeyToo() {
143+
AwsException exception = assertThrows(
144+
AwsException.class,
145+
() -> ReservedTags.extractOverrideApiId(
146+
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "has/slash"))
147+
);
148+
149+
assertEquals("BadRequestException", exception.getErrorCode());
150+
}
151+
152+
@Test
153+
void stripApiGatewayReservedTagsRemovesBothOverrideKeys() {
154+
Map<String, String> tags = new LinkedHashMap<>();
155+
tags.put("env", "test");
156+
tags.put(ReservedTags.OVERRIDE_ID_KEY, "my-api");
157+
tags.put(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api");
158+
tags.put("team", "platform");
159+
160+
assertEquals(Map.of("env", "test", "team", "platform"),
161+
ReservedTags.stripApiGatewayReservedTags(tags));
162+
}
163+
164+
@Test
165+
void stripReservedTagsLeavesDeprecatedCustomIdForOtherServices() {
166+
// _custom_id_ is API Gateway specific. KMS and Cognito use the shared strip and must keep it.
167+
Map<String, String> tags = Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api");
168+
169+
assertEquals(tags, ReservedTags.stripReservedTags(tags));
170+
}
171+
172+
@Test
173+
void rejectApiGatewayReservedTagsOnUpdateAllowsNormalTags() {
174+
assertDoesNotThrow(() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(Map.of("env", "test")));
175+
assertDoesNotThrow(() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(null));
176+
}
177+
178+
@Test
179+
void rejectApiGatewayReservedTagsOnUpdateRejectsBothOverrideKeys() {
180+
for (String key : new String[] {
181+
ReservedTags.OVERRIDE_ID_KEY, ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY}) {
182+
AwsException exception = assertThrows(
183+
AwsException.class,
184+
() -> ReservedTags.rejectApiGatewayReservedTagsOnUpdate(Map.of(key, "too-late")),
185+
"expected rejection for " + key
186+
);
187+
assertEquals("BadRequestException", exception.getErrorCode());
188+
}
189+
}
190+
191+
@Test
192+
void rejectReservedTagsOnUpdateIgnoresDeprecatedCustomIdForOtherServices() {
193+
assertDoesNotThrow(() -> ReservedTags.rejectReservedTagsOnUpdate(
194+
Map.of(ReservedTags.DEPRECATED_API_GATEWAY_CUSTOM_ID_KEY, "legacy-api")));
195+
}
196+
87197
@Test
88198
void rejectUnknownReservedTagsRejectsReservedTagsWithUserPoolTaggingException() {
89199
AwsException exception = assertThrows(

0 commit comments

Comments
 (0)