Skip to content

Latest commit

 

History

History
303 lines (250 loc) · 12.2 KB

File metadata and controls

303 lines (250 loc) · 12.2 KB

Recipe: ASP.NET Minimal API (C# 12 / .NET 8) + Angular 18

End-to-end recipe for wiring a C# .NET 8 backend (entities + EF Core + ASP.NET Minimal API, all metadata-driven via MetaObjects.Codegen) to an Angular 18 frontend (services + reactive forms + grid, generated by @metaobjectsdev/codegen-ts-angular + the @metaobjectsdev/angular runtime). The two halves are bound only by the cross-port REST contract at docs/features/api-contract.md — no runtime coupling to MetaObjects in either process.

1. Stack

Layer Choice
Backend language / runtime C# 12 on .NET 8
Backend framework ASP.NET Core Minimal API
ORM EF Core 8 (Postgres via Npgsql.EntityFrameworkCore.PostgreSQL, or SQLite)
Backend codegen MetaObjects.Codegen (entities + AppDbContext + RoutesGenerator)
Backend CLI dotnet meta (in MetaObjects.Cli)
Frontend framework Angular 18 — standalone components, signals, no NgModules
Frontend runtime @metaobjectsdev/angular (peer-deps @angular/core, @angular/common, @angular/forms, @tanstack/angular-table)
Frontend codegen @metaobjectsdev/codegen-ts-angular (services + forms + grids)
Frontend HTTP HttpClient via provideHttpClient()
Dev-server ports Angular 4200, ASP.NET 5000 (HTTP) / 5001 (HTTPS)

The Angular runtime and codegen packages live in the TypeScript workspace (client/web/packages/angular/ + server/typescript/packages/codegen-ts-angular/) because Angular IS TypeScript. They are completely backend-agnostic — the same packages serve any backend that conforms to the API contract.

2. Backend setup — Program.cs

using Acme.Generated;                       // namespace produced by dotnet meta gen
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// 2.1 — DbContext: the generated AppDbContext is plain EF Core; wire your dialect.
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

// 2.2 — CORS: open the dev origin so the Angular dev-server (4200) can call us.
// The generated routes do NOT enforce CORS themselves — it's a consumer concern.
const string AngularDev = "AngularDev";
builder.Services.AddCors(o => o.AddPolicy(AngularDev, p => p
    .WithOrigins("http://localhost:4200")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));                  // .AllowCredentials() if you set credentials: "include" on the fetcher

// 2.3 — JSON: camelCase property names + ISO 8601 dates are System.Text.Json's
// defaults. The contract expects integer minor-units for currency on the wire;
// the generated entity uses `long`, so STJ emits it as a JSON number — correct.
builder.Services.ConfigureHttpJsonOptions(opt =>
{
    opt.SerializerOptions.PropertyNamingPolicy =
        System.Text.Json.JsonNamingPolicy.CamelCase;
    // STJ DateTime defaults to ISO 8601 with timezone — no extra config needed.
});

var app = builder.Build();

app.UseCors(AngularDev);

// 2.4 — Mount the generated routes under /api (matches metaobjects.config.ts apiPrefix).
// One Map<Entity>Routes extension per entity (RoutesGenerator emits these).
app.MapAuthorRoutes("/api");
// app.MapPostRoutes("/api");  // ... one call per entity

app.Run();

Connection string in appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Host=localhost;Database=acme_blog;Username=acme;Password=acme"
  }
}

For a SQLite dev setup, swap UseNpgsql for UseSqlite("Data Source=app.db").

3. Frontend setup — app.config.ts

The Angular 18 application config wires three things: Angular's HttpClient, the EntityFetcher (over that HttpClient + the API base URL), and the router.

// src/app/app.config.ts
import { ApplicationConfig, inject, provideZoneChangeDetection } from "@angular/core";
import { provideHttpClient, HttpClient } from "@angular/common/http";
import { provideRouter } from "@angular/router";
import { provideEntityFetcher } from "@metaobjectsdev/angular";
import { firstValueFrom } from "rxjs";
import { environment } from "../environments/environment";
import { routes } from "./app.routes";

// 3.1 — Build the fetcher inside an Angular DI factory so it can inject HttpClient.
//      provideEntityFetcher accepts either a function OR a factory; this is the factory form.
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideHttpClient(),
    provideRouter(routes),
    provideEntityFetcher(() => {
      const http = inject(HttpClient);
      return async <T,>(path: string, init?: RequestInit): Promise<T> => {
        // path always starts with apiPrefix (e.g. "/api/authors?limit=25").
        // Prepend the environment-resolved base URL.
        const url = environment.apiBase + path;
        const method = (init?.method ?? "GET").toUpperCase();
        const body = init?.body
          ? typeof init.body === "string" ? JSON.parse(init.body) : init.body
          : undefined;
        const obs = http.request<T>(method, url, {
          body,
          withCredentials: true,                  // matches the backend AllowCredentials()
          headers: { "Content-Type": "application/json", ...(init?.headers as Record<string,string> ?? {}) },
          observe: "body",
          responseType: "json",
        });
        return firstValueFrom(obs);
      };
    }),
  ],
};

Per-env base URL in src/environments/environment.ts (dev) and environment.prod.ts (prod):

// environment.ts
export const environment = { production: false, apiBase: "http://localhost:5000" };
// environment.prod.ts
export const environment = { production: true,  apiBase: "https://api.example.com" };

