Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/mobility-core/mobility-core.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ library
Kernel.Utils.Servant.API
Kernel.Utils.Servant.BaseUrl
Kernel.Utils.Servant.BasicAuth
Kernel.Utils.Servant.BecknResponseWrapper
Kernel.Utils.Servant.Client
Kernel.Utils.Servant.HeaderAuth
Kernel.Utils.Servant.HTML
Expand Down
69 changes: 56 additions & 13 deletions lib/mobility-core/src/Kernel/Types/Beckn/Ack.hs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ module Kernel.Types.Beckn.Ack where

import qualified Control.Lens as L
import Data.Aeson
import qualified Data.Aeson.Key as AesonKey (Key)
import Data.Aeson.Types (unexpected)
import qualified Data.HashMap.Strict.InsOrd as HMSIO
import Data.OpenApi
import EulerHS.Prelude
import qualified Kernel.Types.Beckn.Error as BError

data AckResponse = Ack
data AckResponse
= Ack
| Nack BError.Error
deriving (Generic, Show)

instance ToSchema AckResponse where
Expand All @@ -32,13 +34,13 @@ instance ToSchema AckResponse where
mempty
& type_ L.?~ OpenApiObject
& properties
L..~ HMSIO.singleton "message" messageSchema
L..~ HMSIO.fromList [("message", messageSchema), ("error", errorSchema)]
& required L..~ ["message"]
where
statusSchema =
(mempty :: Schema)
& type_ L.?~ OpenApiString
& enum_ L.?~ ["ACK"]
& enum_ L.?~ ["ACK", "NACK"]
& Inline
ackSchema =
(mempty :: Schema)
Expand All @@ -54,19 +56,60 @@ instance ToSchema AckResponse where
L..~ HMSIO.singleton "ack" ackSchema
& required L..~ ["ack"]
& Inline
codeSchema = (mempty :: Schema) & type_ L.?~ OpenApiString & Inline
messageFieldSchema = (mempty :: Schema) & type_ L.?~ OpenApiString & Inline
errorSchema =
(mempty :: Schema)
& type_ L.?~ OpenApiObject
& properties
L..~ HMSIO.fromList [("code", codeSchema), ("message", messageFieldSchema)]
& required L..~ ["code"]
& Inline

-- ONDC TRV10 v2.1.0 envelope: `{message: {ack: {status}}, error?: {code, message}}`.
-- Error carries only `code` and `message` — the internal `type` and `path` fields are
-- not emitted (spec-optional, and we keep those in the in-memory Error record for logs).
--
-- Parser accepts the unwrapped shape (spec). A WAI-level wrapper middleware in the BPP
-- optionally rewraps outgoing bodies in `{response: ...}` for deployments whose BAPs
-- expect that shape; the response-wrapper middleware is the only place that knows about
-- the wrapper, keeping the wire-format contract explicit.
instance FromJSON AckResponse where
parseJSON = withObject "Ack" $ \v -> do
parseJSON = withObject "AckResponse" $ \v -> do
-- Accept both unwrapped (spec) and wrapped ({response: {...}}) bodies — the wrapper
-- is handled by middleware at emit time, but a peer may echo our wrapped output back.
inner <- (v .: "response") <|> pure v
status <-
(v .: "message")
(inner .: "message")
>>= (.: "ack")
>>= (.: "status")
unless (status == String "ACK") (unexpected status)
pure Ack
case status of
String "ACK" -> pure Ack
String "NACK" -> do
errObj <- inner .: "error"
errCode <- errObj .: "code"
errMsg <- errObj .:? "message"
pure $
Nack
BError.Error
{ BError._type = BError.INTERNAL_ERROR,
BError.code = errCode,
BError.path = Nothing,
BError.message = errMsg
}
other -> unexpected other

