Skip to content

Commit 233ecf6

Browse files
committed
Init
1 parent a2ff60d commit 233ecf6

20 files changed

Lines changed: 1283 additions & 2 deletions

.editorconfig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# editorconfig.org
2+
3+
root = true
4+
5+
[*]
6+
charset = utf-8
7+
end_of_line = lf
8+
insert_final_newline = true
9+
indent_style = space
10+
indent_size = 4
11+
trim_trailing_whitespace = true
12+
13+
[*.md]
14+
trim_trailing_whitespace = false

.github/workflows/build.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: build
2+
3+
on:
4+
push:
5+
branches-ignore:
6+
- master
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Setup Go
13+
uses: actions/setup-go@v4
14+
with:
15+
go-version: '1.25.7'
16+
cache: false
17+
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Golangci Lint
22+
uses: golangci/golangci-lint-action@v9
23+
24+
- name: Run tests
25+
run: go test -race
26+
27+
- name: Check plugin
28+
run: docker run --rm -v "$PWD/go.sum:/app/go.sum" -w /app krakend:2.13.1 krakend check-plugin -g 1.25.7 -l "MUSL-1.2.5_(alpine-3.23.3)" -s ./go.sum

.github/workflows/release.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: release
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Setup Go
13+
uses: actions/setup-go@v4
14+
with:
15+
go-version: '1.25.7'
16+
cache: false
17+
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Golangci Lint
22+
uses: golangci/golangci-lint-action@v9
23+
24+
- name: Run tests
25+
run: go test -race
26+
27+
- name: Build project
28+
run: |
29+
docker run --rm -v "$PWD:/app" -w /app krakend/builder:2.13.1 go build -buildmode=plugin -o krakend-fallback.so .
30+
docker run --rm -v "$PWD/krakend-fallback.so:/app/krakend-fallback.so" -w /app krakend:2.13.1 krakend test-plugin -sc krakend-fallback.so
31+
zip ./krakend-fallback.zip ./krakend-fallback.so
32+
33+
- name: Bump version and push tag
34+
id: bump_tag
35+
uses: anothrNick/github-tag-action@1.61.0
36+
env:
37+
GITHUB_TOKEN: ${{ secrets.JENKSY_GITHUB_TOKEN }}
38+
WITH_V: true
39+
40+
- name: Create Release
41+
id: create_release
42+
uses: actions/create-release@v1.0.0
43+
env:
44+
GITHUB_TOKEN: ${{ secrets.JENKSY_GITHUB_TOKEN }}
45+
with:
46+
tag_name: ${{ steps.bump_tag.outputs.new_tag }}
47+
release_name: Release ${{ steps.bump_tag.outputs.new_tag }}
48+
draft: false
49+
prerelease: false
50+
51+
- name: Upload Release Asset
52+
id: upload-release-asset
53+
uses: actions/upload-release-asset@v1.0.1
54+
env:
55+
GITHUB_TOKEN: ${{ secrets.JENKSY_GITHUB_TOKEN }}
56+
with:
57+
upload_url: ${{ steps.create_release.outputs.upload_url }}
58+
asset_path: ./krakend-fallback.zip
59+
asset_name: krakend-fallback.zip
60+
asset_content_type: application/zip

README.md

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,126 @@
1-
# krakend-fallback
2-
Server plugin that allows configuring route-based rules to enforce required response fields (returning an error if missing) and inject default values for absent fields.
1+
# Krakend Fallback
2+
3+
Server plugin for **KrakenD** that allows configuring route-based rules to:
4+
5+
- Enforce required response fields (returning an error if missing)
6+
- Inject default values for absent fields
7+
- Extract and propagate backend errors (`error_*`) when required fields are missing
8+
9+
---
10+
11+
## Overview
12+
13+
The plugin works as a **server middleware**.
14+
15+
For configured routes it:
16+
17+
1. Intercepts the aggregated JSON response
18+
2. If response is not `2xx` or not JSON → passes it through untouched
19+
3. If JSON and successful:
20+
- Injects default fields (if missing)
21+
- Validates required fields
22+
- If required field is missing:
23+
- Looks for `error_*` keys in the response
24+
- Sorts them
25+
- Returns the first backend error
26+
- If no backend error found → returns `500`
27+
28+
---
29+
30+
## Example KrakenD Configuration
31+
32+
```json
33+
{
34+
"plugin/http-server": {
35+
"name": ["onliner/krakend-fallback"],
36+
"onliner/krakend-fallback": {
37+
"routes": [
38+
{
39+
"path": "/products/{product}/positions",
40+
"required": ["product"],
41+
"default": {
42+
"positions": []
43+
}
44+
}
45+
]
46+
}
47+
}
48+
}
49+
```
50+
51+
---
52+
53+
## Route Configuration
54+
55+
### `path`
56+
57+
Path template using `{param}` syntax.
58+
59+
Example:
60+
61+
```
62+
/products/{product}/positions
63+
```
64+
65+
---
66+
67+
### `required`
68+
69+
List of top-level JSON keys that must exist in the final aggregated response.
70+
71+
If any key is missing:
72+
73+
- Plugin searches for `error_*` fields
74+
- If found → returns the first backend error
75+
- If not found → returns `500 Internal Server Error`
76+
77+
---
78+
79+
### `default`
80+
81+
Map of fields to inject if they are missing.
82+
83+
Defaults are only applied for successful `2xx` JSON responses.
84+
85+
Example:
86+
87+
```json
88+
"default": {
89+
"positions": [],
90+
"shops": null
91+
}
92+
```
93+
94+
---
95+
96+
## Backend Error Format
97+
98+
Backend errors must follow this structure inside the aggregated response:
99+
100+
```json
101+
{
102+
"error_1": {
103+
"http_status_code": 500,
104+
"http_body": "{\"message\":\"backend failed\"}",
105+
"http_body_encoding": "application/json"
106+
}
107+
}
108+
```
109+
110+
### Fields
111+
112+
- `http_status_code` – HTTP status to return
113+
- `http_body` – raw response body
114+
- `http_body_encoding` – Content-Type header (optional, defaults to `text/plain`)
115+
116+
If multiple `error_*` keys exist, the plugin:
117+
118+
1. Sorts them lexicographically
119+
2. Returns the first one
120+
121+
## Notes
122+
123+
- Plugin modifies only successful `2xx` JSON responses
124+
- Original headers are preserved when modifying response
125+
- `Content-Length` is recalculated when body is modified
126+
- Thread-safe and race-tested

