# 21-Jun-2026 — A Python Reference Server: the Same API on Azure, Entra and a Hybrid Overlay

_____

This is a companion to [From Mock to Production](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-04-27-salesforce-mock-to-production). That article described the journey from a synthetic mock to a production-shaped TypeScript server on Fly, with Ramblers Head Office inheriting a five-function checklist. Since then the picture has changed in one important way. At the 16 June meeting, Ramblers were clear about the stack they work in: **Python, Azure and Microsoft Entra ID**, and would rather not take on the TypeScript server.

So rather than delete anything that has already been developed, the existing TypeScript implementation was retained and a **parallel Python reference server** was created that Head Office could adopt in their own stack. Both servers expose the same wire format and stay interchangeable from a consumer's point of view. The Python server is now the **default** production path; the TypeScript server stays as a reference and fallback.

There was very little to re-architect. The contract has been the primary definition since the 7 April [#209](https://github.com/nbarrett/ngx-ramblers/issues/209) revision, the [mock](https://github.com/nbarrett/ramblers-salesforce-mock) is a fixture consumers reach over HTTP rather than code Head Office maintain, and the Salesforce adapter was always Phase 4 work. So the job was mostly: hand the contract over as a first-class artifact, express Entra in it, and give Head Office a Python server wired to the same interface.

## Two servers, one wire contract

From a consumer's point of view the wire format is identical whichever server answers. NGX Ramblers and MailMan do not need to know or care.

```mermaid
flowchart TB
    NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
    MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }

    Wire(["@ramblers/sf-contract<br/>OpenAPI · JSON Schema · types"])

    subgraph ts["TypeScript server (reference)"]
        TSSRV@{ icon: "logos:fly-icon", label: "Express on Fly", pos: "b", h: 48 }
    end

    subgraph py["Python server (default)"]
        PYSRV["FastAPI on Azure Container Apps"]
        OVERLAY["Azure Table overlay"]
        PYSRV --> OVERLAY
    end

    NGX --> Wire
    MM --> Wire
    Wire --> ts
    Wire --> py
```

The repo is now language-agnostic: the TypeScript server lives in [`typescript/`](https://github.com/nbarrett/ramblers-salesforce-server/tree/main/typescript), the Python server in [`python/`](https://github.com/nbarrett/ramblers-salesforce-server/tree/main/python), both consuming [`@ramblers/sf-contract`](https://github.com/nbarrett/ramblers-salesforce-contract) from a pinned tag.

## The Python server

It is a **[FastAPI](https://fastapi.tiangolo.com)** app, live at [salesforce-server.ngx-ramblers.org.uk](https://salesforce-server.ngx-ramblers.org.uk) on **Azure Container Apps**. A push to `main` builds the image, pushes it to the registry and updates the Container App.

The Azure footprint is small, and every secret has one home. The Container App carries a managed identity that reads its secrets from Key Vault; nothing sensitive sits in the image, the repo or a plain environment variable.

```mermaid
flowchart TB
    subgraph azure["Azure"]
        GHA@{ icon: "logos:github-actions", label: "GitHub Actions deploy", pos: "b", h: 48 }
        ACR@{ icon: "logos:docker-icon", label: "Container Registry", pos: "b", h: 48 }
        APP["Azure Container Apps<br/>FastAPI · managed identity"]
        KV["Azure Key Vault<br/>overlay + Salesforce secrets"]
        TBL["Azure Table Storage<br/>consent overlay"]
        GHA -->|build + push| ACR
        ACR -->|deploy image| APP
        APP -->|secrets via managed identity| KV
        APP -->|extension + consent fields| TBL
    end
    SF["Salesforce org<br/>member records"]
    APP -->|native fields · Phase 4| SF

    style azure fill:#E8F0FB,stroke:#5B9BD5,stroke-width:2px,rx:12,ry:12,color:#404143
```

Inbound requests carry a Microsoft Entra ID token. A consumer fetches a token, calls the API with it, and the server validates it against the tenant JWKS:

```mermaid
flowchart LR
    CONSUMER@{ icon: "ngx:user", label: "Consumer (NGX or MailMan)", pos: "b", h: 48 }
    ENTRA["Microsoft Entra ID<br/>token issuer + JWKS"]
    APP["Azure Container Apps<br/>FastAPI"]

    CONSUMER -->|"1 get token"| ENTRA
    CONSUMER -->|"2 call with Bearer token"| APP
    APP -->|"3 validate against JWKS"| ENTRA
```

It runs in two modes:

- **Demo** (the default) - deterministic synthetic data, no Salesforce, no database, no auth. This is the production server run cold: clone it and watch the real [routing](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/api/members.py), [validation](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/models.py), [OpenAPI](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/openapi_info.py) and [overlay](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/overlay/azure_table.py) code work in seconds. This is what is deployed at the live URL, so anyone can see it work.
- **Production** - the same server reading from Salesforce, with the overlay below for the fields Salesforce charges to hold. The data owner fills in [three methods](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py) (Phase 4).

### Hybrid persistence: Salesforce is expensive about custom data

Salesforce charges heavily to store your own custom fields. So the attributes it will not hold cheaply - a preferred name, and the granular group, area and other marketing-consent flags - live in a cheap **[overlay store](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/overlay/azure_table.py)** (Azure Table Storage), keyed by the Salesforce id. A [`HybridMemberProvider`](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/providers/hybrid_member_provider.py) merges them on top of the Salesforce-native fields, so a consumer always sees one member in the exact contract shape and never knows where any field physically lives.

```mermaid
flowchart LR
    REQ([GET members]) --> HYB[HybridMemberProvider]
    HYB --> SRC[Salesforce-native fields]
    HYB --> OVL[Overlay: preferredName + consent flags]
    SRC --> MERGE[One unified member]
    OVL --> MERGE
```

Each attribute has exactly one home, set by a manifest. A field is either in Salesforce or in the overlay, never both. If Head Office ever want to promote an overlay field into Salesforce, they backfill it and drop it from the manifest; the API contract never changes, so no consumer breaks. The overlay holds consent data, so it is deployed in a UK region, encrypted at rest, with a delete path for erasure.

### Entra authentication, as a visible plug-in

The server validates inbound Microsoft Entra ID access tokens (RS256) against the tenant JWKS, checking issuer and audience - the app-to-app (OAuth2 client-credentials) shape Head Office run. Switching it on is three settings - `AUTH_MODE=entra`, the tenant id and the audience - with no change to the routes. The single [validation interface](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/auth.py) is one function.

The bearer scheme is declared in the OpenAPI document, so the interactive docs at [`/docs`](https://salesforce-server.ngx-ramblers.org.uk/docs) show an **Authorize** button, exactly like the mock. A consumer pastes an Entra token and tries the live endpoints. This was proven end to end against the live server with a real token from an Entra app registration: no token and a bad token return `401`, a valid token returns `200`.

### The contract as a first-class artifact

[`@ramblers/sf-contract`](https://github.com/nbarrett/ramblers-salesforce-contract) now ships a committed, static **OpenAPI document** ([`openapi.json`](https://github.com/nbarrett/ramblers-salesforce-contract/blob/main/openapi/openapi.json) and [`openapi.yaml`](https://github.com/nbarrett/ramblers-salesforce-contract/blob/main/openapi/openapi.yaml)) generated from the [same builder](https://github.com/nbarrett/ramblers-salesforce-contract/blob/main/scripts/build-openapi.ts) the servers use, and attached to each [release tag](https://github.com/nbarrett/ramblers-salesforce-contract/tags). A [CI check](https://github.com/nbarrett/ramblers-salesforce-contract/blob/main/scripts/check-openapi-sync.ts) fails if the committed document ever stops matching the builder. The security scheme can express Entra (a JWT bearer with the OpenID configuration URL) for the production servers while keeping an opaque bearer for the mock's local mode. This shipped as contract [`v0.5.0`](https://github.com/nbarrett/ramblers-salesforce-contract/tree/v0.5.0).

If a group adopts the reference server we provide, there is nothing to generate: the typed client and server are already built for them. Generating a client only matters in the other case - a group that decides to build its own implementation, outside what we supply, can generate a typed client in any language straight from that document, with no need to run Node or read TypeScript. See [Generating a typed client from the OpenAPI document](https://ngx-ramblers.org.uk/how-to/technical-articles/2026-06-21-python-reference-server/generating-a-typed-client) for the recommended tools and commands.

A portable [conformance test](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/tests/test_contract_conformance.py) drives that static document against the running server and proves the two agree on the wire - the same check any consumer, in any language, can run against its own server.

## What Ramblers Head Office inherit

The structural work is done: [routing](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/api/members.py), [validation](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/models.py), the [generated OpenAPI](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/openapi_info.py), the [error envelope](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/errors.py), [Entra auth](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/auth.py), and the [hybrid overlay](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/providers/hybrid_member_provider.py). Because the overlay owns the consent and extension fields, the Phase 4 surface is **smaller** than the TypeScript server's: [three methods in the Salesforce source](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py), where someone with Salesforce-side knowledge fills in the [SOQL query](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py#L20), the [single-member lookup](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py#L32) and the [consent writeback](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py#L37).

Head Office also get the pieces that make it deployable in their own infrastructure:

- A **Container Apps** [Bicep template](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/infra/main.bicep) describing the whole deployment - managed environment, storage, Key Vault and the app with a managed identity that reads its secrets from Key Vault.
- Scripts that capture the **[Entra app registrations](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/infra/entra-setup.sh)** and the **[Key Vault](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/infra/keyvault-setup.sh)** wiring as code.
- A **[configuration reference](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/CONFIGURATION.md)** documenting every setting, its purpose, whether it is a secret, and how to obtain it - so a new operator knows exactly what to set, and which three things are ever genuinely secret (the storage connection string, the Salesforce private key, and each consumer's own client secret).

## Status

> - **Python reference server ✓** - live in demo mode on Azure Container Apps at [salesforce-server.ngx-ramblers.org.uk](https://salesforce-server.ngx-ramblers.org.uk), now the default production path. Tracked in [ramblers-salesforce-server#2](https://github.com/nbarrett/ramblers-salesforce-server/issues/2).
> - **Contract [v0.5.0](https://github.com/nbarrett/ramblers-salesforce-contract/tree/v0.5.0) ✓** - static [OpenAPI artifact](https://github.com/nbarrett/ramblers-salesforce-contract/blob/main/openapi/openapi.yaml) and an Entra-aware security scheme. No wire change from v0.4.0.
> - **Entra ✓** - proven end to end against the live server with a real client-credentials token.
> - **Hybrid overlay ✓** - the consent overlay persists to Azure Table Storage, its connection string held in Key Vault and read by the app's managed identity.
> - **Phase 4 (Python)** - [three methods in the Salesforce source](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/app/sources/salesforce.py), tracked in [ramblers-salesforce-server#3](https://github.com/nbarrett/ramblers-salesforce-server/issues/3). The TypeScript route stays available, tracked in [ramblers-salesforce-server#1](https://github.com/nbarrett/ramblers-salesforce-server/issues/1).
>
> Wire format, OpenAPI and the public API are unchanged. NGX Ramblers and MailMan are unaffected, and the mock continues to run at [salesforce-mock.ngx-ramblers.org.uk](https://salesforce-mock.ngx-ramblers.org.uk).

> **If you're Ramblers Head Office:** the Python server is standing and open at [salesforce-server.ngx-ramblers.org.uk](https://salesforce-server.ngx-ramblers.org.uk) - the [`/docs`](https://salesforce-server.ngx-ramblers.org.uk/docs) page is the quickest way in. Your work is the three-method checklist in [ramblers-salesforce-server#3](https://github.com/nbarrett/ramblers-salesforce-server/issues/3), and the [configuration reference](https://github.com/nbarrett/ramblers-salesforce-server/blob/main/python/CONFIGURATION.md) in the repo lists every setting and secret you need to run it in your own Azure.
