first commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"service/internal/infrastructure/config"
|
||||
"service/pkg/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BpjsClient adalah core HTTP client untuk API BPJS.
|
||||
type BpjsClient interface {
|
||||
DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
|
||||
}
|
||||
|
||||
type client struct {
|
||||
cfg config.BpjsConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewBpjsClient membuat instance baru dari BpjsClient.
|
||||
func NewBpjsClient(cfg config.BpjsConfig) BpjsClient {
|
||||
return &client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequest secara otomatis menangani header signature, request, pengecekan error dari BPJS, dan dekripsi respons.
|
||||
func (c *client) DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return nil, fmt.Errorf("BPJS VClaim integration is disabled in configuration")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
|
||||
serviceName := strings.Trim(c.cfg.ServiceName, "/")
|
||||
endpointPath := strings.TrimLeft(endpoint, "/")
|
||||
|
||||
var url string
|
||||
if serviceName != "" {
|
||||
url = fmt.Sprintf("%s/%s/%s", baseURL, serviceName, endpointPath)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/%s", baseURL, endpointPath)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
bodyReader = bytes.NewBuffer(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Gunakan method dari config Anda untuk generate header secara otomatis
|
||||
consID, secretKey, userKey, timestamp, signature := c.cfg.SetHeader()
|
||||
|
||||
req.Header.Set("X-cons-id", consID)
|
||||
req.Header.Set("X-timestamp", timestamp)
|
||||
req.Header.Set("X-signature", signature)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
// Aplicares tidak membutuhkan user_key, kirimkan hanya jika tidak kosong (VClaim/Antrol)
|
||||
if userKey != "" {
|
||||
req.Header.Set("user_key", userKey)
|
||||
}
|
||||
|
||||
// Aplicares menggunakan application/json, sementara layanan lain menggunakan x-www-form-urlencoded
|
||||
if strings.Contains(strings.ToLower(c.cfg.ServiceName), "aplicaresws") {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "Application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
// Injeksi Token Khusus Antrean RS (x-token) via Context
|
||||
if token, ok := ctx.Value(TokenContextKey).(string); ok && token != "" {
|
||||
req.Header.Set("x-token", token)
|
||||
}
|
||||
|
||||
// [RADAR] LOG OUTGOING REQUEST
|
||||
logger.Default().Info("🛫 BPJS API Request Sent",
|
||||
logger.String("target_url", url),
|
||||
logger.String("method", method),
|
||||
logger.String("used_cons_id", consID),
|
||||
logger.String("used_user_key", userKey),
|
||||
logger.String("used_x_token", req.Header.Get("x-token")),
|
||||
logger.String("timestamp", timestamp),
|
||||
)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// [RADAR] LOG INCOMING RESPONSE
|
||||
// logger.Default().Info("🛬 BPJS API Response Received",
|
||||
// logger.Int("status_code", resp.StatusCode),
|
||||
// logger.String("raw_body", strings.TrimSpace(string(respBody))),
|
||||
// )
|
||||
|
||||
// 1. Tangani penolakan API Gateway secara langsung (misal: 401/403 dengan balikan plaintext "Authentication failed")
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("BPJS Gateway Error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
// 2. Tangani kasus BPJS mengembalikan HTTP 200 OK tetapi body berupa Plain Text (misal "Authentication failed")
|
||||
bodyStr := strings.TrimSpace(string(respBody))
|
||||
if !strings.HasPrefix(bodyStr, "{") && !strings.HasPrefix(bodyStr, "[") {
|
||||
logger.Default().Error("BPJS Non-JSON Response", logger.String("body", bodyStr), logger.String("endpoint", endpointPath))
|
||||
return nil, fmt.Errorf("BPJS API Error (Plain Text): %s", bodyStr)
|
||||
}
|
||||
|
||||
// 3. Parsing struktur dasar BPJS Response secara fleksibel
|
||||
var baseResp struct {
|
||||
MetaData struct {
|
||||
Code interface{} `json:"code"` // Gunakan interface{} krn VClaim mereturn string "200", Antrol mereturn int 200
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Metadata struct { // Antrean RS (Antrol) menggunakan key dengan huruf kecil
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metadata"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &baseResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse base BPJS response: %w. Body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// 4. Ekstrak code dan message secara dinamis untuk format VClaim dan Antrean RS
|
||||
var codeStr, message string
|
||||
if baseResp.MetaData.Code != nil {
|
||||
codeStr = fmt.Sprintf("%v", baseResp.MetaData.Code)
|
||||
message = baseResp.MetaData.Message
|
||||
} else if baseResp.Metadata.Code != nil {
|
||||
codeStr = fmt.Sprintf("%v", baseResp.Metadata.Code)
|
||||
message = baseResp.Metadata.Message
|
||||
}
|
||||
|
||||
// Kode sukses BPJS biasanya 200, 201, atau terkadang 1
|
||||
if codeStr != "200" && codeStr != "1" && codeStr != "201" {
|
||||
logger.Default().Warn("BPJS API Error", logger.String("code", codeStr), logger.String("message", message))
|
||||
return nil, fmt.Errorf("BPJS error %s: %s", codeStr, message)
|
||||
}
|
||||
|
||||
// Jika "response" null / kosong, berarti sukses tanpa payload
|
||||
if len(baseResp.Response) == 0 || string(baseResp.Response) == "null" {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
// Dekripsi response (VClaim V2)
|
||||
var cipherText string
|
||||
if err := json.Unmarshal(baseResp.Response, &cipherText); err != nil {
|
||||
// Jika response bukan string terenkripsi (misal: response sukses tanpa data)
|
||||
return baseResp.Response, nil
|
||||
}
|
||||
|
||||
// Lakukan dekripsi
|
||||
decryptedStr, err := DecryptPayload(cipherText, consID, secretKey, timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt BPJS payload: %w", err)
|
||||
}
|
||||
|
||||
return []byte(decryptedStr), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// DecryptPayload mengembalikan payload BPJS API yang sudah didekripsi dan disalurkan ke mesin dekompresi otomatis.
|
||||
func DecryptPayload(cipherText string, consID, secretKey, timestamp string) (string, error) {
|
||||
if cipherText == "" {
|
||||
return "", errors.New("ciphertext cannot be empty")
|
||||
}
|
||||
|
||||
// 1. Generate Key dari gabungan ConsID + SecretKey + Timestamp
|
||||
keyString := consID + secretKey + timestamp
|
||||
hash := sha256.Sum256([]byte(keyString))
|
||||
key := hash[:]
|
||||
|
||||
// IV adalah 16 byte pertama dari key
|
||||
iv := key[:16]
|
||||
|
||||
// 2. Base64 Decode CipherText
|
||||
cipherBytes, err := base64.StdEncoding.DecodeString(cipherText)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to base64 decode cipher text")
|
||||
}
|
||||
|
||||
// 3. AES-256-CBC Decryption
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(cipherBytes)%aes.BlockSize != 0 {
|
||||
return "", errors.New("ciphertext is not a multiple of the block size")
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(cipherBytes, cipherBytes)
|
||||
|
||||
// Unpad PKCS7
|
||||
cipherBytes = unpadPKCS7(cipherBytes)
|
||||
|
||||
// 4. Automatis Dekompresi menggunakan Advanced Handler (LZString/Gzip)
|
||||
return DecompressPayload(cipherBytes)
|
||||
}
|
||||
|
||||
func unpadPKCS7(b []byte) []byte {
|
||||
length := len(b)
|
||||
if length == 0 {
|
||||
return b
|
||||
}
|
||||
unpadding := int(b[length-1])
|
||||
return b[:(length - unpadding)]
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
lzstring "github.com/daku10/go-lz-string"
|
||||
)
|
||||
|
||||
// DecompressPayload mengupayakan ekstraksi JSON menggunakan berbagai algoritma (GZIP, LZString, dll).
|
||||
func DecompressPayload(data []byte) (string, error) {
|
||||
log.Printf("DecompressPayload: Attempting decompression, data length: %d", len(data))
|
||||
|
||||
// Log hex dump for better debugging
|
||||
hexDump := make([]string, min(32, len(data)))
|
||||
for i := 0; i < len(hexDump); i++ {
|
||||
hexDump[i] = fmt.Sprintf("%02x", data[i])
|
||||
}
|
||||
log.Printf("DecompressPayload: Hex dump (first 32 bytes): %s", strings.Join(hexDump, " "))
|
||||
|
||||
// Method 1: Try LZ-string first (most common for BPJS)
|
||||
if result, err := tryLZStringMethods(data); err == nil {
|
||||
log.Println("DecompressPayload: LZ-string decompression successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Method 2: Try gzip
|
||||
if result, err := tryGzipDecompression(data); err == nil && isValidDecompressedResult(result) {
|
||||
log.Println("DecompressPayload: Gzip decompression successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Method 3: Try as plain text
|
||||
if isValidUTF8AndPrintable(string(data)) {
|
||||
result := string(data)
|
||||
if isValidDecompressedResult(result) {
|
||||
log.Println("DecompressPayload: Data is already valid text")
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Try base64 decode then decompress
|
||||
if result, err := tryBase64ThenDecompress(data); err == nil {
|
||||
log.Println("DecompressPayload: Base64 then decompress successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
log.Printf("DecompressPayload: All methods failed")
|
||||
return "", errors.New("all decompression methods failed")
|
||||
}
|
||||
|
||||
// tryLZStringMethods attempts LZ-string decompression
|
||||
func tryLZStringMethods(data []byte) (string, error) {
|
||||
dataStr := string(data)
|
||||
log.Printf("tryLZStringMethods: Raw data length: %d", len(dataStr))
|
||||
|
||||
// Method 1: Clean corrupt prefix and find LZ-string pattern
|
||||
cleanedData := extractCleanLZString(dataStr)
|
||||
if cleanedData != "" {
|
||||
log.Printf("tryLZStringMethods: Found clean LZ-string: %s", cleanedData[:min(50, len(cleanedData))])
|
||||
|
||||
// Decompress according to BPJS standards
|
||||
if result, err := lzstring.DecompressFromEncodedURIComponent(cleanedData); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
log.Printf("LZ-string decompression successful, length: %d", len(result))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback direct decompression
|
||||
if result, err := lzstring.DecompressFromEncodedURIComponent(dataStr); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try base64 LZ-string
|
||||
if result, err := lzstring.DecompressFromBase64(dataStr); err == nil && len(result) > 0 {
|
||||
if isValidDecompressedResult(result) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("LZ-string decompression failed")
|
||||
}
|
||||
|
||||
// extractCleanLZString extracts clean LZ-string from corrupt data
|
||||
func extractCleanLZString(data string) string {
|
||||
// Common LZ-string patterns from BPJS documentation
|
||||
patterns := []string{"EAuUA", "N4Ig", "BwIw", "CwIw", "DwIw", "EwIw", "FwIw", "GwIw", "HwIw"}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if idx := strings.Index(data, pattern); idx >= 0 {
|
||||
// Extract from pattern to end
|
||||
candidate := data[idx:]
|
||||
log.Printf("extractCleanLZString: Found pattern '%s' at position %d", pattern, idx)
|
||||
|
||||
// Clean only valid base64 characters
|
||||
cleaned := extractBase64Only(candidate)
|
||||
if len(cleaned) > 100 { // Minimum length for valid data
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractBase64Only extracts only base64 valid characters
|
||||
func extractBase64Only(s string) string {
|
||||
base64Chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
var result strings.Builder
|
||||
|
||||
for _, char := range s {
|
||||
if strings.ContainsRune(base64Chars, char) {
|
||||
result.WriteRune(char)
|
||||
} else {
|
||||
// Stop at non-base64 character if already long enough
|
||||
if result.Len() > 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// tryGzipDecompression attempts gzip decompression
|
||||
func tryGzipDecompression(data []byte) (string, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(decompressed), nil
|
||||
}
|
||||
|
||||
// tryBase64ThenDecompress attempts base64 decode then decompress
|
||||
func tryBase64ThenDecompress(data []byte) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tryLZStringMethods(decoded)
|
||||
}
|
||||
|
||||
// isValidDecompressedResult validates decompressed result
|
||||
func isValidDecompressedResult(result string) bool {
|
||||
if len(result) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim whitespace and check UTF-8
|
||||
trimmed := strings.TrimSpace(result)
|
||||
if !utf8.ValidString(trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must start with { or [ for JSON
|
||||
if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') {
|
||||
// Validate as JSON
|
||||
var js json.RawMessage
|
||||
if json.Unmarshal([]byte(result), &js) == nil {
|
||||
log.Printf("Decompressed result is valid JSON, length: %d", len(result))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If not JSON, reject
|
||||
log.Printf("Decompressed result is not valid JSON")
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidUTF8AndPrintable checks if string is valid UTF-8 and printable
|
||||
func isValidUTF8AndPrintable(s string) bool {
|
||||
if !utf8.ValidString(s) {
|
||||
log.Printf("isValidUTF8AndPrintable: String is not valid UTF-8")
|
||||
return false
|
||||
}
|
||||
|
||||
// Count valid characters
|
||||
validChars := 0
|
||||
totalChars := 0
|
||||
|
||||
for _, r := range s {
|
||||
totalChars++
|
||||
|
||||
if r >= 32 && r <= 126 { // Printable ASCII
|
||||
validChars++
|
||||
} else if r == '\n' || r == '\r' || r == '\t' { // Allowed control chars
|
||||
validChars++
|
||||
} else if r >= 160 { // Unicode characters
|
||||
validChars++
|
||||
}
|
||||
}
|
||||
|
||||
validRatio := float64(validChars) / float64(totalChars)
|
||||
log.Printf("isValidUTF8AndPrintable: Valid chars ratio: %.2f (%d/%d)", validRatio, validChars, totalChars)
|
||||
|
||||
// At least 70% should be valid characters
|
||||
return validRatio >= 0.7
|
||||
}
|
||||
|
||||
// hasLZStringPattern detects LZ-string patterns
|
||||
func hasLZStringPattern(s string) bool {
|
||||
if len(s) < 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Common LZ-string compressed data patterns
|
||||
commonLZPatterns := []string{
|
||||
"N4Ig", "BwIw", "CwIw", "DwIw", "EwIw", "FwIw", "GwIw", "HwIw",
|
||||
"IwIw", "JwIw", "KwIw", "LwIw", "MwIw", "NwIw", "OwIw", "PwIw",
|
||||
}
|
||||
|
||||
for _, pattern := range commonLZPatterns {
|
||||
if strings.HasPrefix(s, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if string contains only base64 characters without spaces or newlines
|
||||
base64Pattern := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
if len(s) > 50 { // Only check long strings
|
||||
invalidChars := 0
|
||||
for _, char := range s {
|
||||
if !strings.ContainsRune(base64Pattern, char) {
|
||||
invalidChars++
|
||||
}
|
||||
}
|
||||
// If less than 5% invalid characters, likely LZ-string
|
||||
if float64(invalidChars)/float64(len(s)) < 0.05 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanResponse cleans response string
|
||||
func cleanResponse(s string) string {
|
||||
// Remove UTF-8 BOM and other BOM variations
|
||||
s = strings.TrimPrefix(s, "\xef\xbb\xbf") // UTF-8 BOM
|
||||
s = strings.TrimPrefix(s, "\ufeff") // Unicode BOM
|
||||
s = strings.TrimPrefix(s, "\ufffe") // Unicode BOM (reverse)
|
||||
s = strings.TrimPrefix(s, "\xff\xfe") // UTF-16 LE BOM
|
||||
s = strings.TrimPrefix(s, "\xfe\xff") // UTF-16 BE BOM
|
||||
|
||||
// Remove control and non-printable characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= 32 && r <= 126 || r == '\n' || r == '\r' || r == '\t' {
|
||||
result.WriteRune(r)
|
||||
} else if r > 126 && unicode.IsPrint(r) {
|
||||
// Allow Unicode printable characters
|
||||
result.WriteRune(r)
|
||||
}
|
||||
// Skip all other characters (including BOM fragments)
|
||||
}
|
||||
|
||||
cleaned := result.String()
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
// Find and extract valid JSON
|
||||
if idx := strings.Index(cleaned, "{"); idx >= 0 {
|
||||
cleaned = cleaned[idx:]
|
||||
// Find matching closing brace
|
||||
if endIdx := findMatchingBrace(cleaned); endIdx > 0 {
|
||||
cleaned = cleaned[:endIdx+1]
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("cleanResponse: Final cleaned length: %d", len(cleaned))
|
||||
log.Printf("cleanResponse: Final result preview: %s", cleaned[:min(200, len(cleaned))])
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// findMatchingBrace finds matching closing brace
|
||||
func findMatchingBrace(s string) int {
|
||||
if len(s) == 0 || s[0] != '{' {
|
||||
return -1
|
||||
}
|
||||
|
||||
braceCount := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
|
||||
for i, char := range s {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '"' && !escaped {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
|
||||
if !inString {
|
||||
if char == '{' {
|
||||
braceCount++
|
||||
} else if char == '}' {
|
||||
braceCount--
|
||||
if braceCount == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"service/internal/infrastructure/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
// Konstanta Base Service Name (Lingkungan Production)
|
||||
ServiceNameAntreanRS = "antreanrs"
|
||||
ServiceNameAntreanFKTP = "antreanfktp"
|
||||
ServiceNameApotek = "apotek-rest"
|
||||
ServiceNamePCare = "pcare-rest"
|
||||
ServiceNameICare = "ihs"
|
||||
ServiceNameERekamMedis = "erekammedis"
|
||||
ServiceNameAplicare = "aplicaresws"
|
||||
ServiceNameVClaim = "vclaim-rest"
|
||||
)
|
||||
|
||||
// Factory menyediakan akses tersentralisasi ke berbagai layanan HTTP Client BPJS yang Fleksibel.
|
||||
type Factory struct {
|
||||
baseConfig config.BpjsConfig
|
||||
}
|
||||
|
||||
// NewBPJSFactory membuat instance factory baru dengan konfigurasi utama.
|
||||
func NewBPJSFactory(cfg config.BpjsConfig) *Factory {
|
||||
return &Factory{
|
||||
baseConfig: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Factory) resolveServiceName(baseName string) string {
|
||||
name := baseName
|
||||
// Auto-detect environment berdasarkan BaseURL (jika URL mengandung kata 'dev')
|
||||
if strings.Contains(f.baseConfig.BaseURL, "dev") {
|
||||
if baseName == ServiceNameVClaim || baseName == ServiceNameApotek || baseName == ServiceNamePCare {
|
||||
name = baseName + "-dev"
|
||||
} else if baseName == ServiceNameAplicare {
|
||||
name = baseName // Pengecualian: Aplicares sama sekali tidak menggunakan akhiran -dev atau _dev
|
||||
} else {
|
||||
name = baseName + "_dev" // AntreanRS, dll menggunakan underscore
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (f *Factory) createClient(serviceName string, customConsID string, customSecretKey string, customUserKey string) BpjsClient {
|
||||
svcConfig := f.baseConfig
|
||||
|
||||
// Timpa kredensial Default dengan kredensial spesifik layanan (Khusus Apotek)
|
||||
if customConsID != "" {
|
||||
svcConfig.ConsID = customConsID
|
||||
}
|
||||
if customSecretKey != "" {
|
||||
svcConfig.SecretKey = customSecretKey
|
||||
}
|
||||
// Timpa Active UserKey
|
||||
svcConfig.UserKey = customUserKey
|
||||
// [HOTFIX] Penyesuaian Base URL khusus untuk Aplicares
|
||||
// Karena Aplicares BPJS (Ketersediaan Tempat Tidur) BELUM dimigrasikan ke API Gateway Kong (apijkn).
|
||||
// Aplicares masih menggunakan server lamanya yaitu dvlp (Dev) dan new-api (Prod).
|
||||
if serviceName == ServiceNameAplicare {
|
||||
if strings.Contains(svcConfig.BaseURL, "apijkn-dev") {
|
||||
svcConfig.BaseURL = "https://dvlp.bpjs-kesehatan.go.id:8888"
|
||||
} else if strings.Contains(svcConfig.BaseURL, "apijkn.bpjs-kesehatan.go.id") {
|
||||
svcConfig.BaseURL = "https://new-api.bpjs-kesehatan.go.id"
|
||||
}
|
||||
}
|
||||
svcConfig.ServiceName = f.resolveServiceName(serviceName)
|
||||
return NewBpjsClient(svcConfig)
|
||||
}
|
||||
|
||||
// --- Kumpulan Instance Client untuk Masing-masing Layanan BPJS ---
|
||||
|
||||
func (f *Factory) VClaim() BpjsClient {
|
||||
// Gunakan "" agar otomatis memakai kredensial Default (VClaim)
|
||||
return f.createClient(ServiceNameVClaim, "", "", f.baseConfig.VclaimUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) AntreanRS() BpjsClient {
|
||||
return f.createClient(ServiceNameAntreanRS, "", "", f.baseConfig.AntrolUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) AntreanFKTP() BpjsClient {
|
||||
return f.createClient(ServiceNameAntreanFKTP, "", "", f.baseConfig.AntrolUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) Aplicare() BpjsClient {
|
||||
return f.createClient(ServiceNameAplicare, "", "", f.baseConfig.AplicareUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) Apotek() BpjsClient {
|
||||
// Apotek menggunakan ConsID dan SecretKey nya sendiri!
|
||||
return f.createClient(ServiceNameApotek, f.baseConfig.ApotekConsID, f.baseConfig.ApotekSecretKey, f.baseConfig.ApotekUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) PCare() BpjsClient {
|
||||
return f.createClient(ServiceNamePCare, "", "", "")
|
||||
}
|
||||
|
||||
func (f *Factory) ICare() BpjsClient {
|
||||
return f.createClient(ServiceNameICare, "", "", f.baseConfig.IhsUserKey)
|
||||
}
|
||||
|
||||
func (f *Factory) ERekamMedis() BpjsClient {
|
||||
return f.createClient(ServiceNameERekamMedis, "", "", f.baseConfig.IhsUserKey)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package bpjs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"service/internal/infrastructure/config"
|
||||
)
|
||||
|
||||
// VClaimService interface for VClaim operations
|
||||
type VClaimService interface {
|
||||
// Basic HTTP methods
|
||||
Get(ctx context.Context, endpoint string, result interface{}) error
|
||||
Post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Put(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Patch(ctx context.Context, endpoint string, payload interface{}, result interface{}) error
|
||||
Delete(ctx context.Context, endpoint string, result interface{}) error
|
||||
|
||||
// Raw response methods
|
||||
GetRawResponse(ctx context.Context, endpoint string) (*ResponseDTO, error)
|
||||
PostRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
PutRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
PatchRawResponse(ctx context.Context, endpoint string, payload interface{}) (*ResponseDTO, error)
|
||||
DeleteRawResponse(ctx context.Context, endpoint string) (*ResponseDTO, error)
|
||||
|
||||
// Configuration methods
|
||||
SetHTTPClient(client *http.Client)
|
||||
GetConfig() config.BpjsConfig
|
||||
}
|
||||
|
||||
// Service struct for VClaim service
|
||||
type Service struct {
|
||||
config config.BpjsConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Response structures
|
||||
type RawResponseDTO struct {
|
||||
MetaData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
type ResponseDTO struct {
|
||||
MetaData struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"metaData"`
|
||||
Response interface{} `json:"response"`
|
||||
}
|
||||
|
||||
// Request headers structure
|
||||
type RequestHeaders struct {
|
||||
ConsID string
|
||||
SecretKey string
|
||||
UserKey string
|
||||
Timestamp string
|
||||
Signature string
|
||||
AuthHeader string
|
||||
}
|
||||
|
||||
// Decryption result structure
|
||||
type DecryptionResult struct {
|
||||
Success bool
|
||||
Data string
|
||||
Error error
|
||||
Method string
|
||||
}
|
||||
|
||||
// Decompression result structure
|
||||
type DecompressionResult struct {
|
||||
Success bool
|
||||
Data string
|
||||
Error error
|
||||
Method string
|
||||
}
|
||||
|
||||
// Crypto configuration
|
||||
type CryptoConfig struct {
|
||||
KeyLength int
|
||||
IVLength int
|
||||
PaddingScheme string
|
||||
CipherMode string
|
||||
}
|
||||
|
||||
// Decompression configuration
|
||||
type DecompressionConfig struct {
|
||||
MaxRetries int
|
||||
Timeout time.Duration
|
||||
EnabledMethods []string
|
||||
FallbackMethods []string
|
||||
}
|
||||
|
||||
// HTTP configuration
|
||||
type HTTPConfig struct {
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
RetryDelay time.Duration
|
||||
UserAgent string
|
||||
CompressionType string
|
||||
EnableKeepAlive bool
|
||||
MaxIdleConns int
|
||||
IdleConnTimeout time.Duration
|
||||
}
|
||||
|
||||
// Error types
|
||||
type VClaimError struct {
|
||||
Code string
|
||||
Message string
|
||||
Endpoint string
|
||||
HTTPStatus int
|
||||
Timestamp time.Time
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *VClaimError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return e.Message + ": " + e.Cause.Error()
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *VClaimError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Constants
|
||||
const (
|
||||
// Cipher modes
|
||||
CipherModeCBC = "CBC"
|
||||
CipherModeECB = "ECB"
|
||||
CipherModeGCM = "GCM"
|
||||
|
||||
// Padding schemes
|
||||
PaddingPKCS7 = "PKCS7"
|
||||
PaddingANSI = "ANSI"
|
||||
PaddingISO = "ISO"
|
||||
|
||||
// Decompression methods
|
||||
MethodLZString = "lzstring"
|
||||
MethodGzip = "gzip"
|
||||
MethodBase64 = "base64"
|
||||
MethodPlain = "plain"
|
||||
|
||||
// HTTP methods
|
||||
MethodGET = "GET"
|
||||
MethodPOST = "POST"
|
||||
MethodPUT = "PUT"
|
||||
MethodPATCH = "PATCH"
|
||||
MethodDELETE = "DELETE"
|
||||
|
||||
// Default configurations
|
||||
DefaultTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
DefaultKeyLength = 32
|
||||
DefaultIVLength = 16
|
||||
DefaultPaddingScheme = PaddingPKCS7
|
||||
DefaultCipherMode = CipherModeCBC
|
||||
DefaultUserAgent = "BPJS-VClaim-Service/1.0"
|
||||
DefaultCompressionType = "gzip"
|
||||
)
|
||||
|
||||
// Default configurations
|
||||
var (
|
||||
DefaultCryptoConfig = CryptoConfig{
|
||||
KeyLength: DefaultKeyLength,
|
||||
IVLength: DefaultIVLength,
|
||||
PaddingScheme: DefaultPaddingScheme,
|
||||
CipherMode: DefaultCipherMode,
|
||||
}
|
||||
|
||||
DefaultDecompressionConfig = DecompressionConfig{
|
||||
MaxRetries: 3,
|
||||
Timeout: 10 * time.Second,
|
||||
EnabledMethods: []string{MethodLZString, MethodGzip, MethodBase64, MethodPlain},
|
||||
FallbackMethods: []string{MethodPlain},
|
||||
}
|
||||
|
||||
DefaultHTTPConfig = HTTPConfig{
|
||||
Timeout: DefaultTimeout,
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
RetryDelay: DefaultRetryDelay,
|
||||
UserAgent: DefaultUserAgent,
|
||||
CompressionType: DefaultCompressionType,
|
||||
}
|
||||
)
|
||||
|
||||
// ContextKey digunakan untuk mengirimkan token secara dinamis via context
|
||||
type ContextKey string
|
||||
|
||||
const TokenContextKey ContextKey = "x-token"
|
||||
@@ -0,0 +1,173 @@
|
||||
# 📚 Dokumentasi Integrasi Minio Storage
|
||||
|
||||
Modul ini (`internal/interfaces/minio`) berfungsi untuk mengatur koneksi dan inisialisasi ke layanan Object Storage berbasis S3 (seperti Minio, AWS S3, dll) menggunakan SDK `minio-go/v7`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konfigurasi (Environment / YAML)
|
||||
|
||||
Berdasarkan struct `MinioCfg`, aplikasi mengekspektasikan konfigurasi untuk Minio. Pastikan file konfigurasi Anda (misalnya `config.yaml`) memiliki struktur yang dipetakan sebagai berikut:
|
||||
|
||||
```yaml
|
||||
minio:
|
||||
endpoint: "play.min.io" # (Contoh) Jangan gunakan http:// atau https://
|
||||
region: "ap-southeast-1" # Region server S3/Minio
|
||||
accessKey: "YOUR-ACCESS-KEY" # Access key dari Minio
|
||||
secretKey: "YOUR-SECRET-KEY" # Secret key dari Minio
|
||||
useSsl: true # true untuk HTTPS, false untuk HTTP
|
||||
bucketName:
|
||||
- "my-app-bucket" # Daftar bucket yang digunakan
|
||||
- "public-assets"
|
||||
```
|
||||
*(Catatan: Konfigurasi ini akan di-parse menggunakan package `github.com/karincake/apem` sesuai implementasi di `Connect()`)*.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cara Inisialisasi di `main.go`
|
||||
|
||||
Untuk mengaktifkan koneksi ke Minio saat aplikasi berjalan, Anda cukup memanggil fungsi `Connect()` dari package ini pada saat proses bootstrap di file `cmd/api/main.go`.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"service/internal/interfaces/minio"
|
||||
// import lainnya...
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Load config umum (seperti DB, dll)
|
||||
// ...
|
||||
|
||||
// 2. Inisialisasi Minio
|
||||
minio.Connect()
|
||||
|
||||
// Jika berhasil, log "Instantiation for object storage service using Minio, status: DONE!!"
|
||||
// akan muncul di console dan client minio.I siap digunakan.
|
||||
|
||||
// ... Lanjutkan inisialisasi server (REST/gRPC)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Cara Penggunaan (Sinkronisasi, Upload, dan Download)
|
||||
|
||||
Koneksi klien disimpan secara global dalam variabel pointer `minio.I`. Anda dapat memanggilnya di layer **Repository** atau **Service** Anda untuk melakukan operasi Object Storage.
|
||||
|
||||
Berikut adalah contoh implementasi metode-metode yang bisa digunakan:
|
||||
|
||||
### A. Upload File (Sinkronisasi File Lokal ke Minio)
|
||||
Gunakan metode `PutObject` untuk mengunggah data/file ke dalam Minio.
|
||||
|
||||
```go
|
||||
package fileservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"service/internal/interfaces/minio"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func UploadFile(ctx context.Context, file *multipart.FileHeader, bucketName, objectName string) error {
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Menggunakan variabel global minio.I
|
||||
info, err := minio.I.PutObject(ctx, bucketName, objectName, src, file.Size, miniogo.PutObjectOptions{
|
||||
ContentType: file.Header.Get("Content-Type"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// info.Size, info.Key bisa digunakan jika ingin dicatat ke database
|
||||
_ = info
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### B. Download File dari Minio
|
||||
Gunakan metode `GetObject` untuk mengambil file stream dari storage.
|
||||
|
||||
```go
|
||||
package fileservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"service/internal/interfaces/minio"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func DownloadFile(ctx context.Context, bucketName, objectName string) (io.ReadCloser, error) {
|
||||
// Mengambil file stream dari minio
|
||||
object, err := minio.I.GetObject(ctx, bucketName, objectName, miniogo.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ingat untuk melakukan object.Close() setelah selesai membaca di sisi HTTP Handler
|
||||
return object, nil
|
||||
}
|
||||
```
|
||||
|
||||
### C. Menghasilkan Presigned URL (Untuk Download/Akses Publik Sementara)
|
||||
Jika file bersifat privat, namun Anda ingin memberikan akses baca ke *frontend* (browser) untuk jangka waktu tertentu.
|
||||
|
||||
```go
|
||||
package fileservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"service/internal/interfaces/minio"
|
||||
)
|
||||
|
||||
func GetFileUrl(ctx context.Context, bucketName, objectName string) (string, error) {
|
||||
// URL akan valid selama 24 jam
|
||||
expiry := time.Second * 24 * 60 * 60
|
||||
|
||||
presignedURL, err := minio.I.PresignedGetObject(ctx, bucketName, objectName, expiry, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return presignedURL.String(), nil
|
||||
}
|
||||
```
|
||||
|
||||
### D. Memastikan / Membuat Bucket Otomatis
|
||||
Sebelum upload, ada baiknya memastikan bucket sudah ada.
|
||||
|
||||
```go
|
||||
package fileservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"service/internal/interfaces/minio"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func EnsureBucketExists(ctx context.Context, bucketName string) error {
|
||||
exists, err := minio.I.BucketExists(ctx, bucketName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
err = minio.I.MakeBucket(ctx, bucketName, miniogo.MakeBucketOptions{Region: minio.O.Region})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
package minio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
logs "github.com/karincake/apem/loggero"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
var O MinioCfg = MinioCfg{}
|
||||
var I *minio.Client
|
||||
|
||||
type MinioCfg struct {
|
||||
Endpoint string `yaml:"endpoint" env:"MINIO_ENDPOINT"`
|
||||
Region string `yaml:"region" env:"MINIO_REGION"`
|
||||
AccessKey string `yaml:"accessKey" env:"MINIO_ACCESSKEY"`
|
||||
SecretKey string `yaml:"secretKey" env:"MINIO_SECRETKEY"`
|
||||
UseSsl bool `yaml:"useSsl" env:"MINIO_USESSL"`
|
||||
BucketName []string `yaml:"bucketName" env:"MINIO_BUCKETNAME"`
|
||||
}
|
||||
|
||||
type ResponsePostPolicy struct {
|
||||
Url string `json:"url"`
|
||||
FormData map[string]string `json:"form-data"`
|
||||
}
|
||||
|
||||
func (c MinioCfg) GetRegion() string {
|
||||
return c.Region
|
||||
}
|
||||
|
||||
func (c MinioCfg) GetEndpoint() string {
|
||||
return c.Endpoint
|
||||
}
|
||||
|
||||
func (c MinioCfg) GetUseSsl() bool {
|
||||
return c.UseSsl
|
||||
}
|
||||
|
||||
func (c MinioCfg) GetBucketName() []string {
|
||||
return c.BucketName
|
||||
}
|
||||
|
||||
// connect db
|
||||
func Connect() {
|
||||
// Memastikan data konfigurasi dari file .env teraplikasikan
|
||||
if O.Endpoint == "" && os.Getenv("MINIO_ENDPOINT") != "" {
|
||||
O.Endpoint = os.Getenv("MINIO_ENDPOINT")
|
||||
}
|
||||
if O.AccessKey == "" && os.Getenv("MINIO_ACCESSKEY") != "" {
|
||||
O.AccessKey = os.Getenv("MINIO_ACCESSKEY")
|
||||
}
|
||||
if O.SecretKey == "" && os.Getenv("MINIO_SECRETKEY") != "" {
|
||||
O.SecretKey = os.Getenv("MINIO_SECRETKEY")
|
||||
}
|
||||
if O.Region == "" && os.Getenv("MINIO_REGION") != "" {
|
||||
O.Region = os.Getenv("MINIO_REGION")
|
||||
}
|
||||
if os.Getenv("MINIO_USESSL") == "true" {
|
||||
O.UseSsl = true
|
||||
}
|
||||
|
||||
err := NewClient(&O)
|
||||
if err != nil {
|
||||
panic("minio client initialization failed: " + err.Error())
|
||||
}
|
||||
if I == nil {
|
||||
panic("minio client is nil")
|
||||
}
|
||||
logs.I.Println("Instantiation for object storage service using Minio, status: DONE!!")
|
||||
}
|
||||
|
||||
func NewClient(cfg *MinioCfg) error {
|
||||
// Initialize minio client object.
|
||||
endpoint := cfg.Endpoint
|
||||
|
||||
// minio-go SDK requires endpoint without http:// or https:// scheme
|
||||
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||
|
||||
if endpoint == "" {
|
||||
return errors.New("config minio endpoint empty")
|
||||
}
|
||||
accessKey := cfg.AccessKey
|
||||
if accessKey == "" {
|
||||
return errors.New("config minio access key empty")
|
||||
}
|
||||
secretKey := cfg.SecretKey
|
||||
if secretKey == "" {
|
||||
return errors.New("config minio secret key empty")
|
||||
}
|
||||
useSSL := cfg.UseSsl
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
I = minioClient
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
# 📚 Dokumentasi API Satu Sehat (Internal GoPrint)
|
||||
|
||||
**Base URL**: `http://localhost:8080/api/v1/satusehat` *(sesuaikan port dengan environment Anda)*
|
||||
**Otorisasi**: Membutuhkan Header `Authorization: Bearer <token_internal_aplikasi_anda>`.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Pendahuluan
|
||||
|
||||
Dokumen ini menjelaskan cara menggunakan API internal GoPrint yang berfungsi sebagai *proxy* dan *builder* untuk layanan **Satu Sehat Kemenkes**. API ini menyederhanakan *payload* dan mengelola otorisasi secara otomatis.
|
||||
|
||||
### Struktur Endpoint
|
||||
Setiap modul sumber daya (misal: `Patient`, `Encounter`) memiliki 5 endpoint standar:
|
||||
1. `POST /<resource>`: Membuat data baru.
|
||||
2. `GET /<resource>?param=value`: Mencari data berdasarkan parameter.
|
||||
3. `GET /<resource>/:id`: Mengambil detail data berdasarkan ID Kemenkes.
|
||||
4. `PUT /<resource>/:id`: Memperbarui data secara utuh (wajib menyertakan *payload* lengkap).
|
||||
5. `PATCH /<resource>/:id`: Memperbarui sebagian data menggunakan format *JSON Patch* (RFC 6902).
|
||||
|
||||
### Format Respons Error
|
||||
Jika terjadi kegagalan, API akan mengembalikan respons dengan format berikut:
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"status": "error",
|
||||
"message": "Pesan error utama yang mudah dibaca",
|
||||
"data": "Detail teknis error dari Kemenkes atau validasi internal"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Auth (Autentikasi API Kemenkes)
|
||||
Modul ini digunakan untuk mendapatkan status kredensial dan token aktif dari server Kemenkes. *(Catatan: Request internal API Anda sebenarnya sudah otomatis dibungkus dengan token oleh middleware atau `SatuSehatClient` internal)*.
|
||||
|
||||
#### Mengambil Token Aktif (`GET /auth/token`)
|
||||
Mendapatkan *Access Token* OAuth2 yang di-*generate* menggunakan *Client ID* dan *Client Secret* dari konfigurasi *environment* aplikasi.
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||
"expires_in": 3599
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## � Patient (Pasien)
|
||||
Mendaftarkan dan mengelola data demografi pasien.
|
||||
|
||||
#### Membuat Data (`POST /patient`)
|
||||
```json
|
||||
{
|
||||
"name": "BUDI SANTOSO",
|
||||
"nik": "3573012345678901",
|
||||
"ihs_number": "P0123456789",
|
||||
"gender": "male",
|
||||
"birth_date": "1990-12-31",
|
||||
"phone": "08123456789",
|
||||
"email": "[email protected]",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /patient`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `identifier`: Cari berdasarkan NIK atau IHS Number. Contoh: `?identifier=https://fhir.kemkes.go.id/id/nik|3573012345678901`
|
||||
- `name`: Cari berdasarkan nama pasien.
|
||||
- `birthdate`: Cari berdasarkan tanggal lahir (YYYY-MM-DD).
|
||||
- `gender`: Cari berdasarkan jenis kelamin (`male`, `female`, `other`, `unknown`).
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /patient/:id`)**: Mengambil detail pasien berdasarkan ID IHS yang didapat saat pembuatan.
|
||||
- **Memperbarui Data (`PUT /patient/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST` untuk memperbarui data.
|
||||
- **Memperbarui Sebagian (`PATCH /patient/:id`)**: Menggunakan format JSON Patch untuk mengubah data tertentu. Lihat panduan di akhir dokumen.
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Organization (Organisasi)
|
||||
Mengelola data referensi organisasi/faskes.
|
||||
|
||||
#### Membuat Data (`POST /organization`)
|
||||
```json
|
||||
{
|
||||
"active": true,
|
||||
"type_code": "prov",
|
||||
"type_display": "Healthcare Provider",
|
||||
"identifier_system": "http://sys-ids.kemkes.go.id/organization/10000004",
|
||||
"identifier_value": "10000004",
|
||||
"name": "RSUP Dr. Cipto Mangunkusumo",
|
||||
"phone": "021-1500135",
|
||||
"email": "[email protected]",
|
||||
"url": "https://www.rscm.co.id/",
|
||||
"address": "Jl. Diponegoro No.71, RW.5, Kenari, Kec. Senen, Kota Jakarta Pusat",
|
||||
"city": "Jakarta Pusat",
|
||||
"postal_code": "10430",
|
||||
"country_code": "ID",
|
||||
"part_of_id": "1000000"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /organization`)
|
||||
Parameter pencarian yang didukung (wajib salah satu):
|
||||
- `name`: Cari berdasarkan nama organisasi.
|
||||
- `partof`: Cari berdasarkan ID organisasi induk.
|
||||
- `identifier`: Cari berdasarkan identifier unik faskes.
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /organization/:id`)**: Mengambil detail organisasi berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /organization/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /organization/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 👨⚕️ Practitioner (Tenaga Kesehatan)
|
||||
Mengambil dan mengelola data profil tenaga kesehatan (Dokter, Perawat, Bidan, dll).
|
||||
|
||||
#### Membuat Data (`POST /practitioner`)
|
||||
```json
|
||||
{
|
||||
"nik": "3301234567890123",
|
||||
"name": "Dr. Siti Aminah",
|
||||
"gender": "female",
|
||||
"birth_date": "1985-05-15",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /practitioner`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `identifier`: Cari berdasarkan NIK. Contoh: `?identifier=https://fhir.kemkes.go.id/id/nik|3301234567890123`
|
||||
- `name`: Cari berdasarkan nama tenaga kesehatan.
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /practitioner/:id`)**: Mengambil detail Nakes berdasarkan ID IHS Kemenkes.
|
||||
- **Memperbarui Data (`PUT /practitioner/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /practitioner/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 📍 Location (Lokasi / Ruangan)
|
||||
Mencatat data referensi lokasi fisik pelayanan di dalam Faskes (misal: Poli, Bangsal, Kamar, Bed).
|
||||
|
||||
#### Membuat Data (`POST /location`)
|
||||
```json
|
||||
{
|
||||
"identifier_system": "http://sys-ids.kemkes.go.id/location/10000004",
|
||||
"identifier_value": "LOK-001",
|
||||
"status": "active",
|
||||
"name": "Poli Penyakit Dalam",
|
||||
"description": "Poliklinik Penyakit Dalam Gedung A",
|
||||
"physical_type_code": "ro",
|
||||
"physical_type_display": "Room",
|
||||
"managing_organization_id": "10000004",
|
||||
"part_of_id": "b017aa54-f1df-4ec2-9d84-8823815d7228"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /location`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `identifier`: Cari berdasarkan identifier unik lokasi internal faskes.
|
||||
- `name`: Cari berdasarkan nama lokasi.
|
||||
- `organization`: Cari berdasarkan ID Organisasi (faskes) pengelola.
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /location/:id`)**: Mengambil detail lokasi berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /location/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /location/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 KFA (Kamus Farmasi dan Alat Kesehatan)
|
||||
Pencarian data master obat dan alat kesehatan langsung dari database KFA v2 Kemenkes (terpisah dari base FHIR).
|
||||
|
||||
#### Mencari Data Master KFA (`GET /kfa/products`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `identifier`: Cari berdasarkan kode KFA produk.
|
||||
- `keyword`: Cari berdasarkan nama obat / alat kesehatan.
|
||||
- `product_type`: Filter tipe produk (misal: `farmasi`, `alkes`).
|
||||
|
||||
---
|
||||
|
||||
## 🏥 Encounter (Kunjungan)
|
||||
Mencatat kunjungan pasien ke faskes.
|
||||
|
||||
#### Membuat Data (`POST /encounter`)
|
||||
```json
|
||||
{
|
||||
"encounter_id": "KUNJ-001",
|
||||
"organization_id": "10000004",
|
||||
"patient_id": "P0123456789",
|
||||
"patient_name": "Budi Santoso",
|
||||
"practitioner_id": "N10000001",
|
||||
"practitioner_name": "Dr. Siti Aminah",
|
||||
"location_id": "b017aa54-f1df-4ec2-9d84-8823815d7228",
|
||||
"location_name": "Poli Umum",
|
||||
"status": "arrived",
|
||||
"class": "AMB",
|
||||
"period_start": "2023-10-12T08:00:00+07:00"
|
||||
}
|
||||
```
|
||||
*Status yang valid: `planned`, `arrived`, `in-progress`, `finished`, `cancelled`.*
|
||||
|
||||
#### Mencari Data (`GET /encounter`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `patient`: Berdasarkan ID Pasien.
|
||||
- `status`: Berdasarkan status kunjungan.
|
||||
- `date`: Berdasarkan tanggal kunjungan (YYYY-MM-DD).
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /encounter/:id`)**: Mengambil detail kunjungan berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /encounter/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /encounter/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 🤒 Condition (Diagnosis)
|
||||
Mencatat diagnosis penyakit pasien dalam sebuah kunjungan.
|
||||
|
||||
#### Membuat Data (`POST /condition`)
|
||||
```json
|
||||
{
|
||||
"patient_id": "P0123456789",
|
||||
"patient_name": "Budi Santoso",
|
||||
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
|
||||
"clinical_status": "active",
|
||||
"category_code": "encounter-diagnosis",
|
||||
"category_display": "Encounter Diagnosis",
|
||||
"code": "J06.9",
|
||||
"display": "Acute upper respiratory infection, unspecified",
|
||||
"onset_date_time": "2023-10-12T08:15:00+07:00",
|
||||
"recorded_date": "2023-10-12T08:20:00+07:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /condition`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `patient`: Berdasarkan ID Pasien.
|
||||
- `encounter`: Berdasarkan ID Kunjungan.
|
||||
- `code`: Berdasarkan kode diagnosis (ICD-10).
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /condition/:id`)**: Mengambil detail diagnosis berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /condition/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /condition/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 🩺 Observation (Tanda Vital)
|
||||
Mencatat hasil pemeriksaan fisik atau lab.
|
||||
|
||||
#### Membuat Data (`POST /observation`)
|
||||
```json
|
||||
{
|
||||
"patient_id": "P0123456789",
|
||||
"patient_name": "Budi Santoso",
|
||||
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
|
||||
"status": "final",
|
||||
"category_code": "vital-signs",
|
||||
"category_display": "Vital Signs",
|
||||
"code": "8867-4",
|
||||
"display": "Heart rate",
|
||||
"value": 85,
|
||||
"unit": "beats/minute",
|
||||
"unit_code": "/min",
|
||||
"effective_date_time": "2023-10-12T08:10:00+07:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /observation`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `patient`: Berdasarkan ID Pasien.
|
||||
- `encounter`: Berdasarkan ID Kunjungan.
|
||||
- `code`: Berdasarkan kode observasi (LOINC).
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /observation/:id`)**: Mengambil detail observasi berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /observation/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /observation/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 💊 Medication (Obat - Master KFA)
|
||||
Membuat *resource* referensi obat berdasarkan Kamus Farmasi dan Alat Kesehatan (KFA).
|
||||
|
||||
#### Membuat Data (`POST /medication`)
|
||||
```json
|
||||
{
|
||||
"status_code": "active",
|
||||
"kfa_code": "93000940",
|
||||
"kfa_display": "Paracetamol 500 mg Tablet",
|
||||
"form_code": "BS019",
|
||||
"form_display": "Tablet",
|
||||
"manufacturer_id": "10000004",
|
||||
"batch_number": "BATCH12345",
|
||||
"expiration_date": "2025-12-31T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /medication`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `code`: Berdasarkan kode KFA.
|
||||
- `manufacturer`: Berdasarkan ID Organisasi manufaktur.
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /medication/:id`)**: Mengambil detail obat berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /medication/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /medication/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 📅 EpisodeOfCare (Episode Perawatan)
|
||||
Mengaitkan serangkaian *Encounter* yang berkaitan dengan masalah medis yang sama.
|
||||
|
||||
#### Membuat Data (`POST /episodeofcare`)
|
||||
```json
|
||||
{
|
||||
"patient_id": "P0123456789",
|
||||
"patient_name": "Budi Santoso",
|
||||
"organization_id": "10000004",
|
||||
"status": "active",
|
||||
"period_start": "2023-10-12T08:00:00+07:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Mencari Data (`GET /episodeofcare`)
|
||||
Parameter pencarian yang didukung:
|
||||
- `patient`: Berdasarkan ID Pasien.
|
||||
- `status`: Berdasarkan status episode (`planned`, `waitlist`, `active`, `onhold`, `finished`, `cancelled`).
|
||||
|
||||
#### Operasi Lainnya
|
||||
- **Mengambil Data (`GET /episodeofcare/:id`)**: Mengambil detail episode berdasarkan ID Kemenkes.
|
||||
- **Memperbarui Data (`PUT /episodeofcare/:id`)**: Mengirimkan kembali *payload* lengkap seperti `POST`.
|
||||
- **Memperbarui Sebagian (`PATCH /episodeofcare/:id`)**: Menggunakan format JSON Patch.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Panduan Menggunakan Metode `PATCH`
|
||||
|
||||
API Kemenkes (Satu Sehat) mengharuskan update parsial menggunakan standar spesifikasi *JSON Patch (RFC 6902)*.
|
||||
Format *request body* untuk `PATCH` adalah sebuah *array of objects* yang berisi instruksi perubahan.
|
||||
|
||||
Contoh: Mengubah status sebuah **Encounter** dari `arrived` menjadi `finished`.
|
||||
|
||||
**PATCH** `/encounter/5fa23d13-0943-4318-b295-eb1ecfa7384a`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/status",
|
||||
"value": "finished"
|
||||
}
|
||||
]
|
||||
```
|
||||
*(Ini akan mengirim instruksi ke Kemenkes untuk hanya mengganti parameter status menjadi "finished" tanpa menyentuh data lainnya).*
|
||||
@@ -0,0 +1,125 @@
|
||||
# 🏥 Satu Sehat Interface Library
|
||||
|
||||
Package ini menyediakan abstraksi HTTP Client dan Payload Builder yang sangat fleksibel untuk mempermudah integrasi dengan API Satu Sehat (Kemenkes) berbasis standar HL7 FHIR.
|
||||
|
||||
## 🌟 Fitur Utama
|
||||
|
||||
1. **Auto Auth Management**: Klien ini otomatis me-*request* dan men-*cache* Access Token (OAuth2) di memori. Anda tidak perlu lagi memikirkan urusan *refresh token* yang kedaluwarsa.
|
||||
2. **Dynamic Endpoint Detection**: Mendukung *routing* dinamis ke `BaseURL` (FHIR), `AuthURL`, `ConsentURL`, maupun `KFAURL`.
|
||||
3. **FHIRPayload Builder**: Pembuat JSON (*Fluent API / Chaining*) dinamis untuk menyusun *request* spesifikasi FHIR tanpa perlu membuat *struct* raksasa yang kaku.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Panduan Penggunaan Klien API
|
||||
|
||||
Klien API dapat di-*inject* melalui *dependency injection* di `main.go`. Klien ini otomatis menempelkan `Authorization: Bearer <token>` pada setiap *request*.
|
||||
|
||||
```go
|
||||
// Contoh pemanggilan POST ke endpoint /Patient
|
||||
func (r *patientRepository) CreatePatient(ctx context.Context, payload interface{}) ([]byte, error) {
|
||||
// Gunakan DoRequest untuk Base FHIR (BaseURL)
|
||||
response, err := r.satuSehatClient.DoRequest(ctx, "POST", "/Patient", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Panduan Penggunaan `FHIRPayload` (JSON Builder)
|
||||
|
||||
Standar data FHIR banyak menggunakan *array of objects* (`identifier`, `name`, `telecom`, `address`, dll). Menggunakan `struct` Go konvensional akan membuat *codebase* sangat kotor dan tidak fleksibel terhadap perubahan versi FHIR.
|
||||
|
||||
Gunakan `FHIRPayload` untuk merakit JSON *on-the-fly*!
|
||||
|
||||
### 1. Inisialisasi Resource
|
||||
Gunakan `NewFHIRPayload` untuk memulai pembuatan resource. Fungsi ini mewajibkan parameter `resourceType`.
|
||||
|
||||
```go
|
||||
payload := satusehat.NewFHIRPayload("Patient")
|
||||
```
|
||||
|
||||
### 2. Menggunakan `.Set()` (Menyimpan Nilai Tunggal)
|
||||
`.Set()` digunakan untuk atribut *single value* seperti `active`, `gender`, `birthDate`, dll.
|
||||
|
||||
```go
|
||||
payload.
|
||||
Set("active", true).
|
||||
Set("gender", "male").
|
||||
Set("birthDate", "1990-01-01")
|
||||
```
|
||||
|
||||
### 3. Menggunakan `.Append()` (Menyimpan Array Objek)
|
||||
`.Append()` adalah "senjata utama" untuk FHIR. Atribut seperti NIK, Paspor, No HP, Email, biasanya bertipe *array* di FHIR. Jika *key* (misal: `identifier`) belum ada, `.Append` otomatis membuatnya. Jika sudah ada, ia akan menambahkannya ke urutan berikutnya.
|
||||
|
||||
```go
|
||||
payload.
|
||||
// Menambahkan NIK ke dalam array identifier
|
||||
Append("identifier", map[string]interface{}{
|
||||
"use": "official",
|
||||
"system": "https://fhir.kemkes.go.id/id/nik",
|
||||
"value": "3573012345678901",
|
||||
}).
|
||||
// Menambahkan NRM (Nomor Rekam Medis) ke array identifier yang sama
|
||||
Append("identifier", map[string]interface{}{
|
||||
"use": "usual",
|
||||
"system": "https://fhir.kemkes.go.id/id/ihs-number",
|
||||
"value": "P0123456789",
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Realisasi Kode (Full Example)
|
||||
|
||||
Berikut adalah contoh nyata bagaimana Anda menyusun Payload Pendaftaran Pasien dan langsung mengirimkannya via API Client:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"service/internal/interfaces/satusehat"
|
||||
)
|
||||
|
||||
func RegisterPatientExample(ctx context.Context, client satusehat.SatuSehatClient) {
|
||||
// 1. Merakit JSON FHIR Payload dengan gaya Chaining (Builder)
|
||||
payload := satusehat.NewFHIRPayload("Patient").
|
||||
Set("active", true).
|
||||
Append("identifier", map[string]interface{}{
|
||||
"use": "official",
|
||||
"system": "https://fhir.kemkes.go.id/id/nik",
|
||||
"value": "3573012345678901",
|
||||
}).
|
||||
Append("name", map[string]interface{}{
|
||||
"use": "official",
|
||||
"text": "BUDI SANTOSO",
|
||||
}).
|
||||
Append("telecom", map[string]interface{}{
|
||||
"system": "phone",
|
||||
"value": "08123456789",
|
||||
"use": "mobile",
|
||||
}).
|
||||
Append("telecom", map[string]interface{}{
|
||||
"system": "email",
|
||||
"value": "[email protected]",
|
||||
"use": "home",
|
||||
}).
|
||||
Set("gender", "male").
|
||||
Set("birthDate", "1990-12-31")
|
||||
|
||||
// 2. Kirim ke Satu Sehat Kemenkes (Secara otomatis token Bearer disisipkan)
|
||||
responseBody, err := client.DoRequest(ctx, "POST", "/Patient", payload)
|
||||
if err != nil {
|
||||
log.Printf("Gagal mendaftarkan pasien: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Sukses mendaftarkan pasien! Response Kemenkes: %s\n", string(responseBody))
|
||||
}
|
||||
```
|
||||
|
||||
Dengan fitur *chaining* ini, *service* Anda akan bebas dari deklarasi `struct` yang panjangnya bisa ratusan baris, dan penulisan kode akan jauh lebih bersih dan mudah dibaca!
|
||||
@@ -0,0 +1,274 @@
|
||||
package satusehat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"service/internal/infrastructure/config"
|
||||
"service/pkg/logger"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
cfg config.SatuSehatConfig
|
||||
httpClient *http.Client
|
||||
tokenData map[string]interface{}
|
||||
tokenExpiry time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSatuSehatClient membuat instance baru dari SatuSehatClient.
|
||||
func NewSatuSehatClient(cfg config.SatuSehatConfig) SatuSehatClient {
|
||||
return &client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccessToken mengelola request token OAuth2 (Client Credentials) dan melakukan caching di memori.
|
||||
func (c *client) GetAccessToken(ctx context.Context) (map[string]interface{}, error) {
|
||||
c.mu.RLock()
|
||||
// Cek apakah token masih valid (dengan buffer 1 menit untuk mencegah kegagalan)
|
||||
if c.tokenData != nil && time.Now().Before(c.tokenExpiry) {
|
||||
data := c.tokenData
|
||||
c.mu.RUnlock()
|
||||
return data, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Double check setelah mendapat lock
|
||||
if c.tokenData != nil && time.Now().Before(c.tokenExpiry) {
|
||||
return c.tokenData, nil
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", c.cfg.ClientID)
|
||||
data.Set("client_secret", c.cfg.ClientSecret)
|
||||
// Sesuai standar OAuth2, grant_type disisipkan dalam body request
|
||||
data.Set("grant_type", "client_credentials")
|
||||
|
||||
// Deteksi otomatis jika Auth URL di environment hanya berupa Base (tanpa /accesstoken)
|
||||
authURL := strings.TrimRight(c.cfg.AuthURL, "/")
|
||||
if !strings.HasSuffix(authURL, "accesstoken") {
|
||||
authURL += "/accesstoken"
|
||||
}
|
||||
// Kemenkes juga sering mensyaratkan query parameter grant_type di URL
|
||||
if !strings.Contains(authURL, "grant_type") {
|
||||
authURL += "?grant_type=client_credentials"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Baca terlebih dahulu seluruh response body untuk memudahkan debugging jika gagal parse JSON
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read auth response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Default().Error("SatuSehat Auth Error", logger.Int("status", resp.StatusCode), logger.String("response", string(respBody)))
|
||||
return nil, fmt.Errorf("SatuSehat Auth failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
logger.Default().Error("SatuSehat Auth Decode Error", logger.String("response", string(respBody)), logger.ErrorField(err))
|
||||
return nil, fmt.Errorf("failed to decode auth response: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := result["access_token"].(string); !ok {
|
||||
return nil, fmt.Errorf("access_token missing or invalid in response: %s", string(respBody))
|
||||
}
|
||||
|
||||
c.tokenData = result
|
||||
|
||||
var expiresIn int
|
||||
if expRaw, ok := result["expires_in"]; ok {
|
||||
switch v := expRaw.(type) {
|
||||
case string:
|
||||
expiresIn, _ = strconv.Atoi(v)
|
||||
case float64: // Pada interface{}, JSON number dibaca sebagai float64 oleh Go
|
||||
expiresIn = int(v)
|
||||
default:
|
||||
expiresIn = 3599 // Fallback default 1 jam jika atribut tidak ada/tidak sesuai
|
||||
}
|
||||
} else {
|
||||
expiresIn = 3599
|
||||
}
|
||||
|
||||
// Simpan masa berlaku token dikurangi 60 detik sebagai safety margin
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
|
||||
|
||||
logger.Default().Info("🔑 SatuSehat Access Token refreshed successfully")
|
||||
|
||||
return c.tokenData, nil
|
||||
}
|
||||
|
||||
// RefreshToken memaksa pengambilan token baru (melewati cache).
|
||||
func (c *client) RefreshToken(ctx context.Context) (map[string]interface{}, error) {
|
||||
c.mu.Lock()
|
||||
c.tokenData = nil // Invalidasi cache token
|
||||
c.mu.Unlock()
|
||||
return c.GetAccessToken(ctx)
|
||||
}
|
||||
|
||||
// do merupakan helper HTTP request secara general ke berbagai Base URL SatuSehat
|
||||
func (c *client) do(ctx context.Context, baseURL, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return nil, fmt.Errorf("SatuSehat integration is disabled in configuration")
|
||||
}
|
||||
|
||||
authData, err := c.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get SatuSehat access token: %w", err)
|
||||
}
|
||||
token, ok := authData["access_token"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("access token is missing in auth data")
|
||||
}
|
||||
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
endpointPath := strings.TrimLeft(endpoint, "/")
|
||||
targetURL := fmt.Sprintf("%s/%s", baseURL, endpointPath)
|
||||
|
||||
// Marshal body terlebih dahulu agar bisa digunakan berulang kali jika butuh retry
|
||||
var jsonBody []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
jsonBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function untuk merakit ulang request (sangat berguna untuk skenario Retry)
|
||||
buildReq := func(accessToken string) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
if jsonBody != nil {
|
||||
bodyReader = bytes.NewBuffer(jsonBody)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if jsonBody != nil {
|
||||
if method == "PATCH" {
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
req, err := buildReq(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// [RADAR] Logging Outgoing Request
|
||||
logger.Default().Info("🛫 SatuSehat API Request Sent",
|
||||
logger.String("target_url", targetURL),
|
||||
logger.String("method", method),
|
||||
)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http request failed: %w", err)
|
||||
}
|
||||
|
||||
// [AUTO-RETRY LOGIC] Jika Kemenkes menjawab 401 Unauthorized, token mungkin expired/revoked
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Tutup body request pertama yang gagal agar tidak memory leak
|
||||
resp.Body.Close()
|
||||
|
||||
logger.Default().Warn("🔄 Token Satu Sehat ditolak (401). Melakukan Auto-Refresh dan mengulang request...")
|
||||
|
||||
// 1. Refresh Token secara paksa
|
||||
authData, err = c.RefreshToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-refresh token for retry: %w", err)
|
||||
}
|
||||
token, ok = authData["access_token"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("access token is missing after auto-refresh")
|
||||
}
|
||||
|
||||
// 2. Bangun ulang Request dengan token terbaru
|
||||
req, err = buildReq(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to rebuild retry request: %w", err)
|
||||
}
|
||||
|
||||
// 3. Tembak ulang API Kemenkes
|
||||
resp, err = c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http retry request failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pastikan body response dari eksekusi terakhir (berhasil atau retry) selalu ditutup
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Tangani response status code error (seperti FHIROperationOutcome)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
logger.Default().Error("SatuSehat API Error",
|
||||
logger.Int("status_code", resp.StatusCode),
|
||||
logger.String("response", string(respBody)),
|
||||
)
|
||||
|
||||
// Coba parse response body sebagai FHIR OperationOutcome untuk error yang lebih terbaca
|
||||
var outcome map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &outcome); err == nil && outcome["resourceType"] == "OperationOutcome" {
|
||||
return nil, &ErrorOperationOutcome{
|
||||
StatusCode: resp.StatusCode,
|
||||
Outcome: outcome,
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("SatuSehat API Error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *client) DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
return c.do(ctx, c.cfg.BaseURL, method, endpoint, body)
|
||||
}
|
||||
|
||||
func (c *client) DoKFA(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
return c.do(ctx, c.cfg.KFAURL, method, endpoint, body)
|
||||
}
|
||||
|
||||
func (c *client) DoConsent(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
|
||||
return c.do(ctx, c.cfg.ConsentURL, method, endpoint, body)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package satusehat
|
||||
|
||||
import (
|
||||
"service/internal/infrastructure/config"
|
||||
)
|
||||
|
||||
// Factory menyediakan akses tersentralisasi ke HTTP Client SatuSehat.
|
||||
type Factory struct {
|
||||
baseConfig config.SatuSehatConfig
|
||||
}
|
||||
|
||||
// NewSatuSehatFactory membuat instance factory baru untuk SatuSehat.
|
||||
func NewSatuSehatFactory(cfg config.SatuSehatConfig) *Factory {
|
||||
return &Factory{
|
||||
baseConfig: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Client mengembalikan instance utama dari SatuSehatClient.
|
||||
// Jika kelak dibutuhkan client dengan konfigurasi khusus atau environment berbeda,
|
||||
// pembuatannya dapat dipusatkan di Factory ini.
|
||||
func (f *Factory) Client() SatuSehatClient {
|
||||
return NewSatuSehatClient(f.baseConfig)
|
||||
}
|
||||
+25214
File diff suppressed because one or more lines are too long
Executable
+28200
File diff suppressed because one or more lines are too long
Executable
+29195
File diff suppressed because one or more lines are too long
+24621
File diff suppressed because one or more lines are too long
Executable
+29742
File diff suppressed because one or more lines are too long
+54764
File diff suppressed because one or more lines are too long
+11640
File diff suppressed because one or more lines are too long
+8232
File diff suppressed because one or more lines are too long
Executable
+40628
File diff suppressed because one or more lines are too long
+42985
File diff suppressed because one or more lines are too long
+18610
File diff suppressed because one or more lines are too long
Executable
+6559
File diff suppressed because one or more lines are too long
Executable
+5090
File diff suppressed because one or more lines are too long
+33334
File diff suppressed because one or more lines are too long
Executable
+7656
File diff suppressed because one or more lines are too long
+9143
File diff suppressed because one or more lines are too long
internal/interfaces/satusehat/json_postman/18. Use Case - Imunisasi Covid 19.postman_collection.json
Executable
+3147
File diff suppressed because one or more lines are too long
Executable
+56473
File diff suppressed because one or more lines are too long
Executable
+56473
File diff suppressed because one or more lines are too long
Executable
+14401
File diff suppressed because one or more lines are too long
internal/interfaces/satusehat/json_postman/21. Use Case - Registrasi Jantung.postman_collection.json
Executable
+175950
File diff suppressed because one or more lines are too long
Executable
+103959
File diff suppressed because one or more lines are too long
+74356
File diff suppressed because one or more lines are too long
+10888
File diff suppressed because one or more lines are too long
+24077
File diff suppressed because one or more lines are too long
internal/interfaces/satusehat/json_postman/26. Use Case - Modul Klaim (BPJS).postman_collection.json
Executable
+86746
File diff suppressed because one or more lines are too long
Executable
+11056
File diff suppressed because one or more lines are too long
Executable
+73973
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"code": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "93001733",
|
||||
"display": "Lidocain Hydrochloride 20 mg/mL Injeksi (PHAPROS)",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extension": [
|
||||
{
|
||||
"url": "https://fhir.kemkes.go.id/r4/StructureDefinition/MedicationType",
|
||||
"valueCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "NC",
|
||||
"display": "Non-compound",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"form": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "BS034",
|
||||
"display": "Larutan Injeksi",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-form"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "f8fb8eb1-5e4f-4fdc-84fc-b5b0fe8a6c31",
|
||||
"identifier": [
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/medication/10000002",
|
||||
"use": "official",
|
||||
"value": "666735"
|
||||
}
|
||||
],
|
||||
"ingredient": [
|
||||
{
|
||||
"isActive": true,
|
||||
"itemCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "91000286",
|
||||
"display": "LIDOCAINE",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strength": {
|
||||
"denominator": {
|
||||
"code": "413516001",
|
||||
"system": "http://snomed.info/sct",
|
||||
"unit": "Ampule - unit of product usage",
|
||||
"value": 1
|
||||
},
|
||||
"numerator": {
|
||||
"code": "mg/mL",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"value": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"manufacturer": {
|
||||
"reference": "Organization/900001"
|
||||
},
|
||||
"meta": {
|
||||
"lastUpdated": "2023-12-12T02:44:32.858359+00:00",
|
||||
"profile": [
|
||||
"https://fhir.kemkes.go.id/r4/StructureDefinition/Medication"
|
||||
],
|
||||
"versionId": "MTcwMjM0OTA3Mjg1ODM1OTAwMA"
|
||||
},
|
||||
"resourceType": "Medication",
|
||||
"status": "active"
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"authoredOn": "2023-11-13T03:50:00+00:00",
|
||||
"category": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"code": "community",
|
||||
"display": "Community",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/medicationrequest-category"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"contained": [
|
||||
{
|
||||
"code": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "93006334",
|
||||
"display": "Paracetamol 500 mg Tablet (INDOFARMA)",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extension": [
|
||||
{
|
||||
"url": "https://fhir.kemkes.go.id/r4/StructureDefinition/MedicationType",
|
||||
"valueCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "NC",
|
||||
"display": "Non-compound",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"form": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "BS066",
|
||||
"display": "Tablet",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-form"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "2024120387418-001",
|
||||
"identifier": [
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/medication/10000005",
|
||||
"use": "official",
|
||||
"value": "PCT001"
|
||||
}
|
||||
],
|
||||
"ingredient": [
|
||||
{
|
||||
"isActive": true,
|
||||
"itemCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "91000101",
|
||||
"display": "Paracetamol",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strength": {
|
||||
"denominator": {
|
||||
"code": "TAB",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"unit": "Tablet",
|
||||
"value": 1
|
||||
},
|
||||
"numerator": {
|
||||
"code": "mg",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"value": 500
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"manufacturer": {
|
||||
"reference": "Organization/10000004"
|
||||
},
|
||||
"meta": {
|
||||
"profile": [
|
||||
"https://fhir.kemkes.go.id/r4/StructureDefinition/Medication"
|
||||
]
|
||||
},
|
||||
"resourceType": "Medication",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"dispenseRequest": {
|
||||
"dispenseInterval": {
|
||||
"code": "d",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"unit": "days",
|
||||
"value": 1
|
||||
},
|
||||
"expectedSupplyDuration": {
|
||||
"code": "d",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"unit": "days",
|
||||
"value": 5
|
||||
},
|
||||
"numberOfRepeatsAllowed": 1,
|
||||
"performer": {
|
||||
"reference": "Organization/10000005"
|
||||
},
|
||||
"quantity": {
|
||||
"code": "TAB",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"unit": "TAB",
|
||||
"value": 10
|
||||
},
|
||||
"validityPeriod": {
|
||||
"end": "2023-11-15T04:01:00+00:00",
|
||||
"start": "2023-11-15T04:01:00+00:00"
|
||||
}
|
||||
},
|
||||
"dosageInstruction": [
|
||||
{
|
||||
"additionalInstruction": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"code": "418850000",
|
||||
"display": "Contains aspirin and paracetamol. Do not take with any other paracetamol products",
|
||||
"system": "http://snomed.info/sct"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"doseAndRate": [
|
||||
{
|
||||
"doseQuantity": {
|
||||
"code": "TAB",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"unit": "TAB",
|
||||
"value": 1
|
||||
},
|
||||
"type": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "ordered",
|
||||
"display": "Ordered",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/dose-rate-type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"patientInstruction": "Diminum 3x sehari jika Demam, jangan diminum bersamaan dengan produk paracetamol lainnya",
|
||||
"route": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "O",
|
||||
"display": "Oral",
|
||||
"system": "http://www.whocc.no/atc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sequence": 1,
|
||||
"timing": {
|
||||
"repeat": {
|
||||
"frequency": 1,
|
||||
"period": 8,
|
||||
"periodUnit": "h",
|
||||
"when": [
|
||||
"PC"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"encounter": {
|
||||
"reference": "Encounter/2cf6e058-51d2-4cb2-a353-de9d3b032931"
|
||||
},
|
||||
"id": "1140eac3-84b2-40f0-8735-69b80b8091c0",
|
||||
"identifier": [
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/prescription/10000005",
|
||||
"use": "official",
|
||||
"value": "A00000111222"
|
||||
},
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/prescription-item/10000005",
|
||||
"use": "official",
|
||||
"value": "A00000111222-1"
|
||||
}
|
||||
],
|
||||
"intent": "order",
|
||||
"medicationReference": {
|
||||
"reference": "#2024120387418-001"
|
||||
},
|
||||
"meta": {
|
||||
"lastUpdated": "2024-12-03T08:44:29.797707+00:00",
|
||||
"versionId": "MTczMzIxNTQ2OTc5NzcwNzAwMA"
|
||||
},
|
||||
"priority": "routine",
|
||||
"reasonReference": [
|
||||
{
|
||||
"display": "Demam",
|
||||
"reference": "Condition/ae473b50-cad7-4e4b-8714-aee6ea9b0fa5"
|
||||
}
|
||||
],
|
||||
"requester": {
|
||||
"display": "dr. Alexander",
|
||||
"reference": "Practitioner/10009880728"
|
||||
},
|
||||
"resourceType": "MedicationRequest",
|
||||
"status": "completed",
|
||||
"subject": {
|
||||
"display": "Ghina Assyifa",
|
||||
"reference": "Patient/P01654557057"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"authoredOn": "2023-11-13T03:50:00+00:00",
|
||||
"category": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"code": "community",
|
||||
"display": "Community",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/medicationrequest-category"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"contained": [
|
||||
{
|
||||
"extension": [
|
||||
{
|
||||
"url": "https://fhir.kemkes.go.id/r4/StructureDefinition/MedicationType",
|
||||
"valueCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "EP",
|
||||
"display": "Divide into equal parts",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"form": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "BS019",
|
||||
"display": "Kapsul",
|
||||
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-form"
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": "2024120387418-006",
|
||||
"identifier": [
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/medication/10000005",
|
||||
"use": "official",
|
||||
"value": "AB001"
|
||||
}
|
||||
],
|
||||
"ingredient": [
|
||||
{
|
||||
"isActive": true,
|
||||
"itemCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "91000101",
|
||||
"display": "Paracetamol",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strength": {
|
||||
"denominator": {
|
||||
"code": "CAP",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"value": 10
|
||||
},
|
||||
"numerator": {
|
||||
"code": "mg",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"value": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"isActive": true,
|
||||
"itemCodeableConcept": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "91000861",
|
||||
"display": "Chlorphenamine Maleate",
|
||||
"system": "http://sys-ids.kemkes.go.id/kfa"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strength": {
|
||||
"denominator": {
|
||||
"code": "CAP",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"value": 10
|
||||
},
|
||||
"numerator": {
|
||||
"code": "mg",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"value": 40
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"profile": [
|
||||
"https://fhir.kemkes.go.id/r4/StructureDefinition/Medication"
|
||||
]
|
||||
},
|
||||
"resourceType": "Medication",
|
||||
"status": "active"
|
||||
}
|
||||
],
|
||||
"dispenseRequest": {
|
||||
"dispenseInterval": {
|
||||
"code": "d",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"unit": "days",
|
||||
"value": 1
|
||||
},
|
||||
"expectedSupplyDuration": {
|
||||
"code": "d",
|
||||
"system": "http://unitsofmeasure.org",
|
||||
"unit": "days",
|
||||
"value": 5
|
||||
},
|
||||
"numberOfRepeatsAllowed": 1,
|
||||
"performer": {
|
||||
"reference": "Organization/10000005"
|
||||
},
|
||||
"quantity": {
|
||||
"code": "CAP",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"unit": "CAP",
|
||||
"value": 10
|
||||
},
|
||||
"validityPeriod": {
|
||||
"end": "2023-11-15T04:01:00+00:00",
|
||||
"start": "2023-11-15T04:01:00+00:00"
|
||||
}
|
||||
},
|
||||
"dosageInstruction": [
|
||||
{
|
||||
"additionalInstruction": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"code": "418639000",
|
||||
"display": "Warning. May cause drowsiness",
|
||||
"system": "http://snomed.info/sct"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"code": "418637003",
|
||||
"display": "Do not take with any other paracetamol products",
|
||||
"system": "http://snomed.info/sct"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"doseAndRate": [
|
||||
{
|
||||
"doseQuantity": {
|
||||
"code": "CAP",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
|
||||
"unit": "CAP",
|
||||
"value": 1
|
||||
},
|
||||
"type": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "ordered",
|
||||
"display": "Ordered",
|
||||
"system": "http://terminology.hl7.org/CodeSystem/dose-rate-type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"patientInstruction": "Diminum 2x sehari selama 5 hari, jika sakit kepala",
|
||||
"route": {
|
||||
"coding": [
|
||||
{
|
||||
"code": "O",
|
||||
"display": "Oral",
|
||||
"system": "http://www.whocc.no/atc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sequence": 1,
|
||||
"timing": {
|
||||
"repeat": {
|
||||
"frequency": 2,
|
||||
"period": 1,
|
||||
"periodUnit": "d",
|
||||
"when": [
|
||||
"PC"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"encounter": {
|
||||
"reference": "Encounter/2cf6e058-51d2-4cb2-a353-de9d3b032931"
|
||||
},
|
||||
"id": "a37db74c-5546-470b-b0e6-d80d5da303a2",
|
||||
"identifier": [
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/prescription/10000005",
|
||||
"use": "official",
|
||||
"value": "A00000111111-2"
|
||||
},
|
||||
{
|
||||
"system": "http://sys-ids.kemkes.go.id/prescription-item/10000005",
|
||||
"use": "official",
|
||||
"value": "A00000111111-2"
|
||||
}
|
||||
],
|
||||
"intent": "order",
|
||||
"medicationReference": {
|
||||
"reference": "#2024120387418-006"
|
||||
},
|
||||
"meta": {
|
||||
"lastUpdated": "2024-12-03T09:50:39.401699+00:00",
|
||||
"versionId": "MTczMzIxOTQzOTQwMTY5OTAwMA"
|
||||
},
|
||||
"priority": "routine",
|
||||
"reasonReference": [
|
||||
{
|
||||
"display": "Demam Tifoid",
|
||||
"reference": "Condition/d4265ba3-91ff-4a44-bfb7-ebf31c78efb3"
|
||||
}
|
||||
],
|
||||
"requester": {
|
||||
"display": "dr. Alexander",
|
||||
"reference": "Practitioner/10009880728"
|
||||
},
|
||||
"resourceType": "MedicationRequest",
|
||||
"status": "completed",
|
||||
"subject": {
|
||||
"display": "Ghina Assyifa",
|
||||
"reference": "Patient/P01654557057"
|
||||
}
|
||||
}
|
||||
+8111
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
package satusehat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SatuSehatClient adalah core HTTP client untuk API SatuSehat (Kemenkes).
|
||||
type SatuSehatClient interface {
|
||||
// GetAccessToken mengambil access token aktif (dari memori atau request baru jika kadaluwarsa).
|
||||
GetAccessToken(ctx context.Context) (map[string]interface{}, error)
|
||||
// RefreshToken memaksa pengambilan access token baru dari server Kemenkes.
|
||||
RefreshToken(ctx context.Context) (map[string]interface{}, error)
|
||||
// DoRequest mengirimkan HTTP request ke endpoint utama FHIR (BaseURL).
|
||||
DoRequest(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
|
||||
// DoKFA mengirimkan HTTP request ke endpoint KFA (KFAURL).
|
||||
DoKFA(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
|
||||
// DoConsent mengirimkan HTTP request ke endpoint Consent (ConsentURL).
|
||||
DoConsent(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
|
||||
}
|
||||
|
||||
// FHIRPayload adalah struktur data fleksibel berbentuk map untuk membangun JSON payload FHIR Satu Sehat.
|
||||
type FHIRPayload map[string]interface{}
|
||||
|
||||
// NewFHIRPayload membuat instance FHIRPayload baru dengan `resourceType` yang selalu diwajibkan oleh standar FHIR.
|
||||
func NewFHIRPayload(resourceType string) FHIRPayload {
|
||||
return FHIRPayload{
|
||||
"resourceType": resourceType,
|
||||
}
|
||||
}
|
||||
|
||||
// Set menambahkan atau menimpa nilai (value) pada JSON berdasarkan key.
|
||||
// Menggunakan fluent interface (mengembalikan FHIRPayload) agar bisa di-chain (disambung).
|
||||
func (payload FHIRPayload) Set(key string, value interface{}) FHIRPayload {
|
||||
payload[key] = value
|
||||
return payload
|
||||
}
|
||||
|
||||
// Append menambahkan nilai (value) ke dalam sebuah array JSON berdasarkan key.
|
||||
// Sangat berguna untuk FHIR yang sangat sering menggunakan array (seperti `identifier`, `name`, `telecom`).
|
||||
// Jika key belum ada, ia akan secara otomatis membuat array baru.
|
||||
func (payload FHIRPayload) Append(arrayKey string, value interface{}) FHIRPayload {
|
||||
if existing, ok := payload[arrayKey]; ok {
|
||||
// Jika array sudah ada, tambahkan value baru ke dalamnya
|
||||
if slice, isSlice := existing.([]interface{}); isSlice {
|
||||
payload[arrayKey] = append(slice, value)
|
||||
} else {
|
||||
payload[arrayKey] = []interface{}{existing, value} // Fallback safety
|
||||
}
|
||||
} else {
|
||||
// Jika array belum ada, buat array baru dengan 1 elemen
|
||||
payload[arrayKey] = []interface{}{value}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// ToJSON menghasilkan representasi byte JSON dari payload yang telah dibangun.
|
||||
func (payload FHIRPayload) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// ErrorOperationOutcome merepresentasikan error terstruktur dari API SatuSehat (FHIR OperationOutcome)
|
||||
type ErrorOperationOutcome struct {
|
||||
StatusCode int
|
||||
Outcome map[string]interface{}
|
||||
}
|
||||
|
||||
func (err *ErrorOperationOutcome) Error() string {
|
||||
return fmt.Sprintf("SatuSehat API Error (HTTP %d)", err.StatusCode)
|
||||
}
|
||||
|
||||
// FHIRResponse represents a structured response from the SatuSehat API.
|
||||
type FHIRResponse struct {
|
||||
// The ID of the created or retrieved FHIR resource.
|
||||
ID string `json:"id"`
|
||||
// The full JSON response from the API as a map.
|
||||
FullResponse map[string]interface{} `json:"full_response"`
|
||||
// The raw JSON response from the API as a byte slice.
|
||||
RawResponse []byte `json:"-"` // Ignored by JSON marshalling for client response
|
||||
}
|
||||
Reference in New Issue
Block a user