instance ToJSON AckResponse where
toJSON Ack = "message" .== "ack" .== "status" .== String "ACK"
where
(.==) :: AesonKey.Key -> Value -> Value
k .== v = Object (k .= v)
infixr 9 .==
toJSON Ack =
object
[ "message" .= object ["ack" .= object ["status" .= ("ACK" :: Text)]]
]
toJSON (Nack err) =
object
[ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
"error"
.= object
[ "code" .= BError.code err,
"message" .= BError.message err
]
Comment on lines +107 to +114
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n lib/mobility-core/src/Kernel/Types/Beckn/Ack.hs | head -150

Repository: nammayatri/shared-kernel

Length of output: 5068


🏁 Script executed:

fd -type f BError.hs | head -10

Repository: nammayatri/shared-kernel

Length of output: 239


🏁 Script executed:

find . -name "*BError*" -type f

Repository: nammayatri/shared-kernel

Length of output: 50


🏁 Script executed:

find . -path "*/Kernel/Types/Beckn/Error*" -type f

Repository: nammayatri/shared-kernel

Length of output: 120


🏁 Script executed:

cat -n ./lib/mobility-core/src/Kernel/Types/Beckn/Error.hs | head -100

Repository: nammayatri/shared-kernel

Length of output: 2133


Emit optional error message only when present; don't serialize Nothing as null.

Line 113 serializes BError.message err directly, which causes Nothing to become "message": null in the JSON output. However, the FromJSON parser uses optional field parsing (.:? on line 91), and the schema declares "message" as optional (only "code" is required on line 66). For consistency, omit the field entirely when the message is absent rather than emitting null.

Suggested fix
   toJSON (Nack err) =
     object
       [ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
         "error"
-          .= object
-            [ "code" .= BError.code err,
-              "message" .= BError.message err
-            ]
+          .= object
+            ( [ "code" .= BError.code err
+              ]
+                <> maybe [] (\msg -> ["message" .= msg]) (BError.message err)
+            )
       ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/mobility-core/src/Kernel/Types/Beckn/Ack.hs` around lines 107 - 114, In
toJSON (Nack) change the construction so the "message" key is only emitted when
BError.message err is Just something instead of serializing Nothing as null;
specifically, update the toJSON (Nack) body that currently uses BError.code and
BError.message to conditionally include the "message" field (e.g. build the
inner object with BError.code and append the "message" pair only when
BError.message err is present), ensuring the FromJSON expectation (using .:?
elsewhere) matches the produced JSON.

]
17 changes: 14 additions & 3 deletions lib/mobility-core/src/Kernel/Types/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -1142,21 +1142,32 @@ instance IsAPIError CallStatusError
data ServiceabilityError
= RideNotServiceable
| RideNotServiceableInState Text
deriving (Eq, Show, IsBecknAPIError)
| UnserviceableTripCategory Text
deriving (Eq, Show)

instanceExceptionWithParent 'HTTPException ''ServiceabilityError

instance IsBaseError ServiceabilityError where
toMessage RideNotServiceable = Just "Requested ride is not serviceable due to georestrictions."
toMessage (RideNotServiceableInState state_) = Just ("Selected state " <> state_ <> " is not serviceable since it does not fall within the state boundary.")
toMessage RideNotServiceable = Just "Route Serviceability error"
toMessage (RideNotServiceableInState state_) = Just ("Route Serviceability error: selected state " <> state_ <> " is not serviceable.")
toMessage (UnserviceableTripCategory cat) = Just ("Route Serviceability error: unserviceable trip category " <> cat <> ".")

instance IsHTTPError ServiceabilityError where
toErrorCode = \case
RideNotServiceable -> "RIDE_NOT_SERVICEABLE"
RideNotServiceableInState _ -> "RIDE_NOT_SERVICEABLE_IN_STATE"
UnserviceableTripCategory _ -> "UNSERVICEABLE_TRIP_CATEGORY"
toHttpCode = \case
RideNotServiceable -> E400
RideNotServiceableInState _ -> E400
UnserviceableTripCategory _ -> E400

-- ONDC v2.1.0 mandates numeric error codes in NACK responses. "90201" is
-- "Route Serviceability error" per the ONDC mobility spec. Keep the internal
-- toErrorCode for metrics/logs, and surface the ONDC code via toOndcErrorCode
-- which is what the BecknAPIError JSON actually emits.
instance IsBecknAPIError ServiceabilityError where
toOndcErrorCode _ = Just "90201"

instance IsAPIError ServiceabilityError

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,48 @@ class IsBecknAPIError e where
toPath :: e -> Maybe Text
toPath _ = Nothing

toOndcErrorCode :: e -> Maybe Text
toOndcErrorCode _ = Nothing

newtype BecknAPIError = BecknAPIError Error.Error
deriving (Generic, Eq, Show)

-- ONDC TRV10 v2.1.0 shape: `{message: {ack: {status: "NACK"}}, error: {code, message}}`.
-- The error object carries only `code` and `message` — internal `type` and `path`
-- remain in the Haskell Error record for logs/metrics but are not emitted. Optional
-- `"response"` wrapping for deployments that need it is handled by WAI middleware at
-- the HTTP edge, not here.
instance FromJSON BecknAPIError where
parseJSON (Object v) = BecknAPIError <$> v .: "error"
parseJSON (Object v) = do
-- Accept both unwrapped (spec) and wrapped `{response: {...}}` bodies in case a peer
-- echoes back our wrapped output.
inner <- (v .: "response") <|> pure v
errObj <- inner .: "error"
errCode <- errObj .: "code"
errMsg <- errObj .:? "message"
pure $
BecknAPIError
Error.Error
{ Error._type = Error.INTERNAL_ERROR,
Error.code = errCode,
Error.path = Nothing,
Error.message = errMsg
}
parseJSON invalid =
prependFailure
"Parsing BecknAPIError failed, "
(typeMismatch "Object" invalid)

instance ToJSON BecknAPIError where
toJSON (BecknAPIError err) = object ["message" .= ack, "error" .= err]
where
ack = object ["ack" .= status]
status = object ["status" .= ("NACK" :: Text)]
toJSON (BecknAPIError err) =
object
[ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
"error"
.= object
[ "code" .= Error.code err,
"message" .= Error.message err
]
Comment on lines +68 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if file exists and read the relevant section
fd -t f "BecknAPIError.hs" | head -1

Repository: nammayatri/shared-kernel

Length of output: 146


🏁 Script executed:

# Read the file to see the actual code and context
cat -n lib/mobility-core/src/Kernel/Types/Error/BaseError/HTTPError/BecknAPIError.hs

Repository: nammayatri/shared-kernel

Length of output: 3418


🏁 Script executed:

# Find the Error type definition to check the message field type
rg -A 10 "data Error" lib/mobility-core/src/Kernel/Types/Error/BaseError/HTTPError/ --type hs

Repository: nammayatri/shared-kernel

Length of output: 50


🏁 Script executed:

# Find the Error type definition
fd -t f "Error.hs" lib/mobility-core/src/Kernel/Types/Beckn/ | head -5

Repository: nammayatri/shared-kernel

Length of output: 118


🏁 Script executed:

# Search for Error type definition in the Beckn types
rg -A 20 "^data Error\b" lib/mobility-core/src/Kernel/Types/Beckn/ --type hs

Repository: nammayatri/shared-kernel

Length of output: 1657


🏁 Script executed:

# Verify the aeson pattern for conditional object fields
# Search for similar patterns with maybe and object construction
rg -B 3 -A 3 "maybe \[\]" lib/mobility-core/src/ --type hs | head -30

Repository: nammayatri/shared-kernel

Length of output: 2834


🏁 Script executed:

# Check aeson imports and verify the object API in this file
rg "from Data.Aeson" lib/mobility-core/src/Kernel/Types/Error/BaseError/HTTPError/BecknAPIError.hs

Repository: nammayatri/shared-kernel

Length of output: 50


🌐 Web query:

aeson Haskell maybe null serialization optional field

💡 Result:

In Haskell's aeson library, use Maybe for optional fields in data types. Aeson provides built-in FromJSON and ToJSON instances for Maybe a when a has instances. For manual FromJSON instances on records: - Use o .:? "field" :: Parser (Maybe a) for optional fields (returns Nothing if key missing or value fails to parse as a). - To handle explicit null as Nothing (especially post-aeson 0.10), use fmap join (o .:? "field") or o .:? "field" >>= parseJSON for Maybe (Maybe a), or o .:? "field" .!= Nothing. - For required fields, use o .: "field". Example: data Person = Person { name :: String, age :: Maybe Int } deriving Show instance FromJSON Person where parseJSON = withObject "Person" $ \o -> Person <$> o .: "name" <*> o .:? "age" decode "{"name":"Joe"}" :: Maybe Person yields Just (Person "Joe" Nothing). decode "{"name":"Joe","age":30}" yields Just (Person "Joe" (Just 30)). decode "{"name":"Joe","age":null}" also yields Just (Person "Joe" Nothing) due to Maybe's parseJSON handling Null. For automatic derivation (GHC.Generics): deriving (Generic) instance ToJSON Person where toJSON = genericToJSON defaultOptions { omitNothingFields = True } instance FromJSON Person where parseJSON = genericParseJSON defaultOptions { omitNothingFields = True, allowOmittedFields = True } omitNothingFields (default False) omits Nothing Maybe fields during encoding (no "field":null). allowOmittedFields (recent, default True) allows missing fields during parsing, defaulting Maybe fields to Nothing. In aeson 2.2+, supports more types like First/Last, newtypes. For Template Haskell: $(deriveJSON defaultOptions { omitNothingFields = True } ''Person) encode (Person "Joe" Nothing) yields "{"name":"Joe"}" (no age field).

Citations:


Don't emit error.message: null in the NACK payload.

Line 74 serializes a Maybe Text directly with .=, so Nothing becomes JSON null. For this envelope, absent and null are not equivalent; consumers expecting an optional string-valued field typically require it to be omitted when unavailable, not present with a non-string value. Build the error object conditionally instead of always including the field.

Suggested fix
   toJSON (BecknAPIError err) =
     object
-      [ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
-        "error"
-          .= object
-            [ "code" .= Error.code err,
-              "message" .= Error.message err
-            ]
-      ]
+      [ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
+        "error"
+          .= object
+            ( [ "code" .= Error.code err
+              ]
+                <> maybe [] (\msg -> ["message" .= msg]) (Error.message err)
+            )
+      ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
toJSON (BecknAPIError err) =
object
[ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
"error"
.= object
[ "code" .= Error.code err,
"message" .= Error.message err
]
toJSON (BecknAPIError err) =
object
[ "message" .= object ["ack" .= object ["status" .= ("NACK" :: Text)]],
"error"
.= object
( [ "code" .= Error.code err
]
<> maybe [] (\msg -> ["message" .= msg]) (Error.message err)
)
]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@lib/mobility-core/src/Kernel/Types/Error/BaseError/HTTPError/BecknAPIError.hs`
around lines 68 - 75, The toJSON implementation for Be cknAPIError is emitting
"error.message": null when Error.message is Nothing; update the toJSON
(BecknAPIError err) code to build the inner "error" object conditionally: always
include "code" (Error.code err) but only add the "message" key when
Error.message err is Just value (omit the key for Nothing). Change the
construction inside toJSON (BecknAPIError err) (or the helper that builds the
error object) to assemble a list of key/value pairs that appends the "message"
pair only when present (e.g., via maybe/catMaybes) and then pass that to object
for the "error" field so consumers don’t receive a null message.

]

instance FromResponse BecknAPIError where
fromResponse = fromJsonResponse
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ toBecknAPIError e =
BecknAPIError
Error
{ _type = toType e,
code = toErrorCode e,
code = fromMaybe (toErrorCode e) (toOndcErrorCode e),
path = toPath e,
message = toMessageIfNotInternal e
}
Loading
Loading