main.ts bootstrap stays vanilla Angular:

import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { appConfig } from "./app/app.config";

bootstrapApplication(AppComponent, appConfig);

4. End-to-end Author flow

4.1 Metadata — metaobjects/meta.blog.json

{ "metadata.root": {
    "package": "acme::blog",
    "children": [
      { "object.entity": {
        "name": "Author",
        "children": [
          { "source.rdb":   { "@table": "authors" } },
          { "field.long":   { "name": "id" } },
          { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
          { "field.string": { "name": "bio",  "@maxLength": 2000 } },
          { "field.boolean":{ "name": "active" } },
          { "identity.primary": { "@fields": "id", "@generation": "increment" } },
          { "layout.dataGrid": {
              "name": "default",
              "@columns": ["name", "bio", "active"],
              "@defaultSortField": "name",
              "@defaultSortOrder": "asc",
              "@pageSize": 25
          }}
        ]
      }}
    ]
}}

4.2 Codegen — two gen commands run both halves

The dotnet meta CLI (MetaObjects.Cli) emits the C# half; the Node meta (@metaobjectsdev/cli) emits the Angular half. Most repos run them as two adjacent commands in the build pipeline.

C# half — driven by MetaObjects.Cli's config (appsettings.codegen.json or CLI flags). Emits:

  • Author.g.cs — POCO with [Table("authors")], [Key], [Column], etc.
  • AppDbContext.g.csDbSet<Author> Authors { get; set; }
  • AuthorRoutes.g.csMapAuthorRoutes(this IEndpointRouteBuilder, string prefix)

TypeScript / Angular half — driven by metaobjects.config.ts. Emits:

  • Author.ts — entity-constants module (paths, types, allowlists)
  • Author.service.ts@Injectable({ providedIn: 'root' }) with list/get/create/update/delete
  • Author.form.component.ts — standalone reactive form
  • Author.grid.component.ts — standalone grid pre-wired to the service

4.3 Demo

  1. Navigate to /authors in the Angular app.
  2. AuthorGridComponent calls AuthorService.list({ limit: 25, sort: "name:asc", withCount: 1 }).
  3. The service issues GET http://localhost:5000/api/authors?limit=25&sort=name:asc&withCount=1.
  4. The generated MapAuthorRoutes handler honours all three qs params and returns { rows: [...], total: 137 }.
  5. The grid renders the page; the pagination footer shows "1–25 of 137".
  6. Click the + toolbar button; AuthorFormComponent opens.
  7. Submit → POST http://localhost:5000/api/authors with { name, bio, active }.
  8. Backend returns 201 Created with the created row (id populated).
  9. Grid invalidates + refetches.

5. CORS gotchas + base-URL configuration

  • AllowCredentials() + WithOrigins(...) must agree: if your Angular fetcher sends credentials: "include" (cookies / session), the backend CORS policy cannot use AllowAnyOrigin() — you must list explicit origins. The example above does this correctly.
  • Preflight OPTIONS is handled automatically by ASP.NET's CORS middleware when you call app.UseCors(...) before route mapping. The generated routes do not need to handle OPTIONS themselves.
  • Multiple environments: keep apiBase in environment.ts / environment.prod.ts so the prod build hits https://api.example.com while the dev build hits http://localhost:5000. Never hard-code URLs in the fetcher.
  • Secure cookies in prod: set SameSite=None; Secure on auth cookies when the API origin differs from the SPA origin (Cross-site cookies need Secure + None to flow at all).
  • CSRF: if you rely on cookies for auth, layer CSRF protection in your ASP.NET pipeline (AddAntiforgery + a token header the Angular fetcher reads from a cookie and sends back). Out of scope for codegen; standard ASP.NET work.

6. Run commands

# Terminal 1 — backend (port 5000)
cd backend
dotnet run

# Terminal 2 — frontend (port 4200)
cd frontend
npm start   # equivalent to: ng serve --port 4200

Or chain them in a script (root package.json or a justfile):

// package.json (root)
{
  "scripts": {
    "dev:backend":  "dotnet run --project backend",
    "dev:frontend": "ng serve --project frontend --port 4200",
    "dev":          "concurrently \"npm:dev:backend\" \"npm:dev:frontend\""
  }
}

For codegen on metadata change:

# C# half (one-shot)
cd backend && dotnet run --project ../tools/MetaObjects.Cli -- gen

# TS / Angular half (one-shot; re-run on metadata change)
cd frontend && npx meta gen

7. Production deploy notes

The two halves deploy independently:

  • Angularng build --configuration production produces a static dist/ directory. Drop it on any CDN: Cloudflare Pages, Vercel, Azure Static Web Apps, Netlify, S3 + CloudFront. environment.prod.ts.apiBase must point at the deployed API origin.
  • ASP.NET Minimal APIdotnet publish -c Release -o out produces a self-contained app. Deploy targets: Azure App Service (native fit), AWS Elastic Beanstalk / ECS, Railway, Render, or any container host. Bind the ConnectionStrings:Default env-var to your managed Postgres.
  • Single-origin — if you serve the SPA from the same origin as the API (e.g. ASP.NET hosts the Angular dist/ as static files via app.UseStaticFiles()), drop the CORS middleware entirely — same-origin requests don't need it.

A common deployment pair: Angular on Cloudflare Pages, ASP.NET on Azure App Service, Postgres on Neon / Supabase / Azure Database for PostgreSQL.

8. See also