Skip the Designer — Editing Logic Apps Data Mapper LML Files Directly

The Azure Logic Apps Data Mapper has a visual designer in VS Code. You drag lines between schemas, drop in functions, and it generates an .lml file (YAML) that compiles to XSLT 3.0.

In theory, you never touch the YAML. In practice, the designer has reliability problems that make direct LML editing the better workflow.

Why skip the designer?

The designer doesn’t always persist changes correctly:

  • Changes revert on reopen — save, close, reopen, and the .lml on disk has the old value
  • String literals lose inner quotes — xpath("'kg'") becomes xpath("kg"), changing a literal to an element reference
  • Expressions get corrupted — conditions like xpath("if (...) then 'Y' else 'N'") lose quote characters
  • Function arguments get rewritten — argument order or paths change silently
  • No undo across sessions — you need git diff to catch silent corruption

These happen with normal operations, not edge cases.

What LML looks like

LML is YAML. Here’s a complete map:

$version: 1
$input: XML
$output: XML
$sourceSchema: OrderSchema.xsd
$targetSchema: ShipmentOutput.xsd
$sourceNamespaces:
  ns0: http://schemas.contoso.com/Order
$targetNamespaces:
  xs: http://www.w3.org/2001/XMLSchema
Shipments:
  Shipment:
    ShipmentRef: prefixShipRef(/ns0:Order/ns0:OrderID)
    ShipDate: reformatDate(/ns0:Order/ns0:OrderDate)
    Recipient: /ns0:Order/ns0:Customer/ns0:CustomerName
    Lines:
      $for(/ns0:Order/ns0:Items/ns0:Item):
        $if(xpath("ns0:Quantity >= 1")):
          Line:
            Product: ns0:ProductName
            Quantity: ns0:Quantity

Header declares schemas and namespaces. Body maps target elements (left) to source expressions (right).

LML syntax cheat sheet

# Direct mapping
Recipient: /ns0:Order/ns0:Customer/ns0:CustomerName

# Attribute on parent element
$@shipId: /ns0:Order/ns0:OrderID

# Loop over repeating elements (paths inside are relative)
$for(/ns0:Order/ns0:Items/ns0:Item):
  Line:
    Product: ns0:ProductName

# Conditional
$if(xpath("ns0:Quantity >= 1")):
  Line:
    Product: ns0:ProductName

# Custom function (defined in Functions/*.xml)
ShipDate: reformatDate(/ns0:Order/ns0:OrderDate)

# Raw XPath escape hatch
Priority: xpath("upper-case(@priority)")
WeightUnit: xpath("'kg'")   # inner quotes = string literal

Custom extension functions