backend_error.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"sort"
8+
"strings"
9+
10+
"github.com/mitchellh/mapstructure"
11+
)
12+
13+
type BackendError struct {
14+
Status int `mapstructure:"http_status_code"`
15+
Body string `mapstructure:"http_body"`
16+
Encoding string `mapstructure:"http_body_encoding"`
17+
}
18+
19+
func (e BackendError) ToResponse() *http.Response {
20+
enc := e.Encoding
21+
if enc == "" {
22+
enc = "text/plain"
23+
}
24+
return &http.Response{
25+
StatusCode: e.Status,
26+
Header: http.Header{"Content-Type": []string{enc}},
27+
Body: io.NopCloser(bytes.NewBufferString(e.Body)),
28+
ContentLength: int64(len(e.Body)),
29+
}
30+
}
31+
32+
func FindBackendError(body map[string]interface{}) (*http.Response, bool) {
33+
keys := make([]string, 0, len(body))
34+
for k := range body {
35+
if strings.HasPrefix(k, "error_") {
36+
keys = append(keys, k)
37+
}
38+
}
39+
if len(keys) == 0 {
40+
return nil, false
41+
}
42+
43+
sort.Strings(keys)
44+
v, ok := body[keys[0]]
45+
if !ok {
46+
return nil, false
47+
}
48+
49+
var berr BackendError
50+
51+
if err := mapstructure.WeakDecode(v, &berr); err != nil {
52+
return nil, false
53+
}
54+
55+
return berr.ToResponse(), true
56+
}

backend_error_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func readString(t *testing.T, rc io.ReadCloser) string {
11+
t.Helper()
12+
defer func(rc io.ReadCloser) {
13+
err := rc.Close()
14+
assert.NoError(t, err)
15+
}(rc)
16+
b, err := io.ReadAll(rc)
17+
assert.NoError(t, err)
18+
return string(b)
19+
}
20+
21+
func TestFindBackendError_NoKeys(t *testing.T) {
22+
resp, ok := FindBackendError(map[string]interface{}{
23+
"product": map[string]interface{}{"id": 1},
24+
})
25+
26+
assert.False(t, ok)
27+
assert.Nil(t, resp)
28+
}
29+
30+
func TestFindBackendError_PicksFirstSortedKey(t *testing.T) {
31+
body := map[string]interface{}{
32+
"error_2": map[string]interface{}{
33+
"http_status_code": 502,
34+
"http_body": `{"message":"second"}`,
35+
"http_body_encoding": "application/json",
36+
},
37+
"error_1": map[string]interface{}{
38+
"http_status_code": 500,
39+
"http_body": `{"message":"first"}`,
40+
"http_body_encoding": "application/json; charset=utf-8",
41+
},
42+
"product": map[string]interface{}{"id": 1},
43+
}
44+
45+
resp, ok := FindBackendError(body)
46+
assert.True(t, ok)
47+
assert.NotNil(t, resp)
48+
49+
assert.Equal(t, 500, resp.StatusCode)
50+
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
51+
52+
gotBody := readString(t, resp.Body)
53+
assert.Equal(t, `{"message":"first"}`, gotBody)
54+
assert.Equal(t, int64(len(gotBody)), resp.ContentLength)
55+
}
56+
57+
func TestFindBackendError_InvalidShape_ReturnsFalse(t *testing.T) {
58+
body := map[string]interface{}{
59+
"error_1": "not an object",
60+
}
61+
62+
resp, ok := FindBackendError(body)
63+
assert.False(t, ok)
64+
assert.Nil(t, resp)
65+
}
66+
67+
func TestFindBackendErrorMissingEncoding(t *testing.T) {
68+
body := map[string]interface{}{
69+
"error_1": map[string]interface{}{
70+
"http_status_code": 503,
71+
"http_body": "service unavailable",
72+
},
73+
}
74+
75+
resp, ok := FindBackendError(body)
76+
assert.True(t, ok)
77+
assert.NotNil(t, resp)
78+
assert.Equal(t, 503, resp.StatusCode)
79+
assert.Equal(t, "text/plain", resp.Header.Get("Content-Type"))
80+
81+
got := readString(t, resp.Body)
82+
assert.Equal(t, `service unavailable`, got)
83+
}

0 commit comments

Comments
 (0)