Compare commits
3 Commits
f2f1aed4b2
...
a3f9e04787
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3f9e04787 | ||
|
|
84e0d60d23 | ||
|
|
fd9511171b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.DS_Store
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
build/
|
||||
services/ibl_merge_report_service/ibl-merge-report-service
|
||||
|
||||
@@ -16,6 +16,15 @@ Content-Type: application/json
|
||||
"T_OrderHeaderLabNumber": "{{lab_number}}"
|
||||
}
|
||||
|
||||
### Merge langsung dari qr_printout (T_OrderHeaderID)
|
||||
POST http://10.9.20.31/one-api-lab/tools/ibl_merge_report_admin/merge_from_qr
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "{{token}}",
|
||||
"T_OrderHeaderID": {{order_header_id}}
|
||||
}
|
||||
|
||||
### Admin preview via backend tools
|
||||
POST http://10.9.20.31/one-api-lab/tools/ibl_merge_report_admin/preview
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -47,6 +47,37 @@ class Ibl_merge_report_admin extends MY_Controller
|
||||
exit;
|
||||
}
|
||||
|
||||
public function merge_from_qr()
|
||||
{
|
||||
if (!$this->isLogin) {
|
||||
$this->sys_error('Invalid Token');
|
||||
exit;
|
||||
}
|
||||
|
||||
$auth = $this->ibl_merge_report_gateway->is_admin_group_allowed($this->sys_user['M_UserID']);
|
||||
if ($auth['status'] !== 'OK') {
|
||||
$this->sys_error($auth['code'] . ' - ' . $auth['message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$orderHeaderId = isset($this->sys_input['T_OrderHeaderID']) ? (int) $this->sys_input['T_OrderHeaderID'] : 0;
|
||||
if ($orderHeaderId <= 0) {
|
||||
$this->sys_error('T_ORDERHEADERID_REQUIRED - T_OrderHeaderID wajib diisi.');
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $this->ibl_merge_report_gateway->stream_from_qr_printout($orderHeaderId);
|
||||
if ($result['status'] !== 'OK') {
|
||||
$this->sys_error($result['code'] . ' - ' . $result['message']);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $result['data']['payload']['name'] . '"');
|
||||
echo $result['data']['body'];
|
||||
exit;
|
||||
}
|
||||
|
||||
public function preview()
|
||||
{
|
||||
if (!$this->isLogin) {
|
||||
|
||||
@@ -561,6 +561,48 @@ class Ibl_merge_report_gateway
|
||||
);
|
||||
}
|
||||
|
||||
public function stream_from_qr_printout($orderHeaderId)
|
||||
{
|
||||
$query = $this->db_onedev->query(
|
||||
"SELECT QR_PrintOutReportURLElectronic
|
||||
FROM qr_printout
|
||||
WHERE QR_PrintOutT_OrderHeaderID = ?
|
||||
AND QR_PrintOutReportURLElectronic != ''
|
||||
AND QR_PrintOutIsActive = 1
|
||||
ORDER BY QR_PrintOutGroup_ResultID ASC",
|
||||
array((int) $orderHeaderId)
|
||||
);
|
||||
|
||||
if (!$query || $query->num_rows() === 0) {
|
||||
return $this->error('QR_PRINTOUT_NOT_FOUND', 'Tidak ada URL report di qr_printout untuk order ini.');
|
||||
}
|
||||
|
||||
$urls = array();
|
||||
$seen = array();
|
||||
foreach ($query->result_array() as $row) {
|
||||
$url = trim($row['QR_PrintOutReportURLElectronic']);
|
||||
if ($url === '' || isset($seen[$url])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$url] = true;
|
||||
$url = str_replace('http://localhost/', 'http://127.0.0.1/', $url);
|
||||
$urls[] = $url;
|
||||
}
|
||||
|
||||
if (count($urls) === 0) {
|
||||
return $this->error('QR_PRINTOUT_EMPTY', 'URL report kosong setelah normalisasi.');
|
||||
}
|
||||
|
||||
$payload = array(
|
||||
'name' => 'merge-' . (int) $orderHeaderId . '.pdf',
|
||||
'urls' => $urls,
|
||||
'mergeRequestID' => (int) $orderHeaderId,
|
||||
'T_OrderHeaderID' => (int) $orderHeaderId,
|
||||
);
|
||||
|
||||
return $this->call_merge_service($payload);
|
||||
}
|
||||
|
||||
protected function call_merge_service(array $payload)
|
||||
{
|
||||
$config = $this->get_system_config();
|
||||
|
||||
878
docs/superpowers/plans/2026-05-29-ibl-merge-report-service.md
Normal file
878
docs/superpowers/plans/2026-05-29-ibl-merge-report-service.md
Normal file
@@ -0,0 +1,878 @@
|
||||
# ibl_merge_report_service Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a standalone Go HTTP service (`ibl_merge_report_service`) that fetches multiple PDF URLs, merges them into one PDF, and streams the result — called internally by the one-api-lab PHP gateway.
|
||||
|
||||
**Architecture:** The service exposes a single `POST /merge` endpoint. It validates the `X-Internal-Secret` request header, parses the JSON body, fetches each PDF URL sequentially, merges them with pdfcpu, then writes the merged PDF as `application/pdf`. If any source fetch fails, the entire request fails with a mapped HTTP status code. No files are cached on disk.
|
||||
|
||||
**Tech Stack:** Go 1.21+, `github.com/pdfcpu/pdfcpu` for in-memory PDF merging, standard library `net/http`.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `services/ibl_merge_report_service/go.mod` | Module definition |
|
||||
| `services/ibl_merge_report_service/main.go` | Entry point — read env config, register route, start HTTP server |
|
||||
| `services/ibl_merge_report_service/handler.go` | `mergeRequest` struct, `mergeHandler`, validation, `writeError` |
|
||||
| `services/ibl_merge_report_service/handler_test.go` | Unit tests for all handler branches using a mock `mergeFunc` |
|
||||
| `services/ibl_merge_report_service/merger.go` | `fetchPDF`, `mergePDFs`, `fetchError` type |
|
||||
| `services/ibl_merge_report_service/merger_test.go` | httptest-based tests for `fetchPDF` error paths |
|
||||
| `scripts/build-merge-service.sh` | Cross-compile for Linux amd64, output binary to `up/` |
|
||||
| `services/ibl_merge_report_service/ibl-merge-report.service` | Systemd unit template for IBL server |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold — module init + empty stubs
|
||||
|
||||
**Files:**
|
||||
- Create: `services/ibl_merge_report_service/go.mod`
|
||||
- Create: `services/ibl_merge_report_service/main.go`
|
||||
- Create: `services/ibl_merge_report_service/handler.go`
|
||||
- Create: `services/ibl_merge_report_service/merger.go`
|
||||
|
||||
- [ ] **Step 1.1: Create go.mod**
|
||||
|
||||
```bash
|
||||
mkdir -p /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab/services/ibl_merge_report_service
|
||||
```
|
||||
|
||||
`services/ibl_merge_report_service/go.mod`:
|
||||
```
|
||||
module ibl-merge-report-service
|
||||
|
||||
go 1.21
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Create handler.go stub**
|
||||
|
||||
`services/ibl_merge_report_service/handler.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type mergeRequest struct {
|
||||
Name string `json:"name"`
|
||||
URLs []string `json:"urls"`
|
||||
MergeRequestID int64 `json:"mergeRequestID"`
|
||||
TOrderHeaderID int64 `json:"T_OrderHeaderID"`
|
||||
}
|
||||
|
||||
type mergeFunc func(urls []string) ([]byte, error)
|
||||
|
||||
type mergeHandler struct {
|
||||
secret string
|
||||
merge mergeFunc
|
||||
}
|
||||
|
||||
func newMergeHandler(secret string, merge mergeFunc) http.Handler {
|
||||
return &mergeHandler{secret: secret, merge: merge}
|
||||
}
|
||||
|
||||
func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: implement in Task 3
|
||||
writeError(w, http.StatusNotImplemented, "not implemented")
|
||||
}
|
||||
|
||||
func validateMergeRequest(req *mergeRequest) error {
|
||||
// TODO: implement in Task 2
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// suppress unused-import errors until tasks fill in usage
|
||||
var _ = errors.As
|
||||
var _ = fmt.Errorf
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Create merger.go stub**
|
||||
|
||||
`services/ibl_merge_report_service/merger.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 25 * time.Second}
|
||||
|
||||
type fetchError struct {
|
||||
URL string
|
||||
HTTPStatus int // 0 = network/timeout
|
||||
}
|
||||
|
||||
func (e *fetchError) Error() string {
|
||||
if e.HTTPStatus != 0 {
|
||||
return fmt.Sprintf("source %s returned HTTP %d", e.URL, e.HTTPStatus)
|
||||
}
|
||||
return fmt.Sprintf("failed to fetch source %s", e.URL)
|
||||
}
|
||||
|
||||
func fetchPDF(url string) ([]byte, error) {
|
||||
// TODO: implement in Task 4
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func mergePDFs(urls []string) ([]byte, error) {
|
||||
// TODO: implement in Task 5
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: Create main.go**
|
||||
|
||||
`services/ibl_merge_report_service/main.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
secret := os.Getenv("MERGE_INTERNAL_SECRET")
|
||||
if secret == "" {
|
||||
secret = "ibl-merge-secret"
|
||||
}
|
||||
addr := os.Getenv("MERGE_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8005"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/merge", func(w http.ResponseWriter, r *http.Request) {
|
||||
newMergeHandler(secret, mergePDFs).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("ibl_merge_report_service listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.5: Add pdfcpu dependency and verify build**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go get github.com/pdfcpu/pdfcpu@latest && go mod tidy
|
||||
```
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go build ./...
|
||||
```
|
||||
|
||||
Expected: builds without errors (stubs compile).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Validate request TDD
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/ibl_merge_report_service/handler.go` — implement `validateMergeRequest`
|
||||
- Create: `services/ibl_merge_report_service/handler_test.go`
|
||||
|
||||
- [ ] **Step 2.1: Write failing tests for validateMergeRequest**
|
||||
|
||||
`services/ibl_merge_report_service/handler_test.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMergeRequest_MissingName(t *testing.T) {
|
||||
req := &mergeRequest{URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_EmptyURLs(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for empty urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_NilURLs(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: nil, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for nil urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_MissingMergeRequestID(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 0, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing mergeRequestID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_MissingTOrderHeaderID(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 0}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing T_OrderHeaderID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_Valid(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Run tests — verify they fail**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestValidate -v
|
||||
```
|
||||
|
||||
Expected: `TestValidateMergeRequest_Valid` fails (stub returns nil for everything).
|
||||
|
||||
- [ ] **Step 2.3: Implement validateMergeRequest**
|
||||
|
||||
Replace the `validateMergeRequest` stub in `handler.go`:
|
||||
```go
|
||||
func validateMergeRequest(req *mergeRequest) error {
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if len(req.URLs) == 0 {
|
||||
return fmt.Errorf("urls must not be empty")
|
||||
}
|
||||
if req.MergeRequestID <= 0 {
|
||||
return fmt.Errorf("mergeRequestID is required")
|
||||
}
|
||||
if req.TOrderHeaderID <= 0 {
|
||||
return fmt.Errorf("T_OrderHeaderID is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Remove the now-unused stub lines at the bottom of handler.go:
|
||||
```go
|
||||
// Remove these two lines:
|
||||
var _ = errors.As
|
||||
var _ = fmt.Errorf
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Run tests — verify they pass**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestValidate -v
|
||||
```
|
||||
|
||||
Expected: all 6 `TestValidateMergeRequest_*` tests PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Handler ServeHTTP TDD
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/ibl_merge_report_service/handler.go` — implement `ServeHTTP`
|
||||
- Modify: `services/ibl_merge_report_service/handler_test.go` — add handler tests
|
||||
|
||||
- [ ] **Step 3.1: Write failing handler tests**
|
||||
|
||||
Append to `handler_test.go`:
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
)
|
||||
|
||||
func TestHandler_WrongMethod(t *testing.T) {
|
||||
h := newMergeHandler("secret", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/merge", nil)
|
||||
req.Header.Set("X-Internal-Secret", "secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_WrongSecret(t *testing.T) {
|
||||
h := newMergeHandler("correct", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("X-Internal-Secret", "wrong")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_InvalidJSON(t *testing.T) {
|
||||
h := newMergeHandler("s", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{bad"))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ValidationError(t *testing.T) {
|
||||
h := newMergeHandler("s", nil)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FetchError404(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, &fetchError{URL: urls[0], HTTPStatus: http.StatusNotFound}
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FetchErrorNetwork(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, &fetchError{URL: urls[0], HTTPStatus: 0}
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("expected 502, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MergeError(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("pdfcpu: corrupt PDF")
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Success(t *testing.T) {
|
||||
fakeBytes := []byte("%PDF-1.4 fake-merged")
|
||||
mockMerge := func(urls []string) ([]byte, error) { return fakeBytes, nil }
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{
|
||||
Name: "result.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Fatalf("expected application/pdf, got %s", ct)
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), fakeBytes) {
|
||||
t.Fatal("response body does not match expected PDF bytes")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The top of `handler_test.go` must have a single `import` block with all imports. Replace the import at the top of the file with:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Run tests — verify they fail**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestHandler -v
|
||||
```
|
||||
|
||||
Expected: all 8 `TestHandler_*` tests FAIL (stub returns 501).
|
||||
|
||||
- [ ] **Step 3.3: Implement ServeHTTP**
|
||||
|
||||
Replace the stub `ServeHTTP` in `handler.go` with:
|
||||
|
||||
```go
|
||||
func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("X-Internal-Secret") != h.secret {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req mergeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateMergeRequest(&req); err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pdfBytes, err := h.merge(req.URLs)
|
||||
if err != nil {
|
||||
var fe *fetchError
|
||||
if errors.As(err, &fe) {
|
||||
if fe.HTTPStatus == http.StatusNotFound {
|
||||
writeError(w, http.StatusNotFound, "source PDF not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "source PDF unavailable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "merge failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="`+req.Name+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(pdfBytes)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4: Run tests — verify all pass**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestHandler -v
|
||||
```
|
||||
|
||||
Expected: all 8 `TestHandler_*` tests PASS.
|
||||
|
||||
- [ ] **Step 3.5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab
|
||||
git add services/ibl_merge_report_service/
|
||||
git commit -m "TASKCODE - scaffold ibl_merge_report_service with handler logic and tests"
|
||||
```
|
||||
|
||||
(Replace TASKCODE with the actual task code from user before committing.)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: fetchPDF TDD
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/ibl_merge_report_service/merger.go` — implement `fetchPDF`
|
||||
- Create: `services/ibl_merge_report_service/merger_test.go`
|
||||
|
||||
- [ ] **Step 4.1: Write failing fetchPDF tests**
|
||||
|
||||
`services/ibl_merge_report_service/merger_test.go`:
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchPDF_Returns404Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchPDF(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T: %v", err, err)
|
||||
}
|
||||
if fe.HTTPStatus != http.StatusNotFound {
|
||||
t.Fatalf("expected HTTPStatus 404, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_Returns500Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchPDF(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T", err)
|
||||
}
|
||||
if fe.HTTPStatus != http.StatusInternalServerError {
|
||||
t.Fatalf("expected HTTPStatus 500, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_ReturnsBytes(t *testing.T) {
|
||||
expected := []byte("%PDF-1.4 test content")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(expected)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := fetchPDF(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != string(expected) {
|
||||
t.Fatalf("expected %q, got %q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_NetworkError(t *testing.T) {
|
||||
// Port that's almost certainly not listening
|
||||
_, err := fetchPDF("http://127.0.0.1:19999/notexist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable host")
|
||||
}
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T: %v", err, err)
|
||||
}
|
||||
if fe.HTTPStatus != 0 {
|
||||
t.Fatalf("expected HTTPStatus 0 for network error, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Run tests — verify they fail**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestFetchPDF -v
|
||||
```
|
||||
|
||||
Expected: all 4 `TestFetchPDF_*` tests FAIL (stub returns generic error).
|
||||
|
||||
- [ ] **Step 4.3: Implement fetchPDF**
|
||||
|
||||
Replace the `fetchPDF` stub in `merger.go`:
|
||||
|
||||
```go
|
||||
func fetchPDF(url string) ([]byte, error) {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: 0}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: resp.StatusCode}
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: 0}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
```
|
||||
|
||||
Add `"io"` to the import block in `merger.go`.
|
||||
|
||||
- [ ] **Step 4.4: Run tests — verify all pass**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -run TestFetchPDF -v
|
||||
```
|
||||
|
||||
Expected: all 4 `TestFetchPDF_*` tests PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: mergePDFs implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/ibl_merge_report_service/merger.go` — implement `mergePDFs`
|
||||
|
||||
No unit test is written for `mergePDFs` because testing requires valid PDF binaries which are awkward to embed. Error propagation from `fetchPDF` is already covered by `TestFetchPDF_*`. End-to-end correctness is verified manually in Task 8.
|
||||
|
||||
- [ ] **Step 5.1: Implement mergePDFs**
|
||||
|
||||
Replace the `mergePDFs` stub in `merger.go`:
|
||||
|
||||
```go
|
||||
func mergePDFs(urls []string) ([]byte, error) {
|
||||
readers := make([]io.ReadSeeker, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
data, err := fetchPDF(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readers = append(readers, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := api.MergeRaw(readers, &buf, nil); err != nil {
|
||||
return nil, fmt.Errorf("PDF merge error: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
```
|
||||
|
||||
Add `"bytes"` and `"github.com/pdfcpu/pdfcpu/pkg/api"` to the import block in `merger.go`.
|
||||
|
||||
Remove the now-unused `"time"` import only if `httpClient` is still using it — keep `time` because `httpClient` uses `25 * time.Second`.
|
||||
|
||||
The full import block for `merger.go` after this step:
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Run all tests**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -v
|
||||
```
|
||||
|
||||
Expected: all previously passing tests still pass. No new tests in this task.
|
||||
|
||||
- [ ] **Step 5.3: Build to verify no compile errors**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go build ./...
|
||||
```
|
||||
|
||||
Expected: compiles cleanly.
|
||||
|
||||
- [ ] **Step 5.4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab
|
||||
git add services/ibl_merge_report_service/
|
||||
git commit -m "TASKCODE - implement fetchPDF and mergePDFs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deployment files
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/build-merge-service.sh`
|
||||
- Create: `services/ibl_merge_report_service/ibl-merge-report.service`
|
||||
|
||||
- [ ] **Step 6.1: Create build script**
|
||||
|
||||
`scripts/build-merge-service.sh`:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/services/ibl_merge_report_service"
|
||||
OUT_DIR="$REPO_ROOT/up"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Building ibl_merge_report_service for linux/amd64..."
|
||||
cd "$SRC_DIR"
|
||||
GOOS=linux GOARCH=amd64 go build -o "$OUT_DIR/ibl-merge-report-service" .
|
||||
echo "Binary written to: $OUT_DIR/ibl-merge-report-service"
|
||||
```
|
||||
|
||||
Make executable:
|
||||
```bash
|
||||
chmod +x scripts/build-merge-service.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: Create systemd unit template**
|
||||
|
||||
`services/ibl_merge_report_service/ibl-merge-report.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=IBL Merge Report Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=one
|
||||
WorkingDirectory=/home/one/project/ibl_merge_report_service
|
||||
ExecStart=/home/one/project/ibl_merge_report_service/ibl-merge-report-service
|
||||
Environment=MERGE_LISTEN_ADDR=127.0.0.1:8005
|
||||
Environment=MERGE_INTERNAL_SECRET=REPLACE_WITH_VALUE_FROM_DB
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
`MERGE_INTERNAL_SECRET` must match the value of `S_SystemsMergeReportServiceSecret` in `conf_systems`.
|
||||
|
||||
Deployment steps on IBL server:
|
||||
1. Build: `bash scripts/build-merge-service.sh`
|
||||
2. Copy binary: `scp -i /Users/fajrihardhitamurti/id_rsa up/ibl-merge-report-service one@10.9.20.31:/home/one/project/ibl_merge_report_service/`
|
||||
3. Copy unit file: `scp -i /Users/fajrihardhitamurti/id_rsa services/ibl_merge_report_service/ibl-merge-report.service one@10.9.20.31:/tmp/`
|
||||
4. On server: `sudo mv /tmp/ibl-merge-report.service /etc/systemd/system/`
|
||||
5. On server: edit `/etc/systemd/system/ibl-merge-report.service` to set the real `MERGE_INTERNAL_SECRET`
|
||||
6. On server: `sudo systemctl daemon-reload && sudo systemctl enable ibl-merge-report && sudo systemctl start ibl-merge-report`
|
||||
7. Verify: `sudo systemctl status ibl-merge-report`
|
||||
|
||||
- [ ] **Step 6.3: Run build script to verify it compiles for Linux**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab && bash scripts/build-merge-service.sh
|
||||
```
|
||||
|
||||
Expected: `up/ibl-merge-report-service` is created (Linux ELF binary).
|
||||
|
||||
- [ ] **Step 6.4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_GITEA_IBL/BE_IBL/one-api-lab
|
||||
git add scripts/build-merge-service.sh services/ibl_merge_report_service/ibl-merge-report.service up/ibl-merge-report-service
|
||||
git commit -m "TASKCODE - add build script and systemd unit for merge report service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run all tests and final verification
|
||||
|
||||
- [ ] **Step 7.1: Run full test suite**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go test ./... -v
|
||||
```
|
||||
|
||||
Expected: all tests pass. Final count: 6 `TestValidate*` + 8 `TestHandler*` + 4 `TestFetchPDF*` = 18 tests.
|
||||
|
||||
- [ ] **Step 7.2: Run go vet**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go vet ./...
|
||||
```
|
||||
|
||||
Expected: no output (no issues).
|
||||
|
||||
- [ ] **Step 7.3: Smoke test locally (macOS)**
|
||||
|
||||
```bash
|
||||
cd services/ibl_merge_report_service && go run . &
|
||||
sleep 1
|
||||
curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8005/merge \
|
||||
-H "X-Internal-Secret: ibl-merge-secret" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"test.pdf","urls":["http://127.0.0.1:9999/nope"],"mergeRequestID":1,"T_OrderHeaderID":1}'
|
||||
```
|
||||
|
||||
Expected: HTTP `502` (source unavailable — correct error code for unreachable source).
|
||||
|
||||
Kill background process: `kill %1`
|
||||
|
||||
- [ ] **Step 7.4: Verify secret validation**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8005/merge \
|
||||
-H "X-Internal-Secret: wrong-secret" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
Expected: `401`.
|
||||
|
||||
---
|
||||
|
||||
## Error Code Reference
|
||||
|
||||
| Go returns | PHP maps to | Meaning |
|
||||
|------------|------------|---------|
|
||||
| `401` | `MERGE_SERVICE_UNAUTHORIZED` | Bad secret |
|
||||
| `404` | `MERGE_SOURCE_NOT_FOUND` | Source PDF returned 404 |
|
||||
| `422` | `MERGE_SERVICE_REJECTED` | Validation error |
|
||||
| `500` | `MERGE_SERVICE_FAILED` | pdfcpu merge error |
|
||||
| `502` | `MERGE_SERVICE_FAILED` | Source fetch network/non-404 error |
|
||||
|
||||
---
|
||||
|
||||
## Self-Review: Spec Coverage
|
||||
|
||||
- [x] Service listens on `127.0.0.1:8005` (configurable via `MERGE_LISTEN_ADDR`)
|
||||
- [x] `POST /merge` endpoint
|
||||
- [x] Validates `X-Internal-Secret` header → 401
|
||||
- [x] Validates JSON body fields (name, urls, mergeRequestID, T_OrderHeaderID) → 422
|
||||
- [x] Fetches each URL sequentially — if any fails, entire request fails
|
||||
- [x] Merges with pdfcpu, streams as `application/pdf`
|
||||
- [x] No cache / no file artifacts (in-memory only)
|
||||
- [x] Error responses use HTTP status codes that map correctly to PHP gateway error codes
|
||||
- [x] Build script for Linux amd64
|
||||
- [x] Systemd unit for IBL server deployment
|
||||
14
scripts/build-merge-service.sh
Executable file
14
scripts/build-merge-service.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/services/ibl_merge_report_service"
|
||||
OUT_DIR="$REPO_ROOT/build"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Building ibl_merge_report_service for linux/amd64..."
|
||||
cd "$SRC_DIR"
|
||||
GOOS=linux GOARCH=amd64 go build -o "$OUT_DIR/ibl-merge-report-service" .
|
||||
echo "Binary written to: $OUT_DIR/ibl-merge-report-service"
|
||||
18
services/ibl_merge_report_service/go.mod
Normal file
18
services/ibl_merge_report_service/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module ibl-merge-report-service
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/pdfcpu/pdfcpu v0.11.1
|
||||
|
||||
require (
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
24
services/ibl_merge_report_service/go.sum
Normal file
24
services/ibl_merge_report_service/go.sum
Normal file
@@ -0,0 +1,24 @@
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
91
services/ibl_merge_report_service/handler.go
Normal file
91
services/ibl_merge_report_service/handler.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type mergeRequest struct {
|
||||
Name string `json:"name"`
|
||||
URLs []string `json:"urls"`
|
||||
MergeRequestID int64 `json:"mergeRequestID"`
|
||||
TOrderHeaderID int64 `json:"T_OrderHeaderID"`
|
||||
}
|
||||
|
||||
type mergeFunc func(urls []string) ([]byte, error)
|
||||
|
||||
type mergeHandler struct {
|
||||
secret string
|
||||
merge mergeFunc
|
||||
}
|
||||
|
||||
func newMergeHandler(secret string, merge mergeFunc) http.Handler {
|
||||
return &mergeHandler{secret: secret, merge: merge}
|
||||
}
|
||||
|
||||
func (h *mergeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("X-Internal-Secret") != h.secret {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req mergeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateMergeRequest(&req); err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
pdfBytes, err := h.merge(req.URLs)
|
||||
if err != nil {
|
||||
var fe *fetchError
|
||||
if errors.As(err, &fe) {
|
||||
if fe.HTTPStatus == http.StatusNotFound {
|
||||
writeError(w, http.StatusNotFound, "source PDF not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "source PDF unavailable")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "merge failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="`+req.Name+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(pdfBytes)
|
||||
}
|
||||
|
||||
func validateMergeRequest(req *mergeRequest) error {
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if len(req.URLs) == 0 {
|
||||
return fmt.Errorf("urls must not be empty")
|
||||
}
|
||||
if req.MergeRequestID <= 0 {
|
||||
return fmt.Errorf("mergeRequestID is required")
|
||||
}
|
||||
if req.TOrderHeaderID <= 0 {
|
||||
return fmt.Errorf("T_OrderHeaderID is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
164
services/ibl_merge_report_service/handler_test.go
Normal file
164
services/ibl_merge_report_service/handler_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMergeRequest_MissingName(t *testing.T) {
|
||||
req := &mergeRequest{URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_EmptyURLs(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for empty urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_NilURLs(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: nil, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for nil urls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_MissingMergeRequestID(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 0, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing mergeRequestID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_MissingTOrderHeaderID(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 0}
|
||||
if err := validateMergeRequest(req); err == nil {
|
||||
t.Fatal("expected error for missing T_OrderHeaderID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMergeRequest_Valid(t *testing.T) {
|
||||
req := &mergeRequest{Name: "a.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1}
|
||||
if err := validateMergeRequest(req); err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_WrongMethod(t *testing.T) {
|
||||
h := newMergeHandler("secret", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/merge", nil)
|
||||
req.Header.Set("X-Internal-Secret", "secret")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_WrongSecret(t *testing.T) {
|
||||
h := newMergeHandler("correct", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("X-Internal-Secret", "wrong")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_InvalidJSON(t *testing.T) {
|
||||
h := newMergeHandler("s", nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBufferString("{bad"))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ValidationError(t *testing.T) {
|
||||
h := newMergeHandler("s", nil)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("expected 422, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FetchError404(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, &fetchError{URL: urls[0], HTTPStatus: http.StatusNotFound}
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_FetchErrorNetwork(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, &fetchError{URL: urls[0], HTTPStatus: 0}
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("expected 502, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_MergeError(t *testing.T) {
|
||||
mockMerge := func(urls []string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("pdfcpu: corrupt PDF")
|
||||
}
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{Name: "x.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Success(t *testing.T) {
|
||||
fakeBytes := []byte("%PDF-1.4 fake-merged")
|
||||
mockMerge := func(urls []string) ([]byte, error) { return fakeBytes, nil }
|
||||
h := newMergeHandler("s", mockMerge)
|
||||
body, _ := json.Marshal(mergeRequest{
|
||||
Name: "result.pdf", URLs: []string{"http://x"}, MergeRequestID: 1, TOrderHeaderID: 1,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", bytes.NewBuffer(body))
|
||||
req.Header.Set("X-Internal-Secret", "s")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Fatalf("expected application/pdf, got %s", ct)
|
||||
}
|
||||
if !bytes.Equal(w.Body.Bytes(), fakeBytes) {
|
||||
t.Fatal("response body does not match expected PDF bytes")
|
||||
}
|
||||
}
|
||||
18
services/ibl_merge_report_service/ibl-merge-report.service
Normal file
18
services/ibl_merge_report_service/ibl-merge-report.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=IBL Merge Report Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=one
|
||||
WorkingDirectory=/home/one/project/ibl_merge_report_service
|
||||
ExecStart=/home/one/project/ibl_merge_report_service/ibl-merge-report-service
|
||||
Environment=MERGE_LISTEN_ADDR=127.0.0.1:8005
|
||||
Environment=MERGE_INTERNAL_SECRET=REPLACE_WITH_VALUE_FROM_DB
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
30
services/ibl_merge_report_service/main.go
Normal file
30
services/ibl_merge_report_service/main.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
secret := os.Getenv("MERGE_INTERNAL_SECRET")
|
||||
if secret == "" {
|
||||
secret = "ibl-merge-secret"
|
||||
}
|
||||
addr := os.Getenv("MERGE_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:8005"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/merge", func(w http.ResponseWriter, r *http.Request) {
|
||||
newMergeHandler(secret, mergePDFs).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("ibl_merge_report_service listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
60
services/ibl_merge_report_service/merger.go
Normal file
60
services/ibl_merge_report_service/merger.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 25 * time.Second}
|
||||
|
||||
type fetchError struct {
|
||||
URL string
|
||||
HTTPStatus int // 0 = network/timeout
|
||||
}
|
||||
|
||||
func (e *fetchError) Error() string {
|
||||
if e.HTTPStatus != 0 {
|
||||
return fmt.Sprintf("source %s returned HTTP %d", e.URL, e.HTTPStatus)
|
||||
}
|
||||
return fmt.Sprintf("failed to fetch source %s", e.URL)
|
||||
}
|
||||
|
||||
func fetchPDF(url string) ([]byte, error) {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: 0}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: resp.StatusCode}
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &fetchError{URL: url, HTTPStatus: 0}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func mergePDFs(urls []string) ([]byte, error) {
|
||||
readers := make([]io.ReadSeeker, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
data, err := fetchPDF(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readers = append(readers, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := api.MergeRaw(readers, &buf, false, nil); err != nil {
|
||||
return nil, fmt.Errorf("PDF merge error: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
81
services/ibl_merge_report_service/merger_test.go
Normal file
81
services/ibl_merge_report_service/merger_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchPDF_Returns404Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchPDF(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 response")
|
||||
}
|
||||
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T: %v", err, err)
|
||||
}
|
||||
if fe.HTTPStatus != http.StatusNotFound {
|
||||
t.Fatalf("expected HTTPStatus 404, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_Returns500Error(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := fetchPDF(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T", err)
|
||||
}
|
||||
if fe.HTTPStatus != http.StatusInternalServerError {
|
||||
t.Fatalf("expected HTTPStatus 500, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_ReturnsBytes(t *testing.T) {
|
||||
expected := []byte("%PDF-1.4 test content")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(expected)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := fetchPDF(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != string(expected) {
|
||||
t.Fatalf("expected %q, got %q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPDF_NetworkError(t *testing.T) {
|
||||
// Port that's almost certainly not listening
|
||||
_, err := fetchPDF("http://127.0.0.1:19999/notexist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable host")
|
||||
}
|
||||
var fe *fetchError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Fatalf("expected *fetchError, got %T: %v", err, err)
|
||||
}
|
||||
if fe.HTTPStatus != 0 {
|
||||
t.Fatalf("expected HTTPStatus 0 for network error, got %d", fe.HTTPStatus)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user