Defined in Artifacts/DataMapper/Extensions/Functions/*.xml:

<customfunctions>
  <function name="reformatDate" as="xs:string"
            description="Reformats yyyy-MM-dd to dd/MM/yyyy.">
    <param name="dateVal" as="xs:date"/>
    <value-of select="format-date($dateVal, '[D01]/[M01]/[Y0001]')"/>
  </function>
</customfunctions>

Gotchas

Zero-parameter functions crash the compiler. The SDK throws a NullReferenceException and silently skips the entire XML file. Workaround: add a dummy parameter.

Function arguments must be single-step element names. Multi-step paths cause compile errors — wrap them in xpath():

# INVALID
Address: formatAddress(ns0:Customer/ns0:Street, ns0:Customer/ns0:City)

# FIX
Address: formatAddress(xpath("ns0:Customer/ns0:Street"), xpath("ns0:Customer/ns0:City"))

The workflow: edit, compile, verify

1. Edit the YAML

Open the .lml file in any text editor and make your change.

2. Compile to XSLT

Install lml-compile, a dotnet global tool that wraps the Logic Apps SDK:

dotnet tool install -g lml-compile

Compile:

lml-compile Artifacts/MapDefinitions/MyMap-lml.lml Artifacts/Maps/MyMap-lml.xslt
# OK: MyMap-lml.xslt

3. Verify

Run dotnet test or press F5 with the XSLT Debugger extension.

Auto-compile on save

Install the Run on Save extension and add this to your workspace settings (for multi-root workspaces, add it in the .code-workspace file — Run on Save doesn’t read folder-level settings):

"emeraldwalk.runonsave": {
  "commands": [
    {
      "match": "\.lml$",
      "cmd": "lml-compile ${file} ${fileDirname}/../Maps/${fileBasenameNoExt}.xslt"
    }
  ]
}

Note: Run on Save uses its own variables — not VS Code task variables. ${fileBasenameNoExt} and ${fileDirname} work; ${fileBasenameNoExtension} and ${workspaceFolder} do not. Reload the window after adding the config.

Now the cycle is just: edit .lml → save → F5 or dotnet test. The compile happens automatically.

What the compiler catches

  • Malformed YAML / missing $version header
  • Missing schemas in Artifacts/Schemas/
  • Unrecognized function calls
  • Invalid XPath syntax

Errors surface on save instead of at deploy time.

Naming convention

Name hand-authored LML files with a -lml suffix:

Artifacts/MapDefinitions/OrderToShipment-lml.lml  →  Artifacts/Maps/OrderToShipment-lml.xslt

This avoids colliding with hand-authored XSLT files and makes it clear which files are compiled output.

Summary

Use the designer to scaffold the initial map. Then switch to direct LML editing for all subsequent changes.

Edit .lml → save → auto-compile → verify

No designer round-trip. No silent rewrites. No surprises.

The XSLT Debugger extension is on the VS Code Marketplace for macOS and Windows.

awesome-trending-repos: Modern Web Interface for GitHub Trending

CI/CD Pipeline

In my previous blog post, I introduced the awesome-trending-repos project. Back then, the project only wrote data to a README.md file. Things have changed. The project is now a fully functional modern web application.

CI/CD Pipeline
Automated build and deployment process with GitHub Actions

Why a Web Interface?

A README.md file works, but it’s static. To track trending projects effectively, I wanted a more interactive experience. Users needed to be able to filter by programming language, search repositories, and see data visualized with charts.

So I built a modern single-page application (SPA) with React + Vite.

Tech Stack

Frontend:

  • React 19 – Latest version
  • Vite – Fast dev server and build tool
  • Tailwind CSS v4 – Utility-first styling
  • Framer Motion – Animations
  • Recharts – Data visualization

Backend/Infrastructure:

  • GitHub Actions – Automation
  • GitHub Pages – Hosting
  • Custom Domain – furkankoykiran.com.tr

Project Structure

The core project structure:

awesome-trending-repos/
├── src/
│   ├── App.jsx          # Main React component
│   ├── main.jsx         # Entry point
│   └── index.css        # Tailwind + custom styles
├── scripts/
│   └── update.js        # Trending data scrape script
├── public/
│   ├── data/
│   │   └── trending.json # Frontend data source
│   └── logo.png
├── .github/workflows/
│   └── update-trends.yml # CI/CD pipeline
└── vite.config.js       # GitHub Pages config

GitHub Pages Integration

When using Vite with GitHub Pages, the most important configuration is the base path. If your project runs in a subdirectory (e.g., github.io/repo-name), you must specify this in vite.config.js:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  base: '/awesome-trending-repos/', // GitHub Pages subdirectory
});

Without this setting, asset paths will break in production and you’ll get 404 errors.

GitHub Actions Workflow

The workflow runs every day at midnight UTC:

  1. Fetch trending data – Scrape GitHub Trending page
  2. Build frontend – Create dist/ folder with npm run build
  3. Commit data – Commit README and data files if changed
  4. Deploy – Distribute to GitHub Pages
# .github/workflows/update-trends.yml
name: Update and Deploy Trends

on:
  schedule:
    - cron: '0 0 * * *'  # Daily at midnight
  workflow_dispatch:

permissions:
  contents: write
  pages: write
  id-token: write

jobs:
  update-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run update script
        run: npm run update
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Build frontend
        run: npm run build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: './dist'

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: update-and-build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

Deployment Flow
Automated deployment flow

Frontend Features

1. Language Filtering

Users can filter by programming language:

const languages = ['All', ...new Set(repos.map(r => r.language).filter(Boolean))];

const filteredRepos = repos.filter(repo => {
  const matchesLang = filter === 'All' || repo.language === filter;
  const matchesSearch = repo.name.toLowerCase().includes(searchQuery.toLowerCase());
  return matchesLang && matchesSearch;
});

2. Search Function

Real-time search across repo name, owner, and description.

3. Visual Statistics

Recharts library for language distribution bar chart and growth leaders list:

<ResponsiveContainer width="100%" height="100%">
  <BarChart data={data?.insights?.topLanguages || []}>
    <CartesianGrid strokeDasharray="8 8" stroke="#ffffff03" />
    <XAxis dataKey="language" stroke="#475569" />
    <YAxis stroke="#475569" />
    <Tooltip contentStyle={{ backgroundColor: 'rgba(15, 23, 42, 0.9)' }} />
    <Bar dataKey="count" radius={[12, 12, 0, 0]}>
      {data?.insights?.topLanguages?.map((entry, index) => (
        <Cell key={index} fill={getLangColor(entry.language)} />
      ))}
    </Bar>
  </BarChart>
</ResponsiveContainer>

4. Deployment Feed

A feed entry is created for each update. Users can see the deployment history.

Custom Domain Setup

To use a custom domain with GitHub Pages:

  1. Create CNAME file – Add your domain to public/CNAME:
   furkankoykiran.com.tr
  1. DNS setting – At your domain provider:
   CNAME furkankoykiran.github.io
  1. HTTPS – GitHub Pages automatically provides SSL with Let’s Encrypt.

Performance Tips

1. Asset Optimization

Vite production build automatically does minification and tree-shaking. For large images, consider using optimized images instead of putting everything in public/.

2. Lazy Loading

For large lists, consider virtual scrolling. Not needed for this project with 25 repos, but good to know if you scale.

3. Caching

GitHub Pages automatically adds cache headers. For index.html, use cache-busting:

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: `assets/[name].[hash].js`,
        chunkFileNames: `assets/[name].[hash].js`,
        assetFileNames: `assets/[name].[hash].[ext]`
      }
    }
  }
});

Live Demo

The project is currently live at furkankoykiran.com.tr/awesome-trending-repos. It updates automatically every day.

Project Logo
Project logo

OG Image
Open Graph preview image

Conclusion

Moving from a README.md to a modern web application significantly improved the project’s usability. Users now have an interactive experience instead of just a static list.

The GitHub Pages + Vite combination is an excellent solution for static sites. Free, fast, and fully automated with CI/CD.

The project is fully open source. If you want to contribute, send a pull request to the GitHub repo.

Source: blog.furkankoykiran.com.tr

Introducing Flexibility Without Losing Structure in Clprolf

Why a structured approach doesn’t have to be a barrier to entry.

Introduction

When introducing a structured approach like Clprolf, a common concern appears quickly:

  • “Do I have to rewrite everything?”
  • “Does this change how I usually code?”
  • “Is this too strict for real-world usage?”

These concerns are legitimate.

Most real-world codebases are not perfectly structured.
They mix domain logic and technical details.
They evolve under constraints.

So the question becomes:

Can Clprolf exist without forcing a complete rewrite?

Starting From Existing Code

Clprolf is based on two simple principles:

  • a class is either technical or organized around a well-defined domain
  • inheritance must preserve that domain — otherwise, composition is used

These principles provide structure.

But they do not require starting from scratch.

You can start from existing code.

Even when a class mixes concerns,
it still has a primary role.

That role defines its nature.

A Flexible Entry Point

Clprolf can be used in a more flexible way by default, as the compiler operates in this mode.

In this mode:

  • existing code remains valid
  • no immediate refactoring is required
  • no additional annotations are necessary

The goal is not to enforce structure immediately,
but to make it available.

Structure Still Exists

Flexibility does not remove the model.

The same principles still apply.

Even in mixed code:

  • the primary role of a class remains identifiable
  • domain and technical concerns are still distinct in nature

Clprolf does not ignore structure.
It simply does not force it upfront.

Strict and Flexible Clprolf

This leads to two ways of using Clprolf:

  • a flexible usage, closer to common practices
  • a strict usage, with stronger structural guarantees

The difference is not in the model itself,
but in how strictly it is applied.

Strict Clprolf makes the separation explicit.
Flexible Clprolf allows it to remain implicit.

Compatibility Between Both

Both approaches are fully compatible.

  • strict code works in all cases
  • flexible code integrates without issue

There is no fragmentation.

Clprolf remains a single model,
regardless of how strictly it is applied.

Gradual Evolution

In practice, adoption becomes natural.

Developers can:

  • start with their existing code
  • observe where responsibilities become unclear
  • introduce structure where it brings value

No forced transition is required.

Clprolf can be adopted progressively.

Working With Existing Libraries

Clprolf does not require external code to follow its model.

Libraries, frameworks, and APIs can be used as they are.

What matters is how they are integrated.

The structure lives in your code,
not in the code you depend on.

Final Thought

Clprolf is not about forcing a new way of coding.

It is about providing a clearer structure
that can be applied when needed.

You don’t need to change everything to start.
You can start where you are —
and move toward more structure when it makes sense.

awesome-trending-repos: GitHub Trending için Modern Web Arayüzü

CI/CD Pipeline

Önceki blog yazımda awesome-trending-repos projesinden bahsetmiştim. O zamanlar proje sadece README.md dosyasına veri yazıyordu. Artık things changed. Proje artık tam fonksiyonlu bir modern web uygulaması.

CI/CD Pipeline
GitHub Actions ile otomatik build ve deploy süreci

Neden Web Arayüzü?

README.md dosyası işe yarar ama statiktir. Trending projeleri takip etmek için daha interaktif bir deneyim istedim. Kullanıcıların dil bazlı filtreleme yapabilmesi, arama yapabilmesi ve grafiklerle verileri görebilmesi gerekliydi.

Bu yüzden React + Vite ile modern bir single-page application (SPA) geliştirdim.

Tech Stack

Frontend:

  • React 19 – Son sürüm
  • Vite – Hızlı dev server ve build
  • Tailwind CSS v4 – Utility-first styling
  • Framer Motion – Animasyonlar
  • Recharts – Veri görselleştirme

Backend/Infrastructure:

  • GitHub Actions – Otomasyon
  • GitHub Pages – Hosting
  • Custom Domain – furkankoykiran.com.tr

Proje Yapısı

Projenin temel dizini şöyle:

awesome-trending-repos/
├── src/
│   ├── App.jsx          # Ana React bileşeni
│   ├── main.jsx         # Entry point
│   └── index.css        # Tailwind + custom styles
├── scripts/
│   └── update.js        # Trending veri scrape script
├── public/
│   ├── data/
│   │   └── trending.json # Frontend veri kaynağı
│   └── logo.png
├── .github/workflows/
│   └── update-trends.yml # CI/CD pipeline
└── vite.config.js       # GitHub Pages config

GitHub Pages Entegrasyonu

Vite ile GitHub Pages kullanırken en önemli nokta base path konfigürasyonudur. Projeniz bir subdirectory’de çalışacaksa (örn: github.io/repo-name), vite.config.js dosyasında bunu belirtmelisiniz:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()],
  base: '/awesome-trending-repos/', // GitHub Pages subdirectory
});

Bu ayar olmadan, production’da asset path’leri bozulur ve 404 hatası alırsınız.

GitHub Actions Workflow

Her gün gece yarısı UTC’de çalışan workflow:

  1. Trending verisi çek – GitHub Trending sayfasını scrape et
  2. Frontend build – npm run build ile dist/ klasörünü oluştur
  3. Data commit – Değişiklik varsa README ve data dosyalarını commit et
  4. Deploy – GitHub Pages’e dağıt
# .github/workflows/update-trends.yml
name: Update and Deploy Trends

on:
  schedule:
    - cron: '0 0 * * *'  # Her gün gece yarısı
  workflow_dispatch:

permissions:
  contents: write
  pages: write
  id-token: write

jobs:
  update-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run update script
        run: npm run update
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Build frontend
        run: npm run build

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: './dist'

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: update-and-build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

Deployment Flow
Otomatik deployment akışı

Frontend Özellikleri

1. Dil Filtreleme

Kullanıcılar programlama diline göre filtreleme yapabilir:

const languages = ['All', ...new Set(repos.map(r => r.language).filter(Boolean))];

const filteredRepos = repos.filter(repo => {
  const matchesLang = filter === 'All' || repo.language === filter;
  const matchesSearch = repo.name.toLowerCase().includes(searchQuery.toLowerCase());
  return matchesLang && matchesSearch;
});

2. Arama Fonksiyonu

Real-time arama ile repo ismi, owner veya description’da arama yapılır.

3. Görsel İstatistikler

Recharts kütüphanesi ile dil dağılımı bar chart ve growth liderleri listesi:

<ResponsiveContainer width="100%" height="100%">
  <BarChart data={data?.insights?.topLanguages || []}>
    <CartesianGrid strokeDasharray="8 8" stroke="#ffffff03" />
    <XAxis dataKey="language" stroke="#475569" />
    <YAxis stroke="#475569" />
    <Tooltip contentStyle={{ backgroundColor: 'rgba(15, 23, 42, 0.9)' }} />
    <Bar dataKey="count" radius={[12, 12, 0, 0]}>
      {data?.insights?.topLanguages?.map((entry, index) => (
        <Cell key={index} fill={getLangColor(entry.language)} />
      ))}
    </Bar>
  </BarChart>
</ResponsiveContainer>

4. Deployment Feed

Her güncelleme için bir feed entry oluşturulur. Kullanıcılar deployment tarihçesini görebilir.

Custom Domain Ayarı

GitHub Pages için custom domain kullanmak istersen:

  1. CNAME dosyası oluştur – public/CNAME içine domain yaz:
   furkankoykiran.com.tr
  1. DNS ayarı – Domain provider’ında:
   CNAME furkankoykiran.github.io
  1. HTTPS – GitHub Pages otomatik olarak Let’s Encrypt ile SSL sağlar.

Performans İpuçları

1. Asset Optimization

Vite production build otomatik olarak minification ve tree-shaking yapar. Büyük resimler için public/ klasörü yerine optimized image kullanın.

2. Lazy Loading

Büyük listeler için virtual scrolling kullanmayı düşünün. Bu projede 25 repo olduğu için gerekli değil ama scale ederseniz eklenebilir.

3. Caching

GitHub Pages otomatik olarak cache headers ekler. Ancak index.html için cache-busting kullanın:

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: `assets/[name].[hash].js`,
        chunkFileNames: `assets/[name].[hash].js`,
        assetFileNames: `assets/[name].[hash].[ext]`
      }
    }
  }
});

Canlı Demo

Proje şu anda furkankoykiran.com.tr/awesome-trending-repos adresinde yayında. Her gün otomatik olarak güncelleniyor.

Project Logo
Proje logosu

OG Image
Open Graph preview görseli

Sonuç

README.md’den modern web uygulamasına geçiş, projenin kullanılabilirliğini önemli ölçüde artırdı. Kullanıcılar artık sadece statik bir liste yerine interaktif bir deneyim yaşıyor.

GitHub Pages + Vite kombinasyonu, static siteler için harika bir çözüm. Ücretsiz, hızlı ve CI/CD ile tam otomatik.

Proje tamamen açık kaynak. Katkı yapmak isterseniz GitHub repo‘ya pull request gönderebilirsiniz.

Kaynak: blog.furkankoykiran.com.tr

Infisical is Great, Actually

I run ArgoCD. Full GitOps — if it’s not in the repo, it doesn’t exist. That’s great for everything except secrets, where “if it’s in the repo, it might not exist for long either.” GitHub secret scanning will catch an API key in a private repo, helpfully disable it, and send you a polite notification that you messed up. So I needed an ESO backend. Here’s what I looked at.

Shopping Around

I was already applying secrets manually via kubectl — which works fine until it doesn’t, and doesn’t scale past “just me doing everything.” The plan was always to wire up External Secrets Operator; the question was just what it would point at.

SOPS came up first — a Claude recommendation. It encrypts secrets in-repo, which sounds elegant, but the decryption key has to live somewhere, and in practice that somewhere is the machine doing the decrypting. If that machine is compromised, the attacker gets the key, and the key opens everything. Security theater. My brain wanted something that felt like AWS Parameter Store — a place secrets live, accessed over an authenticated API, not a place they’re hidden inside something else with the unhiding tool sitting right next to them.

The second AI-generated recommendation was GitHub Actions Secrets. Which, sure — except my IaC repo has GitHub Actions in it. Secrets that live inside the same system they’re deploying feel like a liability waiting to happen. At this point it was clear I needed an actual secrets service, so I started shopping for an ESO backend.

AWS Secrets Manager, AWS Parameter Store, and OCI Vault were the clear, industry-standard options — the real products that do the job properly. But I’m running K3s on a free OCI ARM VM specifically because I want zero dependencies I can’t walk away from. If OCI’s free tier ever goes south, I want to pack up and leave — depending on OCI Vault would chain my secrets to the same cloud I’m trying to stay portable from. And pulling in AWS just for secrets would be adding a second cloud dependency for no reason.

I also stumbled upon Doppler, which I actually liked the look of. The DX is genuinely good, the CLI is pleasant, the UI is clean. Then I hit the pricing page: service accounts — the thing you need for any automated workflow — are a Team plan feature. $21/month per user. For just me. For secrets. No.

Then I found Infisical.

What Infisical Is

Infisical is an open-source secrets manager with a managed cloud offering and a self-hostable option. The cloud tier is genuinely free for reasonable usage. It has a UI, a CLI, SDKs, and first-class Kubernetes support — either via its own operator or as an External Secrets Operator backend, which is what I ended up using.

The Setup I Actually Use: ESO + Infisical Cloud

Rather than the Infisical operator, I went with External Secrets Operator (ESO) using Infisical as the backend. ESO is a CNCF project with a clean abstraction: you define a SecretStore (or ClusterSecretStore) pointing at your secrets backend, then ExternalSecret resources that describe which secrets to sync and where. The output is always a standard Kubernetes Secret. Swap out the backend someday and your app manifests don’t change.

Installing ESO via ArgoCD

I manage everything with ArgoCD, so the ESO install is an Application pointing at the official release manifest:

# applications/external-secrets/kustomization.yml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://github.com/external-secrets/external-secrets/releases/download/v2.2.0/external-secrets.yaml
  - cluster-secret-stores.yml

The ArgoCD Application itself:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: external-secrets
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  project: default
  source:
    repoURL: https://github.com/notjustanna/iac.git
    targetRevision: main
    path: applications/external-secrets
  destination:
    server: https://kubernetes.default.svc
    namespace: external-secrets
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true

The sync-wave: "-1" ensures ESO is fully deployed before anything tries to create ExternalSecret resources.

Configuring the ClusterSecretStore

Create a machine identity in Infisical (Project → Access Control → Machine Identities), give it read access to your environment, then store the credentials:

kubectl create secret generic infisical-auth 
  --from-literal=clientId=<your-client-id> 
  --from-literal=clientSecret=<your-client-secret> 
  -n external-secrets

Then the ClusterSecretStore:

# applications/external-secrets/cluster-secret-stores.yml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: infisical
spec:
  provider:
    infisical:
      auth:
        universalAuthCredentials:
          clientId:
            name: infisical-auth
            namespace: external-secrets
            key: clientId
          clientSecret:
            name: infisical-auth
            namespace: external-secrets
            key: clientSecret
      secretsScope:
        projectSlug: "your-project-slug"
        environmentSlug: "prod"
        secretsPath: "/"
        recursive: true

One ClusterSecretStore serves the whole cluster. Every namespace can reference it.

Consuming Secrets: A Real Example

Here’s how I pull in the Cloudflare API token for Traefik. In Infisical, the secret lives under /traefik. The ExternalSecret syncs everything under that path into a Kubernetes secret in the traefik namespace:

# applications/traefik/external-secret.yml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: cloudflare-api-token
  namespace: traefik
spec:
  secretStoreRef:
    name: infisical
    kind: ClusterSecretStore
  refreshInterval: 1m
  target:
    name: cloudflare-api-token
    creationPolicy: Owner
    template:
      type: Opaque
  dataFrom:
    - find:
        path: /traefik

Traefik just sees a Kubernetes secret. It has no idea Infisical exists. That’s the whole point.

The Part That Actually Sold Me

The ESO pattern means application manifests don’t change when you change secret backends. Secrets are just Kubernetes secrets to everything downstream — no SDK, no sidecar, no secret-fetching logic in application code. Infisical lives entirely in the infra layer, which is where secrets management should live.

The path-based organization (/traefik, /monitoring, and so on) felt immediately familiar — it’s the same mental model as AWS Parameter Store. That’s not a coincidence; it’s just the right way to organize secrets. The recursive: true on the ClusterSecretStore means ExternalSecret resources can scope as narrowly or broadly as makes sense per workload.

Should You Use the Cloud or Self-Host?

The managed free tier covers up to 5 projects with unlimited secrets. For a homelab or small production workload, there’s genuinely no reason to self-host unless you want to.

If you do want to self-host — the Helm chart is well-documented. Just be aware of the chicken-and-egg problem: if Infisical lives on the same cluster it’s serving secrets to, it becomes a bootstrap dependency, and that gets messy fast. I wrote about this failure mode in more detail here. Infisical Cloud sidesteps it entirely, which is why I’m using it.

Bottom Line

I stopped looking for a better option. Infisical + ESO hit the threshold of “this is clearly correct” — open source, free at the scale I need, Kubernetes-native without being invasive, and not locked to any cloud. The setup I showed above is the whole thing. If you’re still managing secrets via committed files, manual kubectl create secret commands, or CI-only stores — this is the way out.

Cover photo by Sid Verma on Unsplash

Adding Authentication and SSO to a Reflex App

Descope console project settings

This blog was originally published on Descope

Reflex is an open-source full-stack web framework written in and for Python that lets you build both frontend UI and backend logic in pure Python, i.e., you don’t need to manually write JavaScript or separately manage a React/Vue frontend and a separate backend.

By adding Descope to your Reflex application, you can immediately take advantage of a full-fledged identity platform—complete with passwordless authentication, MFA, passkeys, social login, enterprise SSO, and customizable user journeys.

This lets you keep writing your entire Reflex project in Python while relying on Descope to seamlessly deliver the advanced authentication and security capabilities needed for real-world, scalable applications.

Let’s begin!

Prerequisites

It’s helpful to familiarize yourself with Python and Reflex. You’ll also need a Descope account, but don’t worry if you don’t have one yet—you’ll learn how to set one up shortly. The free tier will be sufficient for you to follow along.

Create a Descope Project

The first step in connecting your Reflex app to Descope is to create a new Project in the Descope Console.

  • Go to the Descope sign-up page and register for a free account (or log in if you already have one).
  • From the top-left menu in the console, click + Project to set up a new project.

In Settings > Project, you’ll find your Project ID, which is a unique identifier used to link your Reflex app to Descope.

Descope console project settings

Best practice: Never paste this ID directly into your code. Instead, store it securely as an environment variable in a .env file. We’ll configure this later in the blog.

Create the Reflex app

With your Descope Project set up, let’s move to the Reflex side. We’ll start with a dashboard app template and then integrate authentication step-by-step.

  • Create a new directory for this app and clone the company dashboard template. Optionally, set up a Python virtual environment inside it.
  • Add reflex-descope-auth to the requirements.txt file. Note: This is the Reflex Descope Auth plugin, which uses standard OIDC protocols to handle the login process in a reliable, familiar way.
  • Now run this command to install all the dependencies using the command below:
pip install -r requirements.txt
  • Next, create a .env file and set these environment variables in the project.
DESCOPE_PROJECT_ID=<your-descope-project-id>
DESCOPE_FLOW_ID=<your-flow-id>
DESCOPE_LOGOUT_REDIRECT_URI=http://localhost:3000
SESSION_SECRET=<secure-random-secret>

You can generate a secret using Python’s secrets module:

python -c "import secrets; print(secrets.token_hex(32))"

This is what your project structure should look like:

.
├── .gitignore
├── app
│   ├── __init__.py
│   ├── app.py
│   ├── components
│   │   ├── __init__.py
│   │   ├── documents_table.py
│   │   ├── header.py
│   │   ├── key_metrics.py
│   │   ├── sidebar.py
│   │   └── visitors_chart.py
│   └── states
│       ├── __init__.py
│       ├── auth_state.py
│       └── dashboard_state.py
├── apt-packages.txt
├── assets/
├── .env
├── requirements.txt
└── rxconfig.py

Log in a user

In the app.py file, we’ll add a button that calls start_login, a built-in method that initiates the login flow and redirects users to the Descope authorization endpoint.

def index() -> rx.Component:
    """The main page, which serves as the login entry point."""
    return rx.el.div(
        rx.el.section(
            rx.el.div(
                rx.el.button(
                    "Login with Descope",
                    on_click=AuthState.start_login,
                    class_name=(
                        "mt-8 px-8 py-3 text-white bg-red-600 rounded-lg font-semibold "
                        "hover:bg-blue-700 transition-colors shadow-md"
                    ),
                ),
                class_name="flex flex-col items-start max-w-xl",
            ),
            class_name="container mx-auto flex flex-col lg:flex-row items-center justify-between gap-12 px-4 py-16",
        ),
        class_name="w-full bg-gray-50",
    )

The dashboard_page function defines the main authenticated dashboard view of the application. It’s registered as a Reflex page using the @rx.page() decorator with the root route (/).

When a user visits this page, the on_load parameter triggers the AuthState.check_login method to verify if the user is authenticated. If the user isn’t logged in, they’ll be redirected to the login flow automatically.

@rx.page(route="/", on_load=AuthState.check_login)
def dashboard_page() -> rx.Component:
    """The main dashboard page."""
    return rx.el.div(
        sidebar(),
        rx.el.main(
            header_bar(),
            rx.el.div(
                key_metrics_section(),
                visitors_chart_section(),
                documents_table_section(),
                class_name="p-6 space-y-6",
            ),
            class_name="w-full h-[100vh] overflow-y-auto",
        ),
        class_name="flex flex-row bg-gray-50 h-[100vh] w-full overflow-hidden",
        on_mount=DashboardState.load_initial_data,
    )

The callback page handles the authentication redirect after a user logs in. It’s defined using the @rx.page() decorator and mapped to the /callback route.

When the user returns to this route after completing authentication, the on_load parameter calls AuthState.auth_redirect. This method processes the login response, typically validating tokens, storing session data, and determining whether the login succeeded or failed.

@rx.page(route="/callback", on_load=AuthState.auth_redirect)
def callback() -> rx.Component:
    return rx.el.div(
        rx.cond(
            AuthState.error_message != "",
            rx.el.div(
                rx.el.h1("Login Failed", class_name="text-red-500"),
                rx.el.p(AuthState.error_message),
                rx.el.button(
                    "Try Again",
                    on_click=AuthState.start_login,
                    class_name="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700",
                ),
                class_name="flex flex-col items-center space-y-4",
            ),
            rx.el.div(
                rx.el.p("Processing login..."), class_name="flex items-center space-x-2"
            ),
        ),
        class_name="flex items-center justify-center h-screen",
    )

The auth_error page provides a dedicated route for handling login failures. It’s defined at the /auth-error path and serves as a fallback whenever something goes wrong during the authentication process. For example, if token validation fails, the user denies consent, or the callback flow encounters an unexpected error.

@rx.page(route="/auth-error")
def auth_error() -> rx.Component:
    return rx.el.div(
        rx.el.h1("Login Failed", class_name="text-red-500"),
        rx.el.p(AuthState.error_message),
        rx.el.button(
            "Try Again",
            on_click=AuthState.start_login,
            class_name="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700",
        ),
        class_name="flex flex-col items-center space-y-4 h-screen justify-center",
    )

We’ll also add additional routes to this file to handle page protection and move the dashboard to a dedicated page.

app.add_page(dashboard_page, route="/dashboard")
app.add_page(callback, route="/callback")
app.add_page(auth_error, route="/auth-error")

Define AuthState in Reflex

Authentication in Reflex is handled through reactive state classes. The reflex-descope-auth plugin provides a base class called DescopeAuthState, which already implements the full authentication lifecycle for you. By subclassing it, we can customize how our app handles redirects and session checks after a user logs in or out. The reflex-descope-auth plugin validates Descope tokens only once during finalize_auth. After that, Reflex simply stores the session in state and does not re-check the exp or rexp claims in the token automatically.

finalize_auth is a built-in method that completes the authentication process after the redirect from Descope, exchanges the authorization code for tokens, verifies ID token, and creates a session token for the user.

By using yield, Reflex processes each step reactively—first finalizing auth, then deciding the next redirect.

import reflex as rx
from reflex_descope_auth import DescopeAuthState


class AuthState(DescopeAuthState):
    @rx.event
    async def auth_redirect(self):
        yield DescopeAuthState.finalize_auth()
        if getattr(self, "error_message", None):
            yield rx.redirect("/auth-error")
            return
        yield rx.redirect("/dashboard")

    @rx.event
    def check_login(self):
        if not self.logged_in:
            return rx.redirect("/")

check_login() is a custom method that acts as a page guard for any route that should only be accessible to authenticated users. It works by:

  • Checking the reactive state variable self.logged_in (which is automatically managed by DescopeAuthState).
  • If the user isn’t authenticated (self.logged_in is False), it immediately returns a redirect to the homepage (/), which serves as the login page.

This ensures that only valid sessions can access private pages like /dashboard.

Implementing OAuth social logins, magic links, and SSO

Once your Reflex app is wired up with the Reflex Descope Auth plugin, adding new authentication methods becomes incredibly simple. Descope flows let you drag, drop, and configure options like OAuth social logins, magic links, and SAML/OIDC SSO without changing any of your Reflex code.

If you want users to sign in with Google, GitHub or any other providers, you can configure these OAuth providers in the authentication methods in the console and add the OAuth action in the flow. For passwordless experiences, you can switch to magic link login by adding the built-in magic link action in your flow. And when your app needs enterprise authentication, you can add a SSO action to the same flow.

If you’d like to try SSO end-to-end, you can spin up a tenant and walk through the setup using the Mock SAML Testing guide. And if your organization already uses a SAML IdP such as Okta, ADFS, or PingFederate, you can simply plug in its metadata URL in place of the mock provider.

With everything controlled through Descope’s visual flow editor, your Reflex integration remains the same, with the plugin automatically handling redirects and token exchange. Here’s an example flow that combines the social login, magic link and SSO authentication:

Flow with social login, magic links and SSO authentication methods

One of the biggest hurdles is enabling each customer to set up Single-Sign-On (SSO) with their own identity provider (IdP). Descope’s SSO Setup Suite simplifies how organizations configure Single Sign-On (SSO) for their tenants. Instead of manually exchanging metadata and troubleshooting IdP configurations, the suite offers a guided, self-service flow that allows tenant admins to set up and test their SSO integration (SAML or OIDC) end-to-end.

With built-in steps for attribute mapping, SCIM provisioning, and domain routing, it significantly reduces setup time and support overhead by empowering customers to get their SSO running smoothly with minimal developer involvement.

Displaying the user name and logging out a user

Once a user successfully signs in, we can use AuthState.userinfo to display the user’s profile details such as their name, while AuthState.logged_in tracks the current authentication status. userinfo comes from the JWT claims inside the session token. The plugin extracts claims from the token and userinfo simply reads from there. To be able to add custom attributes, you can define custom claims in Descope.

In the code snippet below, the header displays a personalized welcome message along with a Logout button when the user is signed in and AuthState.logout will allow the user to log out.

import reflex as rx
from app.states.auth_state import AuthState


def header_bar() -> rx.Component:
    """The header bar component."""
    return rx.el.div(
        rx.el.div(
            rx.cond(
                AuthState.logged_in,
                rx.el.div(
                    rx.el.span(
                        f"Welcome, {AuthState.userinfo.get('name', 'User').to(str).split(' ')[0]}",
                        class_name="text-sm font-medium text-gray-700",
                    ),
                    rx.el.button(
                        "Logout",
                        on_click=AuthState.logout,
                        class_name="px-4 py-1 text-sm text-white bg-red-600 rounded-md hover:bg-red-700",
                    ),
                    class_name="flex items-center space-x-4",
                ),
                rx.fragment(),
            )
        ),
        class_name="flex items-center justify-between h-12 px-6 bg-white border-b border-gray-200",
    )

The dashboard will then look similar to this:

Dashboard is displayed after the user is logged in

Reflex SSO and authentication with Descope

With Descope, implementing authentication in a Reflex app becomes both simple and highly flexible. Descope’s flows let you design every step of the user journey without managing complex logic yourself. The Reflex Descope Auth plugin then brings these flows directly into your application, handling the redirects and tokens. Together, they give you a powerful, low-code way to add secure, customizable authentication to any Reflex project.

If you have any questions about Descope or a customer identity project we can help with, book a demo with our auth experts.

Unlock AI on Your Laptop: A Deep Dive into Small Language Models (SLMs) – Phi-3, Gemma, and Llama 3

I created a new website: Free Access to the 8 Volumes on Typescript & AI Masterclass, no registration required. Choose Volume and chapter on the menu on the left. 160 Chapters and hundreds of quizzes at the end of chapters.

The AI revolution is no longer confined to massive data centers. A new wave of “small language models” (SLMs) is democratizing access to powerful AI, bringing cutting-edge capabilities directly to your laptop, phone, and even web browser. Forget needing expensive GPUs and cloud subscriptions – models like Phi-3, Gemma, and Llama 3 are changing the game. This post explores the theory behind SLMs, how they work, and provides a practical code example to get you started building your own local AI applications.

The Rise of Efficient AI: Why Small Language Models Matter

For a while, the narrative in AI was simple: bigger is better. Large Language Models (LLMs) like GPT-4 demonstrated incredible abilities, but at a significant cost. Running these behemoths requires substantial computing power, making them inaccessible to many developers and everyday users. This created a barrier to entry and limited the potential for widespread AI adoption.

Small Language Models represent a paradigm shift. They prioritize efficiency over sheer size, achieving impressive performance with a fraction of the parameters. This isn’t about sacrificing intelligence; it’s about smarter engineering and innovative training techniques. SLMs are built upon the same foundational Transformer architecture as their larger counterparts, but they’ve been meticulously optimized for speed, reduced memory footprint, and on-device execution. This opens up exciting possibilities for privacy-preserving AI, offline functionality, and real-time applications.

How Do SLMs Achieve High Performance with Fewer Parameters?

The core of SLMs lies in two key strategies: knowledge distillation and architectural optimization.

Knowledge Distillation: Imagine a master chef teaching an apprentice. The master doesn’t just provide the final dish; they explain the nuances of flavor, technique, and ingredient selection. Similarly, knowledge distillation involves training a smaller “student” model to mimic the behavior of a larger, more powerful “teacher” model. The student learns not just what the correct answer is, but why it’s correct, by analyzing the probability distributions generated by the teacher. Phi-3, for example, was trained on high-quality data generated by a larger model, effectively compressing vast knowledge into a compact 3.8 billion parameter package.

Architectural Optimization: SLMs aren’t simply scaled-down versions of LLMs. They employ clever architectural tweaks to improve efficiency. This includes reducing the number of Transformer layers, utilizing techniques like Grouped-Query Attention (GQA) – which reduces computational load by sharing key and value projections – and employing other pruning and quantization methods. GQA, used in Llama 3-8B, is analogous to a web server using a connection pool; it reuses resources instead of creating new ones for every request.

Quantization: The Secret to Running AI on Consumer Hardware

Even with optimized architectures, running SLMs on limited hardware requires further compression. This is where quantization comes in. Traditionally, neural networks use 32-bit floating-point numbers (FP32) for calculations. Quantization reduces this precision to 8-bit integers (INT8) or even 4-bit integers (INT4).

Think of it like compressing an audio file. An uncompressed WAV file is large and pristine, while a high-quality MP3 is significantly smaller with minimal perceptible loss in audio quality. Quantization achieves a similar trade-off: reducing model size and accelerating inference with a negligible impact on performance. A 4-billion parameter model that would normally require 16GB of VRAM in FP32 can run comfortably on a laptop with integrated graphics using INT4 quantization.

The SLM Ecosystem: Local and Browser-Based Inference

The theoretical advancements in SLMs are coupled with practical tools that make them accessible.

  • Ollama (Local Inference): Ollama simplifies running LLMs locally. It abstracts away the complexities of model weights, tokenizers, and inference engines, acting as a local API server for AI models. You can interact with SLMs using simple HTTP requests, enjoying zero latency and complete data privacy.
  • Transformers.js and ONNX Runtime Web (Browser Inference): Running models directly in the browser is the ultimate edge AI experience. ONNX Runtime Web executes models in the browser using WebAssembly (WASM) for universal CPU execution or WebGPU for GPU acceleration. Transformers.js provides a JavaScript API for downloading, loading, and running ONNX models, making browser-based AI development seamless.

Code Example: Building a Local SLM Chat Interface with TypeScript

Let’s put theory into practice. This example demonstrates a basic web application that interfaces with a local SLM running via Ollama.

// --- Type Definitions ---
type ChatMessage = {
    role: 'user' | 'assistant';
    content: string;
};

interface OllamaGenerateRequest {
    model: string;
    prompt: string;
    stream: boolean;
    options?: {
        temperature?: number;
        num_ctx?: number;
    };
}

interface OllamaResponseChunk {
    response?: string;
    done: boolean;
    context?: number[];
    total_duration?: number;
}

// --- Configuration ---
const CONFIG = {
    OLLAMA_API_URL: 'http://localhost:11434/api/generate',
    DEFAULT_MODEL: 'phi3:mini',
    MAX_RETRIES: 3,
};

// --- State Management ---
const state = {
    chatHistory: [],
    isGenerating: false,
};

// --- DOM Elements ---
const elements = {
    chatContainer: document.getElementById('chat-container') as HTMLDivElement,
    userInput: document.getElementById('user-input') as HTMLTextAreaElement,
    sendButton: document.getElementById('send-button') as HTMLButtonElement,
    statusIndicator: document.getElementById('status') as HTMLSpanElement,
};

// --- Core Logic ---
async function sendPromptToOllama(prompt: string): Promise<void> {
    if (state.isGenerating) return;

    state.isGenerating = true;
    updateUIStatus('Generating...');

    const requestBody: OllamaGenerateRequest = {
        model: CONFIG.DEFAULT_MODEL,
        prompt: prompt,
        stream: true,
        options: {
            temperature: 0.7,
        },
    };

    try {
        const response = await fetch(CONFIG.OLLAMA_API_URL, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(requestBody),
        });

        if (!response.ok) {
            throw new Error(`Ollama API error: ${response.statusText}`);
        }

        const reader = response.body?.getReader();
        if (!reader) throw new Error('No response body');

        const decoder = new TextDecoder();
        let fullResponse = '';

        while (true) {
            const { done, value } = await reader.read();
            if (done) break;

            const chunkText = decoder.decode(value, { stream: true });
            const lines = chunkText.split('n').filter(line => line.trim() !== '');

            for (const line of lines) {
                try {
                    const json: OllamaResponseChunk = JSON.parse(line);
                    if (json.response) {
                        fullResponse += json.response;
                        updateChatDisplay('assistant', fullResponse, true);
                    }
                    if (json.done) {
                        state.chatHistory.push({ role: 'assistant', content: fullResponse });
                        renderChatHistory();
                    }
                } catch (e) {
                    console.warn('Skipped incomplete JSON chunk');
                }
            }
        }
    } catch (error) {
        console.error('Error communicating with Ollama:', error);
        updateChatDisplay('assistant', `Error: ${error.message}`, false);
    } finally {
        state.isGenerating = false;
        updateUIStatus('Ready');
        elements.userInput.disabled = false;
        elements.sendButton.disabled = false;
        elements.userInput.focus();
    }
}

// --- UI Helper Functions ---
function renderChatHistory(): void {
    elements.chatContainer.innerHTML = '';
    state.chatHistory.forEach(msg => {
        const msgDiv = document.createElement('div');
        msgDiv.className = `message ${msg.role}`;
        msgDiv.textContent = msg.content;
        elements.chatContainer.appendChild(msgDiv);
    });
    elements.chatContainer.scrollTop = elements.chatContainer.scrollHeight;
}

function updateChatDisplay(role: 'assistant', content: string, isStreaming: boolean): void {
    let lastMsg = elements.chatContainer.lastElementChild;
    if (isStreaming && lastMsg && lastMsg.classList.contains('assistant')) {
        lastMsg.textContent = content;
    } else if (!isStreaming) {
        const msgDiv = document.createElement('div');
        msgDiv.className = `message ${role}`;
        msgDiv.textContent = content;
        elements.chatContainer.appendChild(msgDiv);
    }
    elements.chatContainer.scrollTop = elements.chatContainer.scrollHeight;
}

function updateUIStatus(status: string): void {
    elements.statusIndicator.textContent = status;
    elements.statusIndicator.style.color = status === 'Generating...' ? '#f59e0b' : '#10b981';
}

// --- Event Listeners ---
elements.sendButton.addEventListener('click', handleSend);
elements.userInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        handleSend();
    }
});

updateUIStatus('Ready');

The Future is Small: Embracing the SLM Revolution

Small Language Models are not a step back from LLMs; they are a crucial evolution. By prioritizing efficiency and accessibility, they unlock a world of possibilities for AI integration into our daily lives. From on-device assistants to privacy-focused applications, SLMs are paving the way for a more decentralized and democratized AI future. The tools and techniques discussed here provide a solid foundation for exploring this exciting new frontier. Start experimenting with Phi-3, Gemma, and Llama 3 – the power of AI is now within reach, right on your laptop.

The concepts and code demonstrated here are drawn directly from the comprehensive roadmap laid out in the book The Edge of AI. Local LLMs (Ollama), Transformers.js, WebGPU, and Performance Optimization Amazon Link of the AI with JavaScript & TypeScript Series.
The ebook is also on Leanpub.com: https://leanpub.com/EdgeOfAIJavaScriptTypeScript.

👉 Free Access now to the TypeScript & AI Series on Programming Central, it includes 8 Volumes, 160 Chapters and hundreds of quizzes for every chapter.

Workaround: Testing Logic Apps Data Mapper Maps on macOS

Mapping

The Logic Apps Data Mapper in VS Code lets you create XML transformation maps on macOS. You can drag connections between source and target schemas, add functions, save — the visual designer works. The .lml file gets written to disk. You can even compile it to XSLT 3.0.

But you can’t test it.

Mapping

When you click Test Map on macOS, you get this:

400 – BadRequest: The .NET framework worker could not be found.

The Test Map feature depends on the Azure Functions .NET Framework worker — a Windows-only component. The button is there, the backend isn’t. You can create maps all day — you just can’t verify they produce correct output without deploying to Azure or switching to a Windows machine.

This post shows how to compile and run Data Mapper maps locally on any platform using the Logic Apps testing SDK and standard .NET unit tests.

The options

When you can’t use Test Map, you have three paths:

  1. Deploy to Azure and test there — works, but slow and requires infrastructure for every iteration
  2. Start func host start locally with Azurite, call the REST compile endpoint — heavyweight, requires Azurite and a warm Azure Functions host, flaky on macOS
  3. Use DataMapTestExecutor from the testing SDK — no host, no Azurite, no Azure, just dotnet test

Option 3 is what this post covers.

DataMapTestExecutor

The Logic Apps testing SDK (Microsoft.Azure.Workflows.WebJobs.Tests.Extension v1.0.1) includes a class called DataMapTestExecutor. It does two things:

  1. Compiles LML to XSLT — GenerateXslt(mapName) reads the .lml from Artifacts/MapDefinitions/, resolves schemas, custom functions, and inline XSLT snippets, and returns the compiled XSLT 3.0 as bytes.
  2. Runs the transform — RunMapAsync(xslt, input) takes the compiled XSLT and input XML bytes, executes the transform via SaxonCS, and returns the result.

This is the same compilation and execution pipeline that the designer’s Test Map button and the func host runtime use — you’re running the same code path, just without the Azure Functions host scaffolding.

Setting up the test project

Project structure

The test project lives alongside the Logic App project:

MyWorkspace/
├── MyLogicApp/                     # Logic App project (Standard)
│   ├── Artifacts/
│   │   ├── MapDefinitions/         # .lml source files
│   │   ├── Maps/                   # compiled .xslt (generated)
│   │   ├── Schemas/                # XSD / JSON schemas
│   │   ├── SampleData/             # sample input XML
│   │   └── DataMapper/
│   │       └── Extensions/
│   │           ├── Functions/      # custom function XML files
│   │           └── InlineXslt/     # inline XSLT snippets
│   └── .vscode/
└── Tests/
    └── MyLogicApp.Tests/           # test project
        ├── MyLogicApp.Tests.csproj
        └── OrderToShipment/
            ├── OrderToShipmentTest.cs
            └── Order-input.xml     # test-specific input data

The .csproj

PackageReference

The critical dependency is Microsoft.Azure.Workflows.WebJobs.Tests.Extension v1.0.1 — not v1.0.0. The DataMapTestExecutor class doesn’t exist in v1.0.0.

Pointing at the Logic App project root

DataMapTestExecutor needs the Logic App project root — the directory that contains Artifacts/. It scans from there to find schemas, custom functions, and map definitions. The path is relative from the test’s output directory:

private static readonly string ProjectRoot = Path.GetFullPath(
    Path.Combine(Directory.GetCurrentDirectory(),
        "..", "..", "..", "..", "..", "MyLogicApp"));

The number of .. segments depends on your folder structure. Count from bin/Debug/net8.0/ up to wherever the Logic App project lives.

Writing the tests

Setup: compile the LML once

Each test class points at the Logic App project root and names the LML map to compile. [TestInitialize] runs before each test method — it compiles the LML to XSLT so every test starts from a freshly compiled stylesheet.

[TestClass]
public class Order2Shipment20260327DataMapTest
{
    private static readonly string ProjectRoot = Path.GetFullPath(
        Path.Combine(Directory.GetCurrentDirectory(),
            "..", "..", "..", "..", "..", "LADataMapperXsltDebugger"));

    private static readonly string MapName = "Order2Shipment_20260327-lml";

    private static readonly string InputPath = Path.Combine(
        Directory.GetCurrentDirectory(), "Order2Shipment20260327", "Order-input.xml");

    private DataMapTestExecutor executor;
    private byte[] xslt;

    [TestInitialize]
    public async Task Setup()
    {
        executor = new DataMapTestExecutor(ProjectRoot);
        xslt = await executor.GenerateXslt(MapName);
    }
}

GenerateXslt takes the map name without path or extension. It finds the matching .lml in Artifacts/MapDefinitions/, reads the schemas from the LML header, loads custom functions, and compiles to XSLT 3.0.

Parsing the result

RunMapAsync returns a JToken with the transform output encoded as base64:

{ "$content-type": "application/xml", "$content": "PFNoaXBtZW50cy4uLg==" }

A helper to decode it:

private static XDocument ParseResult(JToken result)
{
    var base64 = result["$content"]!.Value<string>()!;
    var xml = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
    return XDocument.Parse(xml);
}

Map 1: Order to Shipment — simple field mappings and filtering

This map transforms a single Order into a Shipment. It tests custom extension functions (prefixShipRef, reformatDate, executionDateTime), direct field mappings, a $for loop over line items, and a $if filter that excludes items with Quantity < 1.

Testing individual fields

Each mapping requirement gets its own test for clear failure diagnostics:

[TestMethod]
public async Task Transform_ShipmentRefHasPrefix()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var shipmentRef = xml.Root!.Element("Shipment")!.Element("ShipmentRef")!.Value;
    Assert.AreEqual("SHP-ORD-1001", shipmentRef);
}

[TestMethod]
public async Task Transform_ShipDateReformatted()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var shipDate = xml.Root!.Element("Shipment")!.Element("ShipDate")!.Value;
    Assert.AreEqual("31/10/2025", shipDate);
}

Testing filtering logic

The test input has 3 items — one with Quantity=0 that the $if(xpath("ns0:Quantity >= 1")) filter should exclude:

[TestMethod]
public async Task Transform_FilteredItemExcluded()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var products = xml.Root!.Element("Shipment")!.Element("Lines")!
        .Elements("Line")
        .Select(l => l.Element("Product")!.Value)
        .ToList();

    CollectionAssert.Contains(products, "Wireless Mouse");
    CollectionAssert.Contains(products, "Keyboard");
    CollectionAssert.DoesNotContain(products, "Defective Cable");
}

Validating the entire output

One test that checks everything — structure, values, element order, and the filter:

[TestMethod]
public async Task Transform_ValidateEntireOutput()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);

    Assert.AreEqual("Shipments", xml.Root!.Name.LocalName);
    var shipment = xml.Root.Element("Shipment")!;

    Assert.AreEqual("SHP-ORD-1001", shipment.Element("ShipmentRef")!.Value);
    Assert.AreEqual("31/10/2025", shipment.Element("ShipDate")!.Value);
    Assert.AreEqual("John Doe", shipment.Element("Recipient")!.Value);

    var execDate = shipment.Element("ExecutionDate")!.Value;
    Assert.IsTrue(execDate.EndsWith("Z") && DateTime.TryParse(execDate, out _),
        $"ExecutionDate not valid UTC: {execDate}");

    var lines = shipment.Element("Lines")!.Elements("Line").ToList();
    Assert.AreEqual(2, lines.Count, "Expected 2 lines after Quantity >= 1 filter");

    Assert.AreEqual("Wireless Mouse", lines[0].Element("Product")!.Value);
    Assert.AreEqual("2", lines[0].Element("Quantity")!.Value);

    Assert.AreEqual("Keyboard", lines[1].Element("Product")!.Value);
    Assert.AreEqual("1", lines[1].Element("Quantity")!.Value);

    var childNames = shipment.Elements().Select(e => e.Name.LocalName).ToList();
    CollectionAssert.AreEqual(
        new[] { "ShipmentRef", "ShipDate", "Recipient", "ExecutionDate", "Lines" },
        childNames, "Element order must match ShipmentOutput.xsd");
}

Map 2: PurchaseOrders to ShippingManifest — attributes, nested loops, computed values

This is a more complex map. It transforms multiple PurchaseOrders into a ShippingManifest with XML attributes, nested $for loops (Orders → Items), computed aggregates (totalWeight, totalValue), and custom functions (shipId, formatAddress, hazardousFlag, parcelSubtotal).

The test class follows the same pattern — different map name, different input:

private static readonly string MapName = "OrderToShipment-po2sm-lml";

private static readonly string InputPath = Path.Combine(
    Directory.GetCurrentDirectory(), "OrderToShipment", "PurchaseOrders-input.xml");

Testing attributes and computed values

[TestMethod]
public async Task Transform_FirstShipment_ShipIdAndPriority()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var first = xml.Root!.Element("Shipment")!;
    Assert.AreEqual("SHP-0847", first.Attribute("shipId")?.Value);
    Assert.AreEqual("EXPRESS", first.Attribute("priority")?.Value);
    Assert.AreEqual("true", first.Attribute("hazmat")?.Value);
}

[TestMethod]
public async Task Transform_FirstShipment_Parcels()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var parcels = xml.Root!.Element("Shipment")!.Element("Parcels")!;
    Assert.AreEqual("3", parcels.Attribute("lineCount")?.Value);
    Assert.AreEqual("kg", parcels.Attribute("weightUnit")?.Value);
    Assert.AreEqual("35.4", parcels.Attribute("totalWeight")?.Value);
}

Testing nested loop output with hazmat flag

The inner $for loops over each Order’s Items. The hazardousFlag custom function maps @hazardous="true" to "Y":

[TestMethod]
public async Task Transform_FirstShipment_HazmatParcel()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var parcels = xml.Root!.Element("Shipment")!.Element("Parcels")!.Elements("Parcel");
    var hazmat = parcels.FirstOrDefault(p => p.Attribute("sku")?.Value == "CHEM-0055");
    Assert.IsNotNull(hazmat);
    Assert.AreEqual("Y", hazmat.Attribute("hazardous")?.Value);
    Assert.AreEqual("128.00", hazmat.Attribute("subtotal")?.Value);  // 4 × 32.00
}

[TestMethod]
public async Task Transform_FirstShipment_TotalValue()
{
    var input = await File.ReadAllBytesAsync(InputPath);
    var result = await executor.RunMapAsync(xslt, input);
    var xml = ParseResult(result);
    var total = xml.Root!.Element("Shipment")!.Element("TotalValue")!;
    Assert.AreEqual("1448.00", total.Element("Amount")?.Value);   // 1014 + 128 + 306
    Assert.AreEqual("EUR", total.Element("Currency")?.Value);
}

Running the tests

UnitTestingExecution

The first test in each suite takes ~400–500ms (JIT + LML compilation). Subsequent tests reuse the compiled XSLT from [TestInitialize] and run in single-digit milliseconds.

What DataMapTestExecutor does under the hood

DataMapTestExecutor(projectRoot)
  → scans Artifacts/Schemas/ for XSD and JSON schemas
  → scans Artifacts/DataMapper/Extensions/Functions/*.xml for custom functions
  → scans Artifacts/DataMapper/Extensions/InlineXslt/*.xml for XSLT snippets

GenerateXslt(mapName)
  → reads Artifacts/MapDefinitions/{mapName}.lml
  → resolves schema references from the LML header
  → compiles LML → XSLT 3.0 via DataMapperUtility
  → returns byte[] of the compiled stylesheet

RunMapAsync(xslt, input)
  → loads the XSLT via SaxonCS (bundled in the SDK)
  → runs the transform against the input bytes
  → returns JToken with base64-encoded XML result

Known gotchas

v1.0.0 vs v1.0.1. DataMapTestExecutor doesn’t exist in v1.0.0 of the SDK. If dotnet test fails with a missing type, check your Microsoft.Azure.Workflows.WebJobs.Tests.Extension version.

ProjectRoot must point to the Logic App root. The path must resolve to the directory containing Artifacts/ — not the test project, not the solution root. If GenerateXslt throws “map not found”, your path is wrong.

Zero-parameter custom functions. If any <function> in Functions/*.xml has no <param> elements, the SDK hits a NullReferenceException on function.parameters.Length. The entire XML file is silently skipped, making all functions in it “unrecognized”. Workaround: add a dummy parameter <param name="_unused" as="xs:string"/>.

Custom function namespace scoping. Function files are parsed in isolation. Namespace prefixes declared in the main stylesheet (like xmlns:tns="...") are not available inside function XML files. Using tns:ElementName in a function’s XPath will throw a NullReferenceException at init time.

Base64 response format. RunMapAsync does not return raw XML. The result is a JToken with $content containing base64-encoded output. You must decode it before asserting.

Beyond testing: the designer’s save problem

And even if Test Map worked, the visual designer has its own problems — changes that revert on reopen, string literals that lose quotes, expressions that get silently rewritten. I wrote a separate post about editing LML files directly as a more reliable alternative to the designer.

The XSLT Debugger extension is on the VS Code Marketplace for macOS and Windows.