How to Write a v3 AsyncAPI Description

How to Write a v3 AsyncAPI Description

Posted in

When we talk about APIs, we usually talk about the ones we find on the Internet that probably implement something like REST and are served over HTTP. This narrow definition of APIs has widened to include other architectural styles and transport mechanisms as the API economy has grown and become more diverse. In this context, the AsyncAPI Specification has been big news for some time, and we’ve covered the API description language for asynchronous APIs on the blog before.

AsyncAPI version 3.0 was released in December 2023 and has gained traction in the tooling ecosystem. We wrote about the changes to version 3.0 in a previous post, where we talked about the value of the operation-orientated view version 3.0 supported in decoupling the Operation Object from Channels. The decoupling of Operation Objects opens the possibility of a use case view of AsyncAPI, where operations provide the basis for describing and fulfilling use cases for API consumers.

In this post, we’ll discuss the major changes provided by AsyncAPI v3.0 through a worked example of a use case and describe how this can improve your existing API design methodology. We’ll use this use case to create an example AsyncAPI description to help clarify the benefits of the approach.

Focus on Use Cases

Firstly, why focus on use cases? You might find the idea of focusing on use cases for messaging systems odd or somewhat abstract. Most existing messaging systems implement messages that support invoking business or technical functions. However, AsyncAPI provides the same means as OpenAPI to support a design-first approach to creating APIs. Many commentators in the community state that a use case view is exactly what they want from an API description language. API consumers and their stakeholders rarely focus on URIs or message queues, and instead concentrate on what the API actually does and tells API consumers what they MUST do to integrate with a given service.

The Operation Object, in its new decoupled guise, supports this kind of thinking. Operation Objects are not built solely for describing use cases, but their construct and abstraction allow use cases to be described more readily. We can use the idea of creating an API description based on a use case to describe how to write your AsyncAPI description using version 3.0.

Our hypothetical example is based on ordering medication using Fast Healthcare Interoperability Resources (FHIR) messages. FHIR is a standard created by HL7, an international standards body, for sending and receiving electronic health data and invoking operations associated with health care. Use cases in health care abound, and using standards-based message definitions gives a great starting point for creating an AsyncAPI description, especially in markets like the USA, where there is a high degree of fragmentation across health providers and services. Standardization is a real boost for interoperability as it helps bring different services together to serve patients more effectively.

You’ll find our full AsyncAPI example on GitHub.

Our use case for ordering medication incorporates two operations:

  1. Retrieving patient details and medication records: This operation allows the health practitioner to check previous health conditions and the patient’s current medication.
  2. Ordering new medication for the patient: The health practitioner can order the required medication for the patient.

There are other steps in this use case, but these steps provide sufficient affordances to designing the API to fulfill the use case using AsyncAPI v3.0. In this post, we’ll provide snippets of the AsyncAPI description, but you can refer to the full example and use AsyncAPI Studio to investigate in greater detail.

Create Standardized Messages

Our first step for creating a medication ordering service is to add our message structures based on the FHIR standard.

Messaging between systems that adopt FHIR and use asynchronous messaging platforms follow the concept of using “Bundles”, where several requests or messages are sent together. We, therefore, created a representation of a request for both patient data and a medication “statement” being sent in one payload. The Schema Objects to support this were designed using the resourceType property, with a oneOf object and the resourceType value being either Patient or MedicationStatement, for example:

- properties:
    resourceType:
      type: "string"
      enum: ["Patient"]

Schema Objects follow largely the same semantics as both AsyncAPI version 2.x and OpenAPI. In our example, these are implemented in the Components Object for the sake of reuse across Message Objects. We created four in total, with request and response payloads for the two operations, and then implemented Message Objects that reference each and can carry the semantics of a message including message traits like common headers. We implemented a header health-system-id that provides a high-level indicator of the system sending the message.

components:
  messages:
    patientMedicationStatementRequest:
      summary: Request message for patient and current medication data
      title: Patient and Medication Request
      payload:
        $ref: "#/components/schemas/patientMedicationStatementRequestPayload"
      traits:
        - $ref: "#/components/messageTraits/healthSystemHeaders"
    patientMedicationStatementResponse:
      summary: Response message for patient and current medication data
      title: Patient and Medication Response
      payload:
        $ref: "#/components/schemas/patientMedicationStatementRequestPayload"
      traits:
        - $ref: "#/components/messageTraits/healthSystemHeaders"
    patientMedicationRequest:
      summary: Request message for patient and current medication data
      title: Patient and Medication Request
      payload:
        $ref: "#/components/schemas/patientMedicationStatementRequestPayload"
      traits:
        - $ref: "#/components/messageTraits/healthSystemHeaders"
    patientMedicationResponse:
      summary: Request message for patient and current medication data
      title: Patient and Medication Request
      payload:
        $ref: "#/components/schemas/patientMedicationStatementRequestPayload"
      traits:
        - $ref: "#/components/messageTraits/healthSystemHeaders"
  schemas:
    patientMedicationStatementRequestPayload:
      type: "object"

      # Remainder of Schema Object definitions

  messageTraits:
    healthSystemHeaders:
      headers:
        type: object
        properties:
          health-system-id:
            description: Health system provider ID as UUID
            type: string
            pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$

The structures described here should, therefore, be very familiar to existing users of AsyncAPI.

Please note that we’ve significantly cut down the message structures in our example for the sake of this post. The source FHIR JSON Schema document is far more comprehensive.

Add Communication Channels

With the messages in place, we created Channel Objects. Channels describe the transport mechanism for sending and receiving messages and align a given message queue or topic with the message structures they support. The changes to the Channel Object in version 3.0 are noteworthy as the publish and subscribe properties have been removed, and the Operation Object they referenced moved to their own root-level property.

The removal of Operation Objects results in the Channel Object being much leaner and focused on the technical implementation. In our example you can see the Channel defines an address, which is a hypothetical message queue in our messaging system, and provides a binding between supported messages and the channel through the messages property:

channels:
  patientMedicationStatements:
    description: Channel for requesting patient record and medication statement
    address: health.patient.medication.statement
    messages:
      patientMedicationStatementRequest:
        $ref: "#/components/messages/patientMedicationStatementRequest"
      patientMedicationStatementResponse:
        $ref: "#/components/messages/patientMedicationStatementResponse"
  patientMedicationRequest:
    description: Channel for ordering medication for a patient
    address: health.patient.medication.request
    messages:
      patientMedicationRequest:
        $ref: "#/components/messages/patientMedicationRequest"
      patientMedicationResponse:
        $ref: "#/components/messages/patientMedicationResponse"

At version 3.0, the Channels Object provides a much greater focus on the technical implementation. The focus of the Channels allows API designers to focus on Operation Object, with reusability being increased by this abstraction.

Describe the Operations

The final step in our design is to create Operation Objects, which allows us to tell the consumer of our AsyncAPI description what to “do” to integrate their application with our API. As the name of an Operation Object is an identifier for a given object, we decided to name them all with a standard prefix, order-medication/, to provide a means by which to group the operations. We could have used a Tag Object as an alternative to this approach.

We created two Operation Objects, order-medication/get-patient-and-medication-statement and order-medication/make-medication-request that map to the two operations in our medication ordering use case:

operations:
  order-medication/get-patient-and-medication-statement:
    summary: Retrieve the patient record and current medication for the patient
    action: send
    channel:
      $ref: "#/channels/patientMedicationStatements"
    messages:
      - $ref: "#/channels/patientMedicationStatements/messages/patientMedicationStatementRequest"
    reply:
      address:
        "location": "$message.header#/replyTo"
      channel:
        $ref: "#/channels/patientMedicationRequest"
      messages:
        - $ref: "#/channels/patientMedicationRequest/messages/patientMedicationResponse"
  order-medication/make-medication-request:
    summary: Send a medication request on-behalf of the patient
    action: send
    channel:
      $ref: "#/channels/patientMedicationRequest"
    messages:
      - $ref: "#/channels/patientMedicationRequest/messages/patientMedicationRequest"
    reply:
      address:
        "location": "$message.header#/replyTo"
      channel:
        $ref: "#/channels/patientMedicationRequest"
      messages:
        - $ref: "#/channels/patientMedicationRequest/messages/patientMedicationResponse"

The Operations reference the Channel Objects we already created and the messages they support. We also leveraged another new feature of version 3.0 in our operations, the Operation Reply Object. The Operation Reply Object supports the implementation of a request/reply pattern, which is a very common messaging paradigm. In our example, we have specified a dynamically-set reply queue, which is specified using a Runtime Expression. The system that replies must use the value of the message header replyTo when sending the response.

Effective AsyncAPI Descriptions

Pulling together a version 3.0 AsyncAPI description, with a focus on a specific use case, shows something important about the revised structure of the AsyncAPI Specification, namely: Adding the Operation Object has provided a separation of concerns between the message, transport, and operation descriptions.

Aside from being able to describe use cases, there are several other significant benefits to the revised structure:

  • Consistency: Operation Objects can remain static in the AsyncAPI document through the API lifecycle, providing a consistent signature. Operation Objects are therefore easier to manage as they are not “caught up” in transport semantics.
  • Portability: Separating Operation Objects from Channel Objects means that Channel Objects can be described once and then used repeatedly, including across AsyncAPI descriptions. This makes Channel Objects much more portable.
  • Ownership: Consistency and portability are complemented by the means for different teams to take ownership of the parts of an AsyncAPI description for which they are responsible. For example, a technical operations team can define Channel Objects separately to the API design team, and inject Channel definitions such as the Servers Object when the information is available. This allows organizations to take a pipeline approach to create accurate AsyncAPI descriptions and leverage automation to ensure the descriptions are as accurate as possible.

We focused our design on a “bottom-up” approach, using existing message payload structures sourced from open standards, and created our Operation Objects as a last step. You can, of course, follow your own approach to constructing your AsyncAPI description — it’s entirely up to you — but the use case design approach gives some tangible results for what you want your interface to actually do. If you choose, you can start with your Operation Objects first, sketching out the requirements for the supported operations and getting agreement with design stakeholders, before embarking on the detailed messaging design.

The main drawback with describing use cases in AsyncAPI is communicating ordering and dependencies. AsyncAPI does not communicate this information through Operation Objects, other than through description and naming conventions. The next step for delivering a fully-featured use case description will, however, be delivered through the OpenAPI Initiative Arazzo Specification. Supporting AsyncAPI was originally in the version 1.0.0 release and is now on the roadmap again for the next version.

Arazzo will provide a much richer means for describing end-to-end use cases, including dependencies, dynamic values passed between operations, and failure conditions based on message data. This, together with the decoupled Operation Object, makes improving developer experience through rich use case descriptions for asynchronous APIs eminently feasible.