first commit

This commit is contained in:
meninjar
2026-04-14 01:21:54 +00:00
commit 35c101725f
443 changed files with 1245931 additions and 0 deletions
+177
View File
@@ -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
}
+58
View File
@@ -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)]
}
+334
View File
@@ -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
}
+108
View File
@@ -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)
}
+195
View File
@@ -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"
+173
View File
@@ -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
}
```
+104
View File
@@ -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
}
+354
View File
@@ -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).*
+125
View File
@@ -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!
+274
View File
@@ -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)
}
+24
View File
@@ -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)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+81
View File
@@ -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"
}
@@ -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"
}
}
File diff suppressed because one or more lines are too long
+81
View File
@@ -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
}