[{"data":1,"prerenderedAt":10875},["ShallowReactive",2],{"blog-all-en":3},[4,2125,2985,4480,5477,7617,8324,8977,9840],{"id":5,"title":6,"body":7,"date":2110,"description":2111,"excerpt":2112,"extension":2113,"meta":2114,"navigation":347,"path":2115,"readTime":116,"seo":2116,"slug":2117,"stem":2118,"tags":2119,"__hash__":2124},"en_blog/en/blog/typescript-interfaces.md","TypeScript in Practice: Hierarchical Interfaces on a Real API",{"type":8,"value":9,"toc":2100},"minimark",[10,14,23,28,31,323,327,330,512,527,531,812,816,819,1053,1057,1060,1519,1522,1611,1626,1630,1633,1794,1808,1812,1815,2057,2061,2064,2093,2096],[11,12,6],"h1",{"id":13},"typescript-in-practice-hierarchical-interfaces-on-a-real-api",[15,16,17,18,22],"p",{},"Most TypeScript tutorials demonstrate trivial examples: ",[19,20,21],"code",{},"interface User { name: string; age: number }",". Production code is considerably more involved. External APIs return nested structures, conditionally optional fields, and type unions that vary by state. Here is how to model all of that cleanly, starting from a real-world case.",[24,25,27],"h2",{"id":26},"the-starting-point-an-api-with-a-hierarchical-structure","The Starting Point: An API With a Hierarchical Structure",[15,29,30],{},"Consider a certificate management API that returns structures of the following shape:",[32,33,38],"pre",{"className":34,"code":35,"language":36,"meta":37,"style":37},"language-json shiki shiki-themes github-dark github-light","{\n  \"account\": {\n    \"id\": \"ACC-001\",\n    \"name\": \"EDF Production\",\n    \"type\": \"PRODUCER\"\n  },\n  \"certificates\": [\n    {\n      \"id\": \"GO-2024-001\",\n      \"volume\": 1500.5,\n      \"unit\": \"MWh\",\n      \"period\": { \"from\": \"2024-01-01\", \"to\": \"2024-01-31\" },\n      \"status\": \"ACTIVE\",\n      \"metadata\": {\n        \"technology\": \"WIND\",\n        \"country\": \"FR\",\n        \"installation_id\": \"INS-042\"\n      }\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"per_page\": 50,\n    \"total\": 312\n  }\n}\n","json","",[19,39,40,49,59,75,88,99,105,114,120,133,146,159,190,203,211,224,237,248,254,260,266,274,287,300,311,317],{"__ignoreMap":37},[41,42,45],"span",{"class":43,"line":44},"line",1,[41,46,48],{"class":47},"sQ3_J","{\n",[41,50,52,56],{"class":43,"line":51},2,[41,53,55],{"class":54},"s0DvM","  \"account\"",[41,57,58],{"class":47},": {\n",[41,60,62,65,68,72],{"class":43,"line":61},3,[41,63,64],{"class":54},"    \"id\"",[41,66,67],{"class":47},": ",[41,69,71],{"class":70},"sg6BJ","\"ACC-001\"",[41,73,74],{"class":47},",\n",[41,76,78,81,83,86],{"class":43,"line":77},4,[41,79,80],{"class":54},"    \"name\"",[41,82,67],{"class":47},[41,84,85],{"class":70},"\"EDF Production\"",[41,87,74],{"class":47},[41,89,91,94,96],{"class":43,"line":90},5,[41,92,93],{"class":54},"    \"type\"",[41,95,67],{"class":47},[41,97,98],{"class":70},"\"PRODUCER\"\n",[41,100,102],{"class":43,"line":101},6,[41,103,104],{"class":47},"  },\n",[41,106,108,111],{"class":43,"line":107},7,[41,109,110],{"class":54},"  \"certificates\"",[41,112,113],{"class":47},": [\n",[41,115,117],{"class":43,"line":116},8,[41,118,119],{"class":47},"    {\n",[41,121,123,126,128,131],{"class":43,"line":122},9,[41,124,125],{"class":54},"      \"id\"",[41,127,67],{"class":47},[41,129,130],{"class":70},"\"GO-2024-001\"",[41,132,74],{"class":47},[41,134,136,139,141,144],{"class":43,"line":135},10,[41,137,138],{"class":54},"      \"volume\"",[41,140,67],{"class":47},[41,142,143],{"class":54},"1500.5",[41,145,74],{"class":47},[41,147,149,152,154,157],{"class":43,"line":148},11,[41,150,151],{"class":54},"      \"unit\"",[41,153,67],{"class":47},[41,155,156],{"class":70},"\"MWh\"",[41,158,74],{"class":47},[41,160,162,165,168,171,173,176,179,182,184,187],{"class":43,"line":161},12,[41,163,164],{"class":54},"      \"period\"",[41,166,167],{"class":47},": { ",[41,169,170],{"class":54},"\"from\"",[41,172,67],{"class":47},[41,174,175],{"class":70},"\"2024-01-01\"",[41,177,178],{"class":47},", ",[41,180,181],{"class":54},"\"to\"",[41,183,67],{"class":47},[41,185,186],{"class":70},"\"2024-01-31\"",[41,188,189],{"class":47}," },\n",[41,191,193,196,198,201],{"class":43,"line":192},13,[41,194,195],{"class":54},"      \"status\"",[41,197,67],{"class":47},[41,199,200],{"class":70},"\"ACTIVE\"",[41,202,74],{"class":47},[41,204,206,209],{"class":43,"line":205},14,[41,207,208],{"class":54},"      \"metadata\"",[41,210,58],{"class":47},[41,212,214,217,219,222],{"class":43,"line":213},15,[41,215,216],{"class":54},"        \"technology\"",[41,218,67],{"class":47},[41,220,221],{"class":70},"\"WIND\"",[41,223,74],{"class":47},[41,225,227,230,232,235],{"class":43,"line":226},16,[41,228,229],{"class":54},"        \"country\"",[41,231,67],{"class":47},[41,233,234],{"class":70},"\"FR\"",[41,236,74],{"class":47},[41,238,240,243,245],{"class":43,"line":239},17,[41,241,242],{"class":54},"        \"installation_id\"",[41,244,67],{"class":47},[41,246,247],{"class":70},"\"INS-042\"\n",[41,249,251],{"class":43,"line":250},18,[41,252,253],{"class":47},"      }\n",[41,255,257],{"class":43,"line":256},19,[41,258,259],{"class":47},"    }\n",[41,261,263],{"class":43,"line":262},20,[41,264,265],{"class":47},"  ],\n",[41,267,269,272],{"class":43,"line":268},21,[41,270,271],{"class":54},"  \"pagination\"",[41,273,58],{"class":47},[41,275,277,280,282,285],{"class":43,"line":276},22,[41,278,279],{"class":54},"    \"page\"",[41,281,67],{"class":47},[41,283,284],{"class":54},"1",[41,286,74],{"class":47},[41,288,290,293,295,298],{"class":43,"line":289},23,[41,291,292],{"class":54},"    \"per_page\"",[41,294,67],{"class":47},[41,296,297],{"class":54},"50",[41,299,74],{"class":47},[41,301,303,306,308],{"class":43,"line":302},24,[41,304,305],{"class":54},"    \"total\"",[41,307,67],{"class":47},[41,309,310],{"class":54},"312\n",[41,312,314],{"class":43,"line":313},25,[41,315,316],{"class":47},"  }\n",[41,318,320],{"class":43,"line":319},26,[41,321,322],{"class":47},"}\n",[24,324,326],{"id":325},"modelling-the-atomic-types","Modelling the Atomic Types",[15,328,329],{},"Start with the leaf-level types — literal unions rather than bare strings:",[32,331,335],{"className":332,"code":333,"language":334,"meta":37,"style":37},"language-typescript shiki shiki-themes github-dark github-light","// types/api.ts\n\nexport type AccountType = \"PRODUCER\" | \"TRADER\" | \"CONSUMER\"\nexport type CertificateStatus = \"ACTIVE\" | \"CANCELLED\" | \"TRANSFERRED\"\nexport type EnergyTechnology = \"WIND\" | \"SOLAR\" | \"HYDRO\" | \"BIOMASS\"\nexport type CountryCode = \"FR\" | \"DE\" | \"ES\" | \"IT\" | \"BE\"\n\nexport interface DateRange {\n  from: string // ISO 8601\n  to: string\n}\n","typescript",[19,336,337,343,349,379,403,432,466,470,483,498,508],{"__ignoreMap":37},[41,338,339],{"class":43,"line":44},[41,340,342],{"class":341},"sryI4","// types/api.ts\n",[41,344,345],{"class":43,"line":51},[41,346,348],{"emptyLinePlaceholder":347},true,"\n",[41,350,351,355,358,362,365,368,371,374,376],{"class":43,"line":61},[41,352,354],{"class":353},"scx8i","export",[41,356,357],{"class":353}," type",[41,359,361],{"class":360},"s-Z4r"," AccountType",[41,363,364],{"class":353}," =",[41,366,367],{"class":70}," \"PRODUCER\"",[41,369,370],{"class":353}," |",[41,372,373],{"class":70}," \"TRADER\"",[41,375,370],{"class":353},[41,377,378],{"class":70}," \"CONSUMER\"\n",[41,380,381,383,385,388,390,393,395,398,400],{"class":43,"line":77},[41,382,354],{"class":353},[41,384,357],{"class":353},[41,386,387],{"class":360}," CertificateStatus",[41,389,364],{"class":353},[41,391,392],{"class":70}," \"ACTIVE\"",[41,394,370],{"class":353},[41,396,397],{"class":70}," \"CANCELLED\"",[41,399,370],{"class":353},[41,401,402],{"class":70}," \"TRANSFERRED\"\n",[41,404,405,407,409,412,414,417,419,422,424,427,429],{"class":43,"line":90},[41,406,354],{"class":353},[41,408,357],{"class":353},[41,410,411],{"class":360}," EnergyTechnology",[41,413,364],{"class":353},[41,415,416],{"class":70}," \"WIND\"",[41,418,370],{"class":353},[41,420,421],{"class":70}," \"SOLAR\"",[41,423,370],{"class":353},[41,425,426],{"class":70}," \"HYDRO\"",[41,428,370],{"class":353},[41,430,431],{"class":70}," \"BIOMASS\"\n",[41,433,434,436,438,441,443,446,448,451,453,456,458,461,463],{"class":43,"line":101},[41,435,354],{"class":353},[41,437,357],{"class":353},[41,439,440],{"class":360}," CountryCode",[41,442,364],{"class":353},[41,444,445],{"class":70}," \"FR\"",[41,447,370],{"class":353},[41,449,450],{"class":70}," \"DE\"",[41,452,370],{"class":353},[41,454,455],{"class":70}," \"ES\"",[41,457,370],{"class":353},[41,459,460],{"class":70}," \"IT\"",[41,462,370],{"class":353},[41,464,465],{"class":70}," \"BE\"\n",[41,467,468],{"class":43,"line":107},[41,469,348],{"emptyLinePlaceholder":347},[41,471,472,474,477,480],{"class":43,"line":116},[41,473,354],{"class":353},[41,475,476],{"class":353}," interface",[41,478,479],{"class":360}," DateRange",[41,481,482],{"class":47}," {\n",[41,484,485,489,492,495],{"class":43,"line":122},[41,486,488],{"class":487},"sFbx2","  from",[41,490,491],{"class":353},":",[41,493,494],{"class":54}," string",[41,496,497],{"class":341}," // ISO 8601\n",[41,499,500,503,505],{"class":43,"line":135},[41,501,502],{"class":487},"  to",[41,504,491],{"class":353},[41,506,507],{"class":54}," string\n",[41,509,510],{"class":43,"line":148},[41,511,322],{"class":47},[15,513,514,515,518,519,522,523,526],{},"Literal unions rather than raw ",[19,516,517],{},"string"," types: TypeScript will flag ",[19,520,521],{},"'WIND_OFFSHORE'"," immediately wherever ",[19,524,525],{},"EnergyTechnology"," is expected, rather than letting it slip through to a runtime error.",[24,528,530],{"id":529},"hierarchical-interfaces","Hierarchical Interfaces",[32,532,534],{"className":332,"code":533,"language":334,"meta":37,"style":37},"export interface Account {\n  id: string\n  name: string\n  type: AccountType\n}\n\nexport interface CertificateMetadata {\n  technology: EnergyTechnology\n  country: CountryCode\n  installation_id: string\n}\n\nexport interface Certificate {\n  id: string\n  volume: number\n  unit: \"MWh\" | \"kWh\"\n  period: DateRange\n  status: CertificateStatus\n  metadata: CertificateMetadata\n}\n\nexport interface Pagination {\n  page: number\n  per_page: number\n  total: number\n}\n\nexport interface CertificatesResponse {\n  account: Account\n  certificates: Certificate[]\n  pagination: Pagination\n}\n",[19,535,536,547,556,565,575,579,583,594,604,614,623,627,631,642,650,660,675,685,695,705,709,713,724,733,742,751,755,760,772,783,796,807],{"__ignoreMap":37},[41,537,538,540,542,545],{"class":43,"line":44},[41,539,354],{"class":353},[41,541,476],{"class":353},[41,543,544],{"class":360}," Account",[41,546,482],{"class":47},[41,548,549,552,554],{"class":43,"line":51},[41,550,551],{"class":487},"  id",[41,553,491],{"class":353},[41,555,507],{"class":54},[41,557,558,561,563],{"class":43,"line":61},[41,559,560],{"class":487},"  name",[41,562,491],{"class":353},[41,564,507],{"class":54},[41,566,567,570,572],{"class":43,"line":77},[41,568,569],{"class":487},"  type",[41,571,491],{"class":353},[41,573,574],{"class":360}," AccountType\n",[41,576,577],{"class":43,"line":90},[41,578,322],{"class":47},[41,580,581],{"class":43,"line":101},[41,582,348],{"emptyLinePlaceholder":347},[41,584,585,587,589,592],{"class":43,"line":107},[41,586,354],{"class":353},[41,588,476],{"class":353},[41,590,591],{"class":360}," CertificateMetadata",[41,593,482],{"class":47},[41,595,596,599,601],{"class":43,"line":116},[41,597,598],{"class":487},"  technology",[41,600,491],{"class":353},[41,602,603],{"class":360}," EnergyTechnology\n",[41,605,606,609,611],{"class":43,"line":122},[41,607,608],{"class":487},"  country",[41,610,491],{"class":353},[41,612,613],{"class":360}," CountryCode\n",[41,615,616,619,621],{"class":43,"line":135},[41,617,618],{"class":487},"  installation_id",[41,620,491],{"class":353},[41,622,507],{"class":54},[41,624,625],{"class":43,"line":148},[41,626,322],{"class":47},[41,628,629],{"class":43,"line":161},[41,630,348],{"emptyLinePlaceholder":347},[41,632,633,635,637,640],{"class":43,"line":192},[41,634,354],{"class":353},[41,636,476],{"class":353},[41,638,639],{"class":360}," Certificate",[41,641,482],{"class":47},[41,643,644,646,648],{"class":43,"line":205},[41,645,551],{"class":487},[41,647,491],{"class":353},[41,649,507],{"class":54},[41,651,652,655,657],{"class":43,"line":213},[41,653,654],{"class":487},"  volume",[41,656,491],{"class":353},[41,658,659],{"class":54}," number\n",[41,661,662,665,667,670,672],{"class":43,"line":226},[41,663,664],{"class":487},"  unit",[41,666,491],{"class":353},[41,668,669],{"class":70}," \"MWh\"",[41,671,370],{"class":353},[41,673,674],{"class":70}," \"kWh\"\n",[41,676,677,680,682],{"class":43,"line":239},[41,678,679],{"class":487},"  period",[41,681,491],{"class":353},[41,683,684],{"class":360}," DateRange\n",[41,686,687,690,692],{"class":43,"line":250},[41,688,689],{"class":487},"  status",[41,691,491],{"class":353},[41,693,694],{"class":360}," CertificateStatus\n",[41,696,697,700,702],{"class":43,"line":256},[41,698,699],{"class":487},"  metadata",[41,701,491],{"class":353},[41,703,704],{"class":360}," CertificateMetadata\n",[41,706,707],{"class":43,"line":262},[41,708,322],{"class":47},[41,710,711],{"class":43,"line":268},[41,712,348],{"emptyLinePlaceholder":347},[41,714,715,717,719,722],{"class":43,"line":276},[41,716,354],{"class":353},[41,718,476],{"class":353},[41,720,721],{"class":360}," Pagination",[41,723,482],{"class":47},[41,725,726,729,731],{"class":43,"line":289},[41,727,728],{"class":487},"  page",[41,730,491],{"class":353},[41,732,659],{"class":54},[41,734,735,738,740],{"class":43,"line":302},[41,736,737],{"class":487},"  per_page",[41,739,491],{"class":353},[41,741,659],{"class":54},[41,743,744,747,749],{"class":43,"line":313},[41,745,746],{"class":487},"  total",[41,748,491],{"class":353},[41,750,659],{"class":54},[41,752,753],{"class":43,"line":319},[41,754,322],{"class":47},[41,756,758],{"class":43,"line":757},27,[41,759,348],{"emptyLinePlaceholder":347},[41,761,763,765,767,770],{"class":43,"line":762},28,[41,764,354],{"class":353},[41,766,476],{"class":353},[41,768,769],{"class":360}," CertificatesResponse",[41,771,482],{"class":47},[41,773,775,778,780],{"class":43,"line":774},29,[41,776,777],{"class":487},"  account",[41,779,491],{"class":353},[41,781,782],{"class":360}," Account\n",[41,784,786,789,791,793],{"class":43,"line":785},30,[41,787,788],{"class":487},"  certificates",[41,790,491],{"class":353},[41,792,639],{"class":360},[41,794,795],{"class":47},"[]\n",[41,797,799,802,804],{"class":43,"line":798},31,[41,800,801],{"class":487},"  pagination",[41,803,491],{"class":353},[41,805,806],{"class":360}," Pagination\n",[41,808,810],{"class":43,"line":809},32,[41,811,322],{"class":47},[24,813,815],{"id":814},"utility-types-for-each-use-case","Utility Types for Each Use Case",[15,817,818],{},"The API always returns the complete structure, but the application rarely needs all of it at once. TypeScript's utility types allow deriving purpose-built types from the canonical interfaces without duplicating definitions:",[32,820,822],{"className":332,"code":821,"language":334,"meta":37,"style":37},"// For list display — metadata is not needed\nexport type CertificateSummary = Pick\u003C\n  Certificate,\n  \"id\" | \"volume\" | \"unit\" | \"status\" | \"period\"\n>\n\n// For updates — only status is mutable\nexport type CertificateUpdate = Pick\u003CCertificate, \"id\"> & {\n  status: CertificateStatus\n}\n\n// For search filters — all fields are optional\nexport type CertificateFilters = Partial\u003CPick\u003CCertificate, \"status\">> & {\n  period?: Partial\u003CDateRange>\n  technology?: EnergyTechnology\n  country?: CountryCode\n}\n\n// For forms — exclude API-generated fields\nexport type CertificateForm = Omit\u003CCertificate, \"id\" | \"status\">\n",[19,823,824,829,846,853,878,883,887,892,924,932,936,940,945,980,996,1004,1012,1016,1020,1025],{"__ignoreMap":37},[41,825,826],{"class":43,"line":44},[41,827,828],{"class":341},"// For list display — metadata is not needed\n",[41,830,831,833,835,838,840,843],{"class":43,"line":51},[41,832,354],{"class":353},[41,834,357],{"class":353},[41,836,837],{"class":360}," CertificateSummary",[41,839,364],{"class":353},[41,841,842],{"class":360}," Pick",[41,844,845],{"class":47},"\u003C\n",[41,847,848,851],{"class":43,"line":61},[41,849,850],{"class":360},"  Certificate",[41,852,74],{"class":47},[41,854,855,858,860,863,865,868,870,873,875],{"class":43,"line":77},[41,856,857],{"class":70},"  \"id\"",[41,859,370],{"class":353},[41,861,862],{"class":70}," \"volume\"",[41,864,370],{"class":353},[41,866,867],{"class":70}," \"unit\"",[41,869,370],{"class":353},[41,871,872],{"class":70}," \"status\"",[41,874,370],{"class":353},[41,876,877],{"class":70}," \"period\"\n",[41,879,880],{"class":43,"line":90},[41,881,882],{"class":47},">\n",[41,884,885],{"class":43,"line":101},[41,886,348],{"emptyLinePlaceholder":347},[41,888,889],{"class":43,"line":107},[41,890,891],{"class":341},"// For updates — only status is mutable\n",[41,893,894,896,898,901,903,905,908,911,913,916,919,922],{"class":43,"line":116},[41,895,354],{"class":353},[41,897,357],{"class":353},[41,899,900],{"class":360}," CertificateUpdate",[41,902,364],{"class":353},[41,904,842],{"class":360},[41,906,907],{"class":47},"\u003C",[41,909,910],{"class":360},"Certificate",[41,912,178],{"class":47},[41,914,915],{"class":70},"\"id\"",[41,917,918],{"class":47},"> ",[41,920,921],{"class":353},"&",[41,923,482],{"class":47},[41,925,926,928,930],{"class":43,"line":122},[41,927,689],{"class":487},[41,929,491],{"class":353},[41,931,694],{"class":360},[41,933,934],{"class":43,"line":135},[41,935,322],{"class":47},[41,937,938],{"class":43,"line":148},[41,939,348],{"emptyLinePlaceholder":347},[41,941,942],{"class":43,"line":161},[41,943,944],{"class":341},"// For search filters — all fields are optional\n",[41,946,947,949,951,954,956,959,961,964,966,968,970,973,976,978],{"class":43,"line":192},[41,948,354],{"class":353},[41,950,357],{"class":353},[41,952,953],{"class":360}," CertificateFilters",[41,955,364],{"class":353},[41,957,958],{"class":360}," Partial",[41,960,907],{"class":47},[41,962,963],{"class":360},"Pick",[41,965,907],{"class":47},[41,967,910],{"class":360},[41,969,178],{"class":47},[41,971,972],{"class":70},"\"status\"",[41,974,975],{"class":47},">> ",[41,977,921],{"class":353},[41,979,482],{"class":47},[41,981,982,984,987,989,991,994],{"class":43,"line":205},[41,983,679],{"class":487},[41,985,986],{"class":353},"?:",[41,988,958],{"class":360},[41,990,907],{"class":47},[41,992,993],{"class":360},"DateRange",[41,995,882],{"class":47},[41,997,998,1000,1002],{"class":43,"line":213},[41,999,598],{"class":487},[41,1001,986],{"class":353},[41,1003,603],{"class":360},[41,1005,1006,1008,1010],{"class":43,"line":226},[41,1007,608],{"class":487},[41,1009,986],{"class":353},[41,1011,613],{"class":360},[41,1013,1014],{"class":43,"line":239},[41,1015,322],{"class":47},[41,1017,1018],{"class":43,"line":250},[41,1019,348],{"emptyLinePlaceholder":347},[41,1021,1022],{"class":43,"line":256},[41,1023,1024],{"class":341},"// For forms — exclude API-generated fields\n",[41,1026,1027,1029,1031,1034,1036,1039,1041,1043,1045,1047,1049,1051],{"class":43,"line":262},[41,1028,354],{"class":353},[41,1030,357],{"class":353},[41,1032,1033],{"class":360}," CertificateForm",[41,1035,364],{"class":353},[41,1037,1038],{"class":360}," Omit",[41,1040,907],{"class":47},[41,1042,910],{"class":360},[41,1044,178],{"class":47},[41,1046,915],{"class":70},[41,1048,370],{"class":353},[41,1050,872],{"class":70},[41,1052,882],{"class":47},[24,1054,1056],{"id":1055},"generic-composables-in-vue","Generic Composables in Vue",[15,1058,1059],{},"A generic API composable eliminates the need to rewrite the same loading/error logic for every endpoint:",[32,1061,1063],{"className":332,"code":1062,"language":334,"meta":37,"style":37},"// composables/useApiQuery.ts\nimport { ref, Ref } from \"vue\"\n\ninterface ApiQueryState\u003CT> {\n  data: Ref\u003CT | null>\n  loading: Ref\u003Cboolean>\n  error: Ref\u003Cstring | null>\n  execute: () => Promise\u003Cvoid>\n}\n\nexport function useApiQuery\u003CT>(\n  fetcher: () => Promise\u003CT>,\n  options?: { immediate?: boolean },\n): ApiQueryState\u003CT> {\n  const data = ref\u003CT | null>(null) as Ref\u003CT | null>\n  const loading = ref(false)\n  const error = ref\u003Cstring | null>(null)\n\n  const execute = async () => {\n    loading.value = true\n    error.value = null\n    try {\n      data.value = await fetcher()\n    } catch (e) {\n      error.value = e instanceof Error ? e.message : \"Unknown error\"\n    } finally {\n      loading.value = false\n    }\n  }\n\n  if (options?.immediate) execute()\n\n  return { data, loading, error, execute }\n}\n",[19,1064,1065,1070,1084,1088,1104,1125,1141,1160,1183,1187,1191,1208,1228,1248,1263,1308,1328,1353,1357,1375,1386,1396,1403,1419,1430,1457,1466,1476,1480,1484,1488,1501,1505,1514],{"__ignoreMap":37},[41,1066,1067],{"class":43,"line":44},[41,1068,1069],{"class":341},"// composables/useApiQuery.ts\n",[41,1071,1072,1075,1078,1081],{"class":43,"line":51},[41,1073,1074],{"class":353},"import",[41,1076,1077],{"class":47}," { ref, Ref } ",[41,1079,1080],{"class":353},"from",[41,1082,1083],{"class":70}," \"vue\"\n",[41,1085,1086],{"class":43,"line":61},[41,1087,348],{"emptyLinePlaceholder":347},[41,1089,1090,1093,1096,1098,1101],{"class":43,"line":77},[41,1091,1092],{"class":353},"interface",[41,1094,1095],{"class":360}," ApiQueryState",[41,1097,907],{"class":47},[41,1099,1100],{"class":360},"T",[41,1102,1103],{"class":47},"> {\n",[41,1105,1106,1109,1111,1114,1116,1118,1120,1123],{"class":43,"line":90},[41,1107,1108],{"class":487},"  data",[41,1110,491],{"class":353},[41,1112,1113],{"class":360}," Ref",[41,1115,907],{"class":47},[41,1117,1100],{"class":360},[41,1119,370],{"class":353},[41,1121,1122],{"class":54}," null",[41,1124,882],{"class":47},[41,1126,1127,1130,1132,1134,1136,1139],{"class":43,"line":101},[41,1128,1129],{"class":487},"  loading",[41,1131,491],{"class":353},[41,1133,1113],{"class":360},[41,1135,907],{"class":47},[41,1137,1138],{"class":54},"boolean",[41,1140,882],{"class":47},[41,1142,1143,1146,1148,1150,1152,1154,1156,1158],{"class":43,"line":107},[41,1144,1145],{"class":487},"  error",[41,1147,491],{"class":353},[41,1149,1113],{"class":360},[41,1151,907],{"class":47},[41,1153,517],{"class":54},[41,1155,370],{"class":353},[41,1157,1122],{"class":54},[41,1159,882],{"class":47},[41,1161,1162,1165,1167,1170,1173,1176,1178,1181],{"class":43,"line":116},[41,1163,1164],{"class":360},"  execute",[41,1166,491],{"class":353},[41,1168,1169],{"class":47}," () ",[41,1171,1172],{"class":353},"=>",[41,1174,1175],{"class":360}," Promise",[41,1177,907],{"class":47},[41,1179,1180],{"class":54},"void",[41,1182,882],{"class":47},[41,1184,1185],{"class":43,"line":122},[41,1186,322],{"class":47},[41,1188,1189],{"class":43,"line":135},[41,1190,348],{"emptyLinePlaceholder":347},[41,1192,1193,1195,1198,1201,1203,1205],{"class":43,"line":148},[41,1194,354],{"class":353},[41,1196,1197],{"class":353}," function",[41,1199,1200],{"class":360}," useApiQuery",[41,1202,907],{"class":47},[41,1204,1100],{"class":360},[41,1206,1207],{"class":47},">(\n",[41,1209,1210,1213,1215,1217,1219,1221,1223,1225],{"class":43,"line":161},[41,1211,1212],{"class":360},"  fetcher",[41,1214,491],{"class":353},[41,1216,1169],{"class":47},[41,1218,1172],{"class":353},[41,1220,1175],{"class":360},[41,1222,907],{"class":47},[41,1224,1100],{"class":360},[41,1226,1227],{"class":47},">,\n",[41,1229,1230,1233,1235,1238,1241,1243,1246],{"class":43,"line":192},[41,1231,1232],{"class":487},"  options",[41,1234,986],{"class":353},[41,1236,1237],{"class":47}," { ",[41,1239,1240],{"class":487},"immediate",[41,1242,986],{"class":353},[41,1244,1245],{"class":54}," boolean",[41,1247,189],{"class":47},[41,1249,1250,1253,1255,1257,1259,1261],{"class":43,"line":205},[41,1251,1252],{"class":47},")",[41,1254,491],{"class":353},[41,1256,1095],{"class":360},[41,1258,907],{"class":47},[41,1260,1100],{"class":360},[41,1262,1103],{"class":47},[41,1264,1265,1268,1271,1273,1276,1278,1280,1282,1284,1287,1290,1293,1296,1298,1300,1302,1304,1306],{"class":43,"line":213},[41,1266,1267],{"class":353},"  const",[41,1269,1270],{"class":54}," data",[41,1272,364],{"class":353},[41,1274,1275],{"class":360}," ref",[41,1277,907],{"class":47},[41,1279,1100],{"class":360},[41,1281,370],{"class":353},[41,1283,1122],{"class":54},[41,1285,1286],{"class":47},">(",[41,1288,1289],{"class":54},"null",[41,1291,1292],{"class":47},") ",[41,1294,1295],{"class":353},"as",[41,1297,1113],{"class":360},[41,1299,907],{"class":47},[41,1301,1100],{"class":360},[41,1303,370],{"class":353},[41,1305,1122],{"class":54},[41,1307,882],{"class":47},[41,1309,1310,1312,1315,1317,1319,1322,1325],{"class":43,"line":226},[41,1311,1267],{"class":353},[41,1313,1314],{"class":54}," loading",[41,1316,364],{"class":353},[41,1318,1275],{"class":360},[41,1320,1321],{"class":47},"(",[41,1323,1324],{"class":54},"false",[41,1326,1327],{"class":47},")\n",[41,1329,1330,1332,1335,1337,1339,1341,1343,1345,1347,1349,1351],{"class":43,"line":239},[41,1331,1267],{"class":353},[41,1333,1334],{"class":54}," error",[41,1336,364],{"class":353},[41,1338,1275],{"class":360},[41,1340,907],{"class":47},[41,1342,517],{"class":54},[41,1344,370],{"class":353},[41,1346,1122],{"class":54},[41,1348,1286],{"class":47},[41,1350,1289],{"class":54},[41,1352,1327],{"class":47},[41,1354,1355],{"class":43,"line":250},[41,1356,348],{"emptyLinePlaceholder":347},[41,1358,1359,1361,1364,1366,1369,1371,1373],{"class":43,"line":256},[41,1360,1267],{"class":353},[41,1362,1363],{"class":360}," execute",[41,1365,364],{"class":353},[41,1367,1368],{"class":353}," async",[41,1370,1169],{"class":47},[41,1372,1172],{"class":353},[41,1374,482],{"class":47},[41,1376,1377,1380,1383],{"class":43,"line":262},[41,1378,1379],{"class":47},"    loading.value ",[41,1381,1382],{"class":353},"=",[41,1384,1385],{"class":54}," true\n",[41,1387,1388,1391,1393],{"class":43,"line":268},[41,1389,1390],{"class":47},"    error.value ",[41,1392,1382],{"class":353},[41,1394,1395],{"class":54}," null\n",[41,1397,1398,1401],{"class":43,"line":276},[41,1399,1400],{"class":353},"    try",[41,1402,482],{"class":47},[41,1404,1405,1408,1410,1413,1416],{"class":43,"line":289},[41,1406,1407],{"class":47},"      data.value ",[41,1409,1382],{"class":353},[41,1411,1412],{"class":353}," await",[41,1414,1415],{"class":360}," fetcher",[41,1417,1418],{"class":47},"()\n",[41,1420,1421,1424,1427],{"class":43,"line":302},[41,1422,1423],{"class":47},"    } ",[41,1425,1426],{"class":353},"catch",[41,1428,1429],{"class":47}," (e) {\n",[41,1431,1432,1435,1437,1440,1443,1446,1449,1452,1454],{"class":43,"line":313},[41,1433,1434],{"class":47},"      error.value ",[41,1436,1382],{"class":353},[41,1438,1439],{"class":47}," e ",[41,1441,1442],{"class":353},"instanceof",[41,1444,1445],{"class":360}," Error",[41,1447,1448],{"class":353}," ?",[41,1450,1451],{"class":47}," e.message ",[41,1453,491],{"class":353},[41,1455,1456],{"class":70}," \"Unknown error\"\n",[41,1458,1459,1461,1464],{"class":43,"line":319},[41,1460,1423],{"class":47},[41,1462,1463],{"class":353},"finally",[41,1465,482],{"class":47},[41,1467,1468,1471,1473],{"class":43,"line":757},[41,1469,1470],{"class":47},"      loading.value ",[41,1472,1382],{"class":353},[41,1474,1475],{"class":54}," false\n",[41,1477,1478],{"class":43,"line":762},[41,1479,259],{"class":47},[41,1481,1482],{"class":43,"line":774},[41,1483,316],{"class":47},[41,1485,1486],{"class":43,"line":785},[41,1487,348],{"emptyLinePlaceholder":347},[41,1489,1490,1493,1496,1499],{"class":43,"line":798},[41,1491,1492],{"class":353},"  if",[41,1494,1495],{"class":47}," (options?.immediate) ",[41,1497,1498],{"class":360},"execute",[41,1500,1418],{"class":47},[41,1502,1503],{"class":43,"line":809},[41,1504,348],{"emptyLinePlaceholder":347},[41,1506,1508,1511],{"class":43,"line":1507},33,[41,1509,1510],{"class":353},"  return",[41,1512,1513],{"class":47}," { data, loading, error, execute }\n",[41,1515,1517],{"class":43,"line":1516},34,[41,1518,322],{"class":47},[15,1520,1521],{},"Usage in a Vue component:",[32,1523,1525],{"className":332,"code":1524,"language":334,"meta":37,"style":37},"const {\n  data: certificates,\n  loading,\n  error,\n  execute,\n} = useApiQuery\u003CCertificatesResponse>(\n  () => $fetch(\"/api/certificates\", { params: filters.value }),\n  { immediate: true },\n)\n",[19,1526,1527,1534,1545,1551,1557,1563,1579,1597,1607],{"__ignoreMap":37},[41,1528,1529,1532],{"class":43,"line":44},[41,1530,1531],{"class":353},"const",[41,1533,482],{"class":47},[41,1535,1536,1538,1540,1543],{"class":43,"line":51},[41,1537,1108],{"class":487},[41,1539,67],{"class":47},[41,1541,1542],{"class":54},"certificates",[41,1544,74],{"class":47},[41,1546,1547,1549],{"class":43,"line":61},[41,1548,1129],{"class":54},[41,1550,74],{"class":47},[41,1552,1553,1555],{"class":43,"line":77},[41,1554,1145],{"class":54},[41,1556,74],{"class":47},[41,1558,1559,1561],{"class":43,"line":90},[41,1560,1164],{"class":54},[41,1562,74],{"class":47},[41,1564,1565,1568,1570,1572,1574,1577],{"class":43,"line":101},[41,1566,1567],{"class":47},"} ",[41,1569,1382],{"class":353},[41,1571,1200],{"class":360},[41,1573,907],{"class":47},[41,1575,1576],{"class":360},"CertificatesResponse",[41,1578,1207],{"class":47},[41,1580,1581,1584,1586,1589,1591,1594],{"class":43,"line":107},[41,1582,1583],{"class":47},"  () ",[41,1585,1172],{"class":353},[41,1587,1588],{"class":360}," $fetch",[41,1590,1321],{"class":47},[41,1592,1593],{"class":70},"\"/api/certificates\"",[41,1595,1596],{"class":47},", { params: filters.value }),\n",[41,1598,1599,1602,1605],{"class":43,"line":116},[41,1600,1601],{"class":47},"  { immediate: ",[41,1603,1604],{"class":54},"true",[41,1606,189],{"class":47},[41,1608,1609],{"class":43,"line":122},[41,1610,1327],{"class":47},[15,1612,1613,1614,1617,1618,1621,1622,1625],{},"TypeScript infers ",[19,1615,1616],{},"data"," as ",[19,1619,1620],{},"Ref\u003CCertificatesResponse | null>"," automatically — no manual casting, no ",[19,1623,1624],{},"as any",".",[24,1627,1629],{"id":1628},"typing-vue-props-against-api-interfaces","Typing Vue Props Against API Interfaces",[15,1631,1632],{},"A common mistake is defining Vue props with local types that duplicate the API interfaces. The correct approach uses the canonical types directly:",[32,1634,1636],{"className":332,"code":1635,"language":334,"meta":37,"style":37},"// components/CertificateCard.vue\n\u003Cscript setup lang=\"ts\">\nimport type { CertificateSummary, CertificateStatus } from '@/types/api'\n\nconst props = defineProps\u003C{\n  certificate: CertificateSummary\n  selected?: boolean\n}>()\n\nconst emit = defineEmits\u003C{\n  select: [id: string]\n  statusChange: [id: string, status: CertificateStatus]\n}>()\n\u003C/script>\n",[19,1637,1638,1643,1657,1671,1675,1690,1700,1710,1715,1719,1733,1753,1780,1784],{"__ignoreMap":37},[41,1639,1640],{"class":43,"line":44},[41,1641,1642],{"class":341},"// components/CertificateCard.vue\n",[41,1644,1645,1647,1650,1652,1655],{"class":43,"line":51},[41,1646,907],{"class":353},[41,1648,1649],{"class":47},"script setup lang",[41,1651,1382],{"class":353},[41,1653,1654],{"class":70},"\"ts\"",[41,1656,882],{"class":353},[41,1658,1659,1661,1663,1666,1668],{"class":43,"line":61},[41,1660,1074],{"class":353},[41,1662,357],{"class":353},[41,1664,1665],{"class":47}," { CertificateSummary, CertificateStatus } ",[41,1667,1080],{"class":353},[41,1669,1670],{"class":70}," '@/types/api'\n",[41,1672,1673],{"class":43,"line":77},[41,1674,348],{"emptyLinePlaceholder":347},[41,1676,1677,1679,1682,1684,1687],{"class":43,"line":90},[41,1678,1531],{"class":353},[41,1680,1681],{"class":54}," props",[41,1683,364],{"class":353},[41,1685,1686],{"class":360}," defineProps",[41,1688,1689],{"class":47},"\u003C{\n",[41,1691,1692,1695,1697],{"class":43,"line":101},[41,1693,1694],{"class":487},"  certificate",[41,1696,491],{"class":353},[41,1698,1699],{"class":360}," CertificateSummary\n",[41,1701,1702,1705,1707],{"class":43,"line":107},[41,1703,1704],{"class":487},"  selected",[41,1706,986],{"class":353},[41,1708,1709],{"class":54}," boolean\n",[41,1711,1712],{"class":43,"line":116},[41,1713,1714],{"class":47},"}>()\n",[41,1716,1717],{"class":43,"line":122},[41,1718,348],{"emptyLinePlaceholder":347},[41,1720,1721,1723,1726,1728,1731],{"class":43,"line":135},[41,1722,1531],{"class":353},[41,1724,1725],{"class":54}," emit",[41,1727,364],{"class":353},[41,1729,1730],{"class":360}," defineEmits",[41,1732,1689],{"class":47},[41,1734,1735,1738,1740,1743,1746,1748,1750],{"class":43,"line":148},[41,1736,1737],{"class":487},"  select",[41,1739,491],{"class":353},[41,1741,1742],{"class":47}," [",[41,1744,1745],{"class":360},"id",[41,1747,67],{"class":47},[41,1749,517],{"class":54},[41,1751,1752],{"class":47},"]\n",[41,1754,1755,1758,1760,1762,1764,1766,1768,1770,1773,1775,1778],{"class":43,"line":161},[41,1756,1757],{"class":487},"  statusChange",[41,1759,491],{"class":353},[41,1761,1742],{"class":47},[41,1763,1745],{"class":360},[41,1765,67],{"class":47},[41,1767,517],{"class":54},[41,1769,178],{"class":47},[41,1771,1772],{"class":360},"status",[41,1774,67],{"class":47},[41,1776,1777],{"class":360},"CertificateStatus",[41,1779,1752],{"class":47},[41,1781,1782],{"class":43,"line":192},[41,1783,1714],{"class":47},[41,1785,1786,1789,1792],{"class":43,"line":205},[41,1787,1788],{"class":353},"\u003C/",[41,1790,1791],{"class":47},"script",[41,1793,882],{"class":353},[15,1795,1796,1799,1800,1803,1804,1807],{},[19,1797,1798],{},"defineProps\u003CT>()"," and ",[19,1801,1802],{},"defineEmits\u003CT>()"," with generic types: no ",[19,1805,1806],{},"PropType\u003CT>"," import required, and TypeScript validates props at compile time and within the template.",[24,1809,1811],{"id":1810},"type-guards-for-api-responses","Type Guards for API Responses",[15,1813,1814],{},"Real-world APIs do not always honour their contracts. A type guard enables runtime validation without sacrificing static typing:",[32,1816,1818],{"className":332,"code":1817,"language":334,"meta":37,"style":37},"function isCertificate(value: unknown): value is Certificate {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    \"id\" in value &&\n    \"volume\" in value &&\n    \"status\" in value &&\n    [\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"].includes(\n      (value as Certificate).status,\n    )\n  )\n}\n\n// Usage\nconst raw = await $fetch(\"/api/certificates/GO-2024-001\")\nif (!isCertificate(raw)) {\n  throw new Error(\"Invalid API response\")\n}\n// TypeScript now knows raw is of type Certificate\nconsole.log(raw.volume)\n",[19,1819,1820,1852,1859,1876,1888,1900,1911,1922,1948,1960,1965,1970,1974,1978,1983,2003,2020,2037,2041,2046],{"__ignoreMap":37},[41,1821,1822,1825,1828,1830,1833,1835,1838,1840,1842,1845,1848,1850],{"class":43,"line":44},[41,1823,1824],{"class":353},"function",[41,1826,1827],{"class":360}," isCertificate",[41,1829,1321],{"class":47},[41,1831,1832],{"class":487},"value",[41,1834,491],{"class":353},[41,1836,1837],{"class":54}," unknown",[41,1839,1252],{"class":47},[41,1841,491],{"class":353},[41,1843,1844],{"class":487}," value",[41,1846,1847],{"class":353}," is",[41,1849,639],{"class":360},[41,1851,482],{"class":47},[41,1853,1854,1856],{"class":43,"line":51},[41,1855,1510],{"class":353},[41,1857,1858],{"class":47}," (\n",[41,1860,1861,1864,1867,1870,1873],{"class":43,"line":61},[41,1862,1863],{"class":353},"    typeof",[41,1865,1866],{"class":47}," value ",[41,1868,1869],{"class":353},"===",[41,1871,1872],{"class":70}," \"object\"",[41,1874,1875],{"class":353}," &&\n",[41,1877,1878,1881,1884,1886],{"class":43,"line":77},[41,1879,1880],{"class":47},"    value ",[41,1882,1883],{"class":353},"!==",[41,1885,1122],{"class":54},[41,1887,1875],{"class":353},[41,1889,1890,1892,1895,1897],{"class":43,"line":90},[41,1891,64],{"class":70},[41,1893,1894],{"class":353}," in",[41,1896,1866],{"class":47},[41,1898,1899],{"class":353},"&&\n",[41,1901,1902,1905,1907,1909],{"class":43,"line":101},[41,1903,1904],{"class":70},"    \"volume\"",[41,1906,1894],{"class":353},[41,1908,1866],{"class":47},[41,1910,1899],{"class":353},[41,1912,1913,1916,1918,1920],{"class":43,"line":107},[41,1914,1915],{"class":70},"    \"status\"",[41,1917,1894],{"class":353},[41,1919,1866],{"class":47},[41,1921,1899],{"class":353},[41,1923,1924,1927,1929,1931,1934,1936,1939,1942,1945],{"class":43,"line":116},[41,1925,1926],{"class":47},"    [",[41,1928,200],{"class":70},[41,1930,178],{"class":47},[41,1932,1933],{"class":70},"\"CANCELLED\"",[41,1935,178],{"class":47},[41,1937,1938],{"class":70},"\"TRANSFERRED\"",[41,1940,1941],{"class":47},"].",[41,1943,1944],{"class":360},"includes",[41,1946,1947],{"class":47},"(\n",[41,1949,1950,1953,1955,1957],{"class":43,"line":122},[41,1951,1952],{"class":47},"      (value ",[41,1954,1295],{"class":353},[41,1956,639],{"class":360},[41,1958,1959],{"class":47},").status,\n",[41,1961,1962],{"class":43,"line":135},[41,1963,1964],{"class":47},"    )\n",[41,1966,1967],{"class":43,"line":148},[41,1968,1969],{"class":47},"  )\n",[41,1971,1972],{"class":43,"line":161},[41,1973,322],{"class":47},[41,1975,1976],{"class":43,"line":192},[41,1977,348],{"emptyLinePlaceholder":347},[41,1979,1980],{"class":43,"line":205},[41,1981,1982],{"class":341},"// Usage\n",[41,1984,1985,1987,1990,1992,1994,1996,1998,2001],{"class":43,"line":213},[41,1986,1531],{"class":353},[41,1988,1989],{"class":54}," raw",[41,1991,364],{"class":353},[41,1993,1412],{"class":353},[41,1995,1588],{"class":360},[41,1997,1321],{"class":47},[41,1999,2000],{"class":70},"\"/api/certificates/GO-2024-001\"",[41,2002,1327],{"class":47},[41,2004,2005,2008,2011,2014,2017],{"class":43,"line":226},[41,2006,2007],{"class":353},"if",[41,2009,2010],{"class":47}," (",[41,2012,2013],{"class":353},"!",[41,2015,2016],{"class":360},"isCertificate",[41,2018,2019],{"class":47},"(raw)) {\n",[41,2021,2022,2025,2028,2030,2032,2035],{"class":43,"line":239},[41,2023,2024],{"class":353},"  throw",[41,2026,2027],{"class":353}," new",[41,2029,1445],{"class":360},[41,2031,1321],{"class":47},[41,2033,2034],{"class":70},"\"Invalid API response\"",[41,2036,1327],{"class":47},[41,2038,2039],{"class":43,"line":250},[41,2040,322],{"class":47},[41,2042,2043],{"class":43,"line":256},[41,2044,2045],{"class":341},"// TypeScript now knows raw is of type Certificate\n",[41,2047,2048,2051,2054],{"class":43,"line":262},[41,2049,2050],{"class":47},"console.",[41,2052,2053],{"class":360},"log",[41,2055,2056],{"class":47},"(raw.volume)\n",[24,2058,2060],{"id":2059},"what-this-changes-in-practice","What This Changes in Practice",[15,2062,2063],{},"Investing in rigorous TypeScript modelling from the outset delivers:",[2065,2066,2067,2075,2081,2087],"ul",{},[2068,2069,2070,2074],"li",{},[2071,2072,2073],"strong",{},"Reliable autocomplete"," across all API objects in the IDE",[2068,2076,2077,2080],{},[2071,2078,2079],{},"Errors caught at compile time"," rather than discovered in production",[2068,2082,2083,2086],{},[2071,2084,2085],{},"Safe refactoring"," — renaming a field in an interface propagates the error everywhere it is used",[2068,2088,2089,2092],{},[2071,2090,2091],{},"Implicit documentation"," — the types are the source of truth for the shape of the data",[15,2094,2095],{},"The upfront cost is real, particularly on an existing project with JavaScript to migrate. However, on a new Vue.js project consuming an external API, starting with strict TypeScript from the first commit is consistently the most cost-effective decision over the medium term.",[2097,2098,2099],"style",{},"html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}",{"title":37,"searchDepth":51,"depth":51,"links":2101},[2102,2103,2104,2105,2106,2107,2108,2109],{"id":26,"depth":51,"text":27},{"id":325,"depth":51,"text":326},{"id":529,"depth":51,"text":530},{"id":814,"depth":51,"text":815},{"id":1055,"depth":51,"text":1056},{"id":1628,"depth":51,"text":1629},{"id":1810,"depth":51,"text":1811},{"id":2059,"depth":51,"text":2060},"2025-06-09","Most TypeScript tutorials demonstrate trivial examples: interface User { name: string; age: number }. Production code is considerably more involved. External APIs return nested structures, conditionally optional fields, and type unions that vary by state. Here is how to model all of that cleanly, starting from a real-world case.",null,"md",{},"/en/blog/typescript-interfaces",{"title":6,"description":2111},"typescript-interfaces","en/blog/typescript-interfaces",[2120,2121,2122,2123],"TypeScript","Vue.js","API","Frontend","u8YrnjoAkeDweoJODzg5Jabq2lI1WgO2vLZk1gcLA90",{"id":2126,"title":2127,"body":2128,"date":2973,"description":2136,"excerpt":2112,"extension":2113,"meta":2974,"navigation":347,"path":2975,"readTime":122,"seo":2976,"slug":2977,"stem":2978,"tags":2979,"__hash__":2984},"en_blog/en/blog/pattern-bff.md","The BFF Pattern with FastAPI: Backend-for-Frontend",{"type":8,"value":2129,"toc":2962},[2130,2134,2137,2141,2155,2173,2187,2191,2199,2202,2206,2211,2430,2434,2609,2613,2727,2731,2734,2872,2876,2956,2959],[11,2131,2133],{"id":2132},"the-bff-pattern-with-fastapi-putting-a-backend-in-front-of-your-frontend","The BFF Pattern with FastAPI: Putting a Backend in Front of Your Frontend",[15,2135,2136],{},"The Backend-for-Frontend (BFF) pattern is not new — Netflix, SoundCloud, and others popularised it over a decade ago. Yet it remains underused in Vue.js and FastAPI architectures, where the prevailing tendency is to handle OAuth2 tokens directly in the browser. Here is why that is a risky trade-off, and how the BFF pattern addresses it.",[24,2138,2140],{"id":2139},"the-problem-with-client-side-tokens","The Problem with Client-Side Tokens",[15,2142,2143,2144,2147,2148,178,2151,2154],{},"In a standard Vue.js single-page application with Azure B2C, the OAuth2 flow terminates with an ",[19,2145,2146],{},"access_token"," stored somewhere in the browser: ",[19,2149,2150],{},"localStorage",[19,2152,2153],{},"sessionStorage",", or a cookie. Each of these options carries limitations:",[2065,2156,2157,2162,2167],{},[2068,2158,2159,2161],{},[2071,2160,2150],{}," — readable by any JavaScript executing on the domain; directly exposed to XSS attacks",[2068,2163,2164,2166],{},[2071,2165,2153],{}," — same vulnerabilities; discarded when the tab is closed",[2068,2168,2169,2172],{},[2071,2170,2171],{},"HttpOnly cookie"," — the most defensible client-side option, yet token refresh still requires server-side coordination",[15,2174,2175,2176,2179,2180,2183,2184,2186],{},"The deeper issue is structural: the Azure B2C ",[19,2177,2178],{},"client_secret"," cannot be embedded in a single-page application. The OAuth2 code exchange (",[19,2181,2182],{},"authorization_code"," → ",[19,2185,2146],{},") must happen server-side. Without a BFF, teams either compromise on security or burden the frontend with PKCE and elaborate workarounds.",[24,2188,2190],{"id":2189},"architecture-overview","Architecture Overview",[32,2192,2197],{"className":2193,"code":2195,"language":2196},[2194],"language-text","Browser (Vue.js)\n        │\n        │  Session cookie (HttpOnly, Secure)\n        ▼\n  FastAPI BFF\n        │\n        ├─── Redis (encrypted sessions + OAuth2 tokens)\n        │\n        └─── Azure B2C (code exchange, token refresh)\n                │\n                └─── Downstream APIs (access_token as Bearer)\n","text",[19,2198,2195],{"__ignoreMap":37},[15,2200,2201],{},"The browser never sees an OAuth2 token. It exchanges only an opaque session cookie with the BFF. The BFF holds the tokens and injects them into requests to downstream APIs on behalf of the client.",[24,2203,2205],{"id":2204},"fastapi-implementation","FastAPI Implementation",[2207,2208,2210],"h3",{"id":2209},"session-management-with-redis","Session Management with Redis",[32,2212,2216],{"className":2213,"code":2214,"language":2215,"meta":37,"style":37},"language-python shiki shiki-themes github-dark github-light","import json\nimport secrets\nfrom datetime import timedelta\nfrom cryptography.fernet import Fernet\nimport redis.asyncio as aioredis\nfrom fastapi import Request, Response\n\nclass SessionManager:\n    def __init__(self, redis: aioredis.Redis, secret_key: bytes):\n        self.redis = redis\n        self.fernet = Fernet(secret_key)\n        self.session_ttl = 3600  # 1 hour\n\n    def _session_key(self, session_id: str) -> str:\n        return f\"bff:session:{session_id}\"\n\n    async def create_session(self, response: Response, data: dict) -> str:\n        session_id = secrets.token_urlsafe(32)\n        encrypted = self.fernet.encrypt(json.dumps(data).encode())\n        await self.redis.setex(\n            self._session_key(session_id),\n            self.session_ttl,\n            encrypted\n        )\n        response.set_cookie(\n            key=\"session_id\",\n            value=session_id,\n            httponly=True,\n            secure=True,\n            samesite=\"lax\",\n            max_age=self.session_ttl\n        )\n        return session_id\n\n    async def get_session(self, request: Request) -> dict | None:\n        session_id = request.cookies.get(\"session_id\")\n        if not session_id:\n            return None\n        raw = await self.redis.get(self._session_key(session_id))\n        if not raw:\n            return None\n        return json.loads(self.fernet.decrypt(raw))\n","python",[19,2217,2218,2223,2228,2233,2238,2243,2248,2252,2257,2262,2267,2272,2277,2281,2286,2291,2295,2300,2305,2310,2315,2320,2325,2330,2335,2340,2345,2350,2355,2360,2365,2370,2374,2379,2383,2389,2395,2401,2407,2413,2419,2424],{"__ignoreMap":37},[41,2219,2220],{"class":43,"line":44},[41,2221,2222],{},"import json\n",[41,2224,2225],{"class":43,"line":51},[41,2226,2227],{},"import secrets\n",[41,2229,2230],{"class":43,"line":61},[41,2231,2232],{},"from datetime import timedelta\n",[41,2234,2235],{"class":43,"line":77},[41,2236,2237],{},"from cryptography.fernet import Fernet\n",[41,2239,2240],{"class":43,"line":90},[41,2241,2242],{},"import redis.asyncio as aioredis\n",[41,2244,2245],{"class":43,"line":101},[41,2246,2247],{},"from fastapi import Request, Response\n",[41,2249,2250],{"class":43,"line":107},[41,2251,348],{"emptyLinePlaceholder":347},[41,2253,2254],{"class":43,"line":116},[41,2255,2256],{},"class SessionManager:\n",[41,2258,2259],{"class":43,"line":122},[41,2260,2261],{},"    def __init__(self, redis: aioredis.Redis, secret_key: bytes):\n",[41,2263,2264],{"class":43,"line":135},[41,2265,2266],{},"        self.redis = redis\n",[41,2268,2269],{"class":43,"line":148},[41,2270,2271],{},"        self.fernet = Fernet(secret_key)\n",[41,2273,2274],{"class":43,"line":161},[41,2275,2276],{},"        self.session_ttl = 3600  # 1 hour\n",[41,2278,2279],{"class":43,"line":192},[41,2280,348],{"emptyLinePlaceholder":347},[41,2282,2283],{"class":43,"line":205},[41,2284,2285],{},"    def _session_key(self, session_id: str) -> str:\n",[41,2287,2288],{"class":43,"line":213},[41,2289,2290],{},"        return f\"bff:session:{session_id}\"\n",[41,2292,2293],{"class":43,"line":226},[41,2294,348],{"emptyLinePlaceholder":347},[41,2296,2297],{"class":43,"line":239},[41,2298,2299],{},"    async def create_session(self, response: Response, data: dict) -> str:\n",[41,2301,2302],{"class":43,"line":250},[41,2303,2304],{},"        session_id = secrets.token_urlsafe(32)\n",[41,2306,2307],{"class":43,"line":256},[41,2308,2309],{},"        encrypted = self.fernet.encrypt(json.dumps(data).encode())\n",[41,2311,2312],{"class":43,"line":262},[41,2313,2314],{},"        await self.redis.setex(\n",[41,2316,2317],{"class":43,"line":268},[41,2318,2319],{},"            self._session_key(session_id),\n",[41,2321,2322],{"class":43,"line":276},[41,2323,2324],{},"            self.session_ttl,\n",[41,2326,2327],{"class":43,"line":289},[41,2328,2329],{},"            encrypted\n",[41,2331,2332],{"class":43,"line":302},[41,2333,2334],{},"        )\n",[41,2336,2337],{"class":43,"line":313},[41,2338,2339],{},"        response.set_cookie(\n",[41,2341,2342],{"class":43,"line":319},[41,2343,2344],{},"            key=\"session_id\",\n",[41,2346,2347],{"class":43,"line":757},[41,2348,2349],{},"            value=session_id,\n",[41,2351,2352],{"class":43,"line":762},[41,2353,2354],{},"            httponly=True,\n",[41,2356,2357],{"class":43,"line":774},[41,2358,2359],{},"            secure=True,\n",[41,2361,2362],{"class":43,"line":785},[41,2363,2364],{},"            samesite=\"lax\",\n",[41,2366,2367],{"class":43,"line":798},[41,2368,2369],{},"            max_age=self.session_ttl\n",[41,2371,2372],{"class":43,"line":809},[41,2373,2334],{},[41,2375,2376],{"class":43,"line":1507},[41,2377,2378],{},"        return session_id\n",[41,2380,2381],{"class":43,"line":1516},[41,2382,348],{"emptyLinePlaceholder":347},[41,2384,2386],{"class":43,"line":2385},35,[41,2387,2388],{},"    async def get_session(self, request: Request) -> dict | None:\n",[41,2390,2392],{"class":43,"line":2391},36,[41,2393,2394],{},"        session_id = request.cookies.get(\"session_id\")\n",[41,2396,2398],{"class":43,"line":2397},37,[41,2399,2400],{},"        if not session_id:\n",[41,2402,2404],{"class":43,"line":2403},38,[41,2405,2406],{},"            return None\n",[41,2408,2410],{"class":43,"line":2409},39,[41,2411,2412],{},"        raw = await self.redis.get(self._session_key(session_id))\n",[41,2414,2416],{"class":43,"line":2415},40,[41,2417,2418],{},"        if not raw:\n",[41,2420,2422],{"class":43,"line":2421},41,[41,2423,2406],{},[41,2425,2427],{"class":43,"line":2426},42,[41,2428,2429],{},"        return json.loads(self.fernet.decrypt(raw))\n",[2207,2431,2433],{"id":2432},"azure-b2c-oauth2-callback","Azure B2C OAuth2 Callback",[32,2435,2437],{"className":2213,"code":2436,"language":2215,"meta":37,"style":37},"from fastapi import APIRouter, Request, Response\nfrom httpx import AsyncClient\n\nrouter = APIRouter()\n\n@router.get(\"/auth/callback\")\nasync def oauth_callback(\n    request: Request,\n    response: Response,\n    code: str,\n    state: str,\n):\n    # Code exchange happens entirely server-side\n    async with AsyncClient() as client:\n        token_response = await client.post(\n            f\"https://{settings.b2c_tenant}.b2clogin.com/\"\n            f\"{settings.b2c_tenant}.onmicrosoft.com/\"\n            f\"{settings.b2c_policy}/oauth2/v2.0/token\",\n            data={\n                \"grant_type\": \"authorization_code\",\n                \"client_id\": settings.client_id,\n                \"client_secret\": settings.client_secret,  # Never exposed to the browser\n                \"code\": code,\n                \"redirect_uri\": settings.redirect_uri,\n            }\n        )\n\n    tokens = token_response.json()\n    await session_manager.create_session(response, {\n        \"access_token\": tokens[\"access_token\"],\n        \"refresh_token\": tokens[\"refresh_token\"],\n        \"expires_at\": time.time() + tokens[\"expires_in\"]\n    })\n\n    return RedirectResponse(url=\"/\")\n",[19,2438,2439,2444,2449,2453,2458,2462,2467,2472,2477,2482,2487,2492,2497,2502,2507,2512,2517,2522,2527,2532,2537,2542,2547,2552,2557,2562,2566,2570,2575,2580,2585,2590,2595,2600,2604],{"__ignoreMap":37},[41,2440,2441],{"class":43,"line":44},[41,2442,2443],{},"from fastapi import APIRouter, Request, Response\n",[41,2445,2446],{"class":43,"line":51},[41,2447,2448],{},"from httpx import AsyncClient\n",[41,2450,2451],{"class":43,"line":61},[41,2452,348],{"emptyLinePlaceholder":347},[41,2454,2455],{"class":43,"line":77},[41,2456,2457],{},"router = APIRouter()\n",[41,2459,2460],{"class":43,"line":90},[41,2461,348],{"emptyLinePlaceholder":347},[41,2463,2464],{"class":43,"line":101},[41,2465,2466],{},"@router.get(\"/auth/callback\")\n",[41,2468,2469],{"class":43,"line":107},[41,2470,2471],{},"async def oauth_callback(\n",[41,2473,2474],{"class":43,"line":116},[41,2475,2476],{},"    request: Request,\n",[41,2478,2479],{"class":43,"line":122},[41,2480,2481],{},"    response: Response,\n",[41,2483,2484],{"class":43,"line":135},[41,2485,2486],{},"    code: str,\n",[41,2488,2489],{"class":43,"line":148},[41,2490,2491],{},"    state: str,\n",[41,2493,2494],{"class":43,"line":161},[41,2495,2496],{},"):\n",[41,2498,2499],{"class":43,"line":192},[41,2500,2501],{},"    # Code exchange happens entirely server-side\n",[41,2503,2504],{"class":43,"line":205},[41,2505,2506],{},"    async with AsyncClient() as client:\n",[41,2508,2509],{"class":43,"line":213},[41,2510,2511],{},"        token_response = await client.post(\n",[41,2513,2514],{"class":43,"line":226},[41,2515,2516],{},"            f\"https://{settings.b2c_tenant}.b2clogin.com/\"\n",[41,2518,2519],{"class":43,"line":239},[41,2520,2521],{},"            f\"{settings.b2c_tenant}.onmicrosoft.com/\"\n",[41,2523,2524],{"class":43,"line":250},[41,2525,2526],{},"            f\"{settings.b2c_policy}/oauth2/v2.0/token\",\n",[41,2528,2529],{"class":43,"line":256},[41,2530,2531],{},"            data={\n",[41,2533,2534],{"class":43,"line":262},[41,2535,2536],{},"                \"grant_type\": \"authorization_code\",\n",[41,2538,2539],{"class":43,"line":268},[41,2540,2541],{},"                \"client_id\": settings.client_id,\n",[41,2543,2544],{"class":43,"line":276},[41,2545,2546],{},"                \"client_secret\": settings.client_secret,  # Never exposed to the browser\n",[41,2548,2549],{"class":43,"line":289},[41,2550,2551],{},"                \"code\": code,\n",[41,2553,2554],{"class":43,"line":302},[41,2555,2556],{},"                \"redirect_uri\": settings.redirect_uri,\n",[41,2558,2559],{"class":43,"line":313},[41,2560,2561],{},"            }\n",[41,2563,2564],{"class":43,"line":319},[41,2565,2334],{},[41,2567,2568],{"class":43,"line":757},[41,2569,348],{"emptyLinePlaceholder":347},[41,2571,2572],{"class":43,"line":762},[41,2573,2574],{},"    tokens = token_response.json()\n",[41,2576,2577],{"class":43,"line":774},[41,2578,2579],{},"    await session_manager.create_session(response, {\n",[41,2581,2582],{"class":43,"line":785},[41,2583,2584],{},"        \"access_token\": tokens[\"access_token\"],\n",[41,2586,2587],{"class":43,"line":798},[41,2588,2589],{},"        \"refresh_token\": tokens[\"refresh_token\"],\n",[41,2591,2592],{"class":43,"line":809},[41,2593,2594],{},"        \"expires_at\": time.time() + tokens[\"expires_in\"]\n",[41,2596,2597],{"class":43,"line":1507},[41,2598,2599],{},"    })\n",[41,2601,2602],{"class":43,"line":1516},[41,2603,348],{"emptyLinePlaceholder":347},[41,2605,2606],{"class":43,"line":2385},[41,2607,2608],{},"    return RedirectResponse(url=\"/\")\n",[2207,2610,2612],{"id":2611},"proxying-to-downstream-apis","Proxying to Downstream APIs",[32,2614,2616],{"className":2213,"code":2615,"language":2215,"meta":37,"style":37},"@router.api_route(\"/api/{path:path}\", methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\"])\nasync def proxy(request: Request, path: str):\n    session = await session_manager.get_session(request)\n    if not session:\n        raise HTTPException(status_code=401)\n\n    # Refresh the token proactively before it expires\n    if time.time() > session[\"expires_at\"] - 60:\n        session = await token_refresher.refresh(session)\n\n    async with AsyncClient() as client:\n        upstream = await client.request(\n            method=request.method,\n            url=f\"{settings.api_base_url}/{path}\",\n            headers={\"Authorization\": f\"Bearer {session['access_token']}\"},\n            content=await request.body(),\n        )\n\n    return Response(\n        content=upstream.content,\n        status_code=upstream.status_code,\n        media_type=upstream.headers.get(\"content-type\")\n    )\n",[19,2617,2618,2623,2628,2633,2638,2643,2647,2652,2657,2662,2666,2670,2675,2680,2685,2690,2695,2699,2703,2708,2713,2718,2723],{"__ignoreMap":37},[41,2619,2620],{"class":43,"line":44},[41,2621,2622],{},"@router.api_route(\"/api/{path:path}\", methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\"])\n",[41,2624,2625],{"class":43,"line":51},[41,2626,2627],{},"async def proxy(request: Request, path: str):\n",[41,2629,2630],{"class":43,"line":61},[41,2631,2632],{},"    session = await session_manager.get_session(request)\n",[41,2634,2635],{"class":43,"line":77},[41,2636,2637],{},"    if not session:\n",[41,2639,2640],{"class":43,"line":90},[41,2641,2642],{},"        raise HTTPException(status_code=401)\n",[41,2644,2645],{"class":43,"line":101},[41,2646,348],{"emptyLinePlaceholder":347},[41,2648,2649],{"class":43,"line":107},[41,2650,2651],{},"    # Refresh the token proactively before it expires\n",[41,2653,2654],{"class":43,"line":116},[41,2655,2656],{},"    if time.time() > session[\"expires_at\"] - 60:\n",[41,2658,2659],{"class":43,"line":122},[41,2660,2661],{},"        session = await token_refresher.refresh(session)\n",[41,2663,2664],{"class":43,"line":135},[41,2665,348],{"emptyLinePlaceholder":347},[41,2667,2668],{"class":43,"line":148},[41,2669,2506],{},[41,2671,2672],{"class":43,"line":161},[41,2673,2674],{},"        upstream = await client.request(\n",[41,2676,2677],{"class":43,"line":192},[41,2678,2679],{},"            method=request.method,\n",[41,2681,2682],{"class":43,"line":205},[41,2683,2684],{},"            url=f\"{settings.api_base_url}/{path}\",\n",[41,2686,2687],{"class":43,"line":213},[41,2688,2689],{},"            headers={\"Authorization\": f\"Bearer {session['access_token']}\"},\n",[41,2691,2692],{"class":43,"line":226},[41,2693,2694],{},"            content=await request.body(),\n",[41,2696,2697],{"class":43,"line":239},[41,2698,2334],{},[41,2700,2701],{"class":43,"line":250},[41,2702,348],{"emptyLinePlaceholder":347},[41,2704,2705],{"class":43,"line":256},[41,2706,2707],{},"    return Response(\n",[41,2709,2710],{"class":43,"line":262},[41,2711,2712],{},"        content=upstream.content,\n",[41,2714,2715],{"class":43,"line":268},[41,2716,2717],{},"        status_code=upstream.status_code,\n",[41,2719,2720],{"class":43,"line":276},[41,2721,2722],{},"        media_type=upstream.headers.get(\"content-type\")\n",[41,2724,2725],{"class":43,"line":289},[41,2726,1964],{},[24,2728,2730],{"id":2729},"distributed-locking-for-token-refresh","Distributed Locking for Token Refresh",[15,2732,2733],{},"In a multi-pod environment, several workers may attempt to refresh the same token concurrently. A Redis lock prevents redundant refresh calls and the race conditions they produce:",[32,2735,2737],{"className":2213,"code":2736,"language":2215,"meta":37,"style":37},"async def refresh(self, session: dict) -> dict:\n    lock_key = f\"bff:lock:refresh:{session['user_id']}\"\n\n    async with self.redis.lock(lock_key, timeout=10, blocking_timeout=8):\n        # Re-read the session — another pod may have already refreshed it\n        fresh = await self.session_manager.get_session_by_user(session[\"user_id\"])\n        if time.time() \u003C fresh[\"expires_at\"] - 60:\n            return fresh  # Already refreshed; nothing to do\n\n        async with AsyncClient() as client:\n            response = await client.post(\n                settings.token_endpoint,\n                data={\n                    \"grant_type\": \"refresh_token\",\n                    \"refresh_token\": fresh[\"refresh_token\"],\n                    \"client_id\": settings.client_id,\n                    \"client_secret\": settings.client_secret,\n                }\n            )\n        new_tokens = response.json()\n        updated = {\n            **fresh,\n            **new_tokens,\n            \"expires_at\": time.time() + new_tokens[\"expires_in\"]\n        }\n        await self.session_manager.update_session(updated)\n        return updated\n",[19,2738,2739,2744,2749,2753,2758,2763,2768,2773,2778,2782,2787,2792,2797,2802,2807,2812,2817,2822,2827,2832,2837,2842,2847,2852,2857,2862,2867],{"__ignoreMap":37},[41,2740,2741],{"class":43,"line":44},[41,2742,2743],{},"async def refresh(self, session: dict) -> dict:\n",[41,2745,2746],{"class":43,"line":51},[41,2747,2748],{},"    lock_key = f\"bff:lock:refresh:{session['user_id']}\"\n",[41,2750,2751],{"class":43,"line":61},[41,2752,348],{"emptyLinePlaceholder":347},[41,2754,2755],{"class":43,"line":77},[41,2756,2757],{},"    async with self.redis.lock(lock_key, timeout=10, blocking_timeout=8):\n",[41,2759,2760],{"class":43,"line":90},[41,2761,2762],{},"        # Re-read the session — another pod may have already refreshed it\n",[41,2764,2765],{"class":43,"line":101},[41,2766,2767],{},"        fresh = await self.session_manager.get_session_by_user(session[\"user_id\"])\n",[41,2769,2770],{"class":43,"line":107},[41,2771,2772],{},"        if time.time() \u003C fresh[\"expires_at\"] - 60:\n",[41,2774,2775],{"class":43,"line":116},[41,2776,2777],{},"            return fresh  # Already refreshed; nothing to do\n",[41,2779,2780],{"class":43,"line":122},[41,2781,348],{"emptyLinePlaceholder":347},[41,2783,2784],{"class":43,"line":135},[41,2785,2786],{},"        async with AsyncClient() as client:\n",[41,2788,2789],{"class":43,"line":148},[41,2790,2791],{},"            response = await client.post(\n",[41,2793,2794],{"class":43,"line":161},[41,2795,2796],{},"                settings.token_endpoint,\n",[41,2798,2799],{"class":43,"line":192},[41,2800,2801],{},"                data={\n",[41,2803,2804],{"class":43,"line":205},[41,2805,2806],{},"                    \"grant_type\": \"refresh_token\",\n",[41,2808,2809],{"class":43,"line":213},[41,2810,2811],{},"                    \"refresh_token\": fresh[\"refresh_token\"],\n",[41,2813,2814],{"class":43,"line":226},[41,2815,2816],{},"                    \"client_id\": settings.client_id,\n",[41,2818,2819],{"class":43,"line":239},[41,2820,2821],{},"                    \"client_secret\": settings.client_secret,\n",[41,2823,2824],{"class":43,"line":250},[41,2825,2826],{},"                }\n",[41,2828,2829],{"class":43,"line":256},[41,2830,2831],{},"            )\n",[41,2833,2834],{"class":43,"line":262},[41,2835,2836],{},"        new_tokens = response.json()\n",[41,2838,2839],{"class":43,"line":268},[41,2840,2841],{},"        updated = {\n",[41,2843,2844],{"class":43,"line":276},[41,2845,2846],{},"            **fresh,\n",[41,2848,2849],{"class":43,"line":289},[41,2850,2851],{},"            **new_tokens,\n",[41,2853,2854],{"class":43,"line":302},[41,2855,2856],{},"            \"expires_at\": time.time() + new_tokens[\"expires_in\"]\n",[41,2858,2859],{"class":43,"line":313},[41,2860,2861],{},"        }\n",[41,2863,2864],{"class":43,"line":319},[41,2865,2866],{},"        await self.session_manager.update_session(updated)\n",[41,2868,2869],{"class":43,"line":757},[41,2870,2871],{},"        return updated\n",[24,2873,2875],{"id":2874},"trade-off-summary","Trade-off Summary",[2877,2878,2879,2895],"table",{},[2880,2881,2882],"thead",{},[2883,2884,2885,2889,2892],"tr",{},[2886,2887,2888],"th",{},"Aspect",[2886,2890,2891],{},"Without BFF",[2886,2893,2894],{},"With BFF",[2896,2897,2898,2910,2923,2934,2945],"tbody",{},[2883,2899,2900,2904,2907],{},[2901,2902,2903],"td",{},"Tokens in the browser",[2901,2905,2906],{},"Yes (localStorage / cookie)",[2901,2908,2909],{},"Never exposed",[2883,2911,2912,2917,2920],{},[2901,2913,2914,2916],{},[19,2915,2178],{}," exposure",[2901,2918,2919],{},"Absent — PKCE required",[2901,2921,2922],{},"Server-side only",[2883,2924,2925,2928,2931],{},[2901,2926,2927],{},"Token refresh",[2901,2929,2930],{},"Frontend-managed",[2901,2932,2933],{},"BFF-managed, with distributed lock",[2883,2935,2936,2939,2942],{},[2901,2937,2938],{},"XSS attack surface",[2901,2940,2941],{},"Tokens accessible",[2901,2943,2944],{},"Opaque session cookie only",[2883,2946,2947,2950,2953],{},[2901,2948,2949],{},"Operational complexity",[2901,2951,2952],{},"Simpler",[2901,2954,2955],{},"Additional service to operate",[15,2957,2958],{},"The BFF pattern is not a universal prescription. For public-facing applications with low-sensitivity data, client-side PKCE is simpler and perfectly adequate. However, for applications managing tokens with elevated privileges, sensitive scopes, or multi-API integrations — the BFF is the most architecturally sound approach available.",[2097,2960,2961],{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":37,"searchDepth":51,"depth":51,"links":2963},[2964,2965,2966,2971,2972],{"id":2139,"depth":51,"text":2140},{"id":2189,"depth":51,"text":2190},{"id":2204,"depth":51,"text":2205,"children":2967},[2968,2969,2970],{"id":2209,"depth":61,"text":2210},{"id":2432,"depth":61,"text":2433},{"id":2611,"depth":61,"text":2612},{"id":2729,"depth":51,"text":2730},{"id":2874,"depth":51,"text":2875},"2025-03-13",{},"/en/blog/pattern-bff",{"title":2127,"description":2136},"pattern-bff","en/blog/pattern-bff",[2980,2121,2981,2982,2983],"FastAPI","Azure B2C","Architecture","OAuth2","gpMAUaUWHFwn22F2ddZvQBWwLj0EGKM2PKUsk8TExMY",{"id":2986,"title":2987,"body":2988,"date":2973,"description":2996,"excerpt":2112,"extension":2113,"meta":4470,"navigation":347,"path":4471,"readTime":135,"seo":4472,"slug":4473,"stem":4474,"tags":4475,"__hash__":4479},"en_blog/en/blog/websockets-fastapi.md","WebSockets: Shared State, Authentication, and Disconnections",{"type":8,"value":2989,"toc":4458},[2990,2994,2997,3001,3041,3044,3048,3051,3351,3362,3366,3373,3376,3380,3506,3509,3771,3775,3778,3875,3878,3882,3885,4018,4028,4032,4035,4131,4137,4166,4170,4175,4178,4371,4377,4430,4437,4441,4455],[11,2991,2993],{"id":2992},"websockets-with-fastapi-shared-state-authentication-and-clean-disconnections","WebSockets with FastAPI: Shared State, Authentication, and Clean Disconnections",[15,2995,2996],{},"The official FastAPI WebSocket tutorial fits in twenty lines. That is sufficient to understand the API, not to build something reliable. Here is what the examples do not show: authentication, distributed state management, broadcasting, and dead connection cleanup.",[24,2998,3000],{"id":2999},"the-problem-with-the-basic-example","The Problem with the Basic Example",[32,3002,3004],{"className":2213,"code":3003,"language":2215,"meta":37,"style":37},"# What every tutorial shows\n@app.websocket(\"/ws\")\nasync def websocket_endpoint(websocket: WebSocket):\n    await websocket.accept()\n    while True:\n        data = await websocket.receive_text()\n        await websocket.send_text(f\"Message: {data}\")\n",[19,3005,3006,3011,3016,3021,3026,3031,3036],{"__ignoreMap":37},[41,3007,3008],{"class":43,"line":44},[41,3009,3010],{},"# What every tutorial shows\n",[41,3012,3013],{"class":43,"line":51},[41,3014,3015],{},"@app.websocket(\"/ws\")\n",[41,3017,3018],{"class":43,"line":61},[41,3019,3020],{},"async def websocket_endpoint(websocket: WebSocket):\n",[41,3022,3023],{"class":43,"line":77},[41,3024,3025],{},"    await websocket.accept()\n",[41,3027,3028],{"class":43,"line":90},[41,3029,3030],{},"    while True:\n",[41,3032,3033],{"class":43,"line":101},[41,3034,3035],{},"        data = await websocket.receive_text()\n",[41,3037,3038],{"class":43,"line":107},[41,3039,3040],{},"        await websocket.send_text(f\"Message: {data}\")\n",[15,3042,3043],{},"This code has several production problems: no authentication, no handling of unexpected disconnections, no broadcasting to other clients, and state that does not survive a pod restart.",[24,3045,3047],{"id":3046},"connectionmanager-handling-multiple-clients","ConnectionManager: Handling Multiple Clients",[15,3049,3050],{},"The first step is a manager that maintains the list of active connections:",[32,3052,3054],{"className":2213,"code":3053,"language":2215,"meta":37,"style":37},"import asyncio\nfrom fastapi import WebSocket\nfrom typing import Any\nimport logging\n\nlogger = logging.getLogger(__name__)\n\nclass ConnectionManager:\n    def __init__(self):\n        # user_id -> list of websockets (a user may have multiple tabs open)\n        self._connections: dict[str, list[WebSocket]] = {}\n        self._lock = asyncio.Lock()\n\n    async def connect(self, websocket: WebSocket, user_id: str) -> None:\n        await websocket.accept()\n        async with self._lock:\n            if user_id not in self._connections:\n                self._connections[user_id] = []\n            self._connections[user_id].append(websocket)\n        logger.info(f\"WebSocket connected: user={user_id}, total={self.total_connections}\")\n\n    async def disconnect(self, websocket: WebSocket, user_id: str) -> None:\n        async with self._lock:\n            if user_id in self._connections:\n                self._connections[user_id] = [\n                    ws for ws in self._connections[user_id] if ws != websocket\n                ]\n                if not self._connections[user_id]:\n                    del self._connections[user_id]\n        logger.info(f\"WebSocket disconnected: user={user_id}\")\n\n    async def send_to_user(self, user_id: str, message: dict) -> None:\n        \"\"\"Sends a message to all connections belonging to a given user.\"\"\"\n        connections = self._connections.get(user_id, [])\n        dead_connections = []\n\n        for websocket in connections:\n            try:\n                await websocket.send_json(message)\n            except Exception:\n                dead_connections.append(websocket)\n\n        for ws in dead_connections:\n            await self.disconnect(ws, user_id)\n\n    async def broadcast(self, message: dict, exclude_user: str | None = None) -> None:\n        \"\"\"Broadcasts a message to all connected users.\"\"\"\n        tasks = []\n        for user_id in list(self._connections.keys()):\n            if user_id != exclude_user:\n                tasks.append(self.send_to_user(user_id, message))\n        await asyncio.gather(*tasks, return_exceptions=True)\n\n    @property\n    def total_connections(self) -> int:\n        return sum(len(ws_list) for ws_list in self._connections.values())\n\nmanager = ConnectionManager()\n",[19,3055,3056,3061,3066,3071,3076,3080,3085,3089,3094,3099,3104,3109,3114,3118,3123,3128,3133,3138,3143,3148,3153,3157,3162,3166,3171,3176,3181,3186,3191,3196,3201,3205,3210,3215,3220,3225,3229,3234,3239,3244,3249,3254,3258,3264,3270,3275,3281,3287,3293,3299,3305,3311,3317,3322,3328,3334,3340,3345],{"__ignoreMap":37},[41,3057,3058],{"class":43,"line":44},[41,3059,3060],{},"import asyncio\n",[41,3062,3063],{"class":43,"line":51},[41,3064,3065],{},"from fastapi import WebSocket\n",[41,3067,3068],{"class":43,"line":61},[41,3069,3070],{},"from typing import Any\n",[41,3072,3073],{"class":43,"line":77},[41,3074,3075],{},"import logging\n",[41,3077,3078],{"class":43,"line":90},[41,3079,348],{"emptyLinePlaceholder":347},[41,3081,3082],{"class":43,"line":101},[41,3083,3084],{},"logger = logging.getLogger(__name__)\n",[41,3086,3087],{"class":43,"line":107},[41,3088,348],{"emptyLinePlaceholder":347},[41,3090,3091],{"class":43,"line":116},[41,3092,3093],{},"class ConnectionManager:\n",[41,3095,3096],{"class":43,"line":122},[41,3097,3098],{},"    def __init__(self):\n",[41,3100,3101],{"class":43,"line":135},[41,3102,3103],{},"        # user_id -> list of websockets (a user may have multiple tabs open)\n",[41,3105,3106],{"class":43,"line":148},[41,3107,3108],{},"        self._connections: dict[str, list[WebSocket]] = {}\n",[41,3110,3111],{"class":43,"line":161},[41,3112,3113],{},"        self._lock = asyncio.Lock()\n",[41,3115,3116],{"class":43,"line":192},[41,3117,348],{"emptyLinePlaceholder":347},[41,3119,3120],{"class":43,"line":205},[41,3121,3122],{},"    async def connect(self, websocket: WebSocket, user_id: str) -> None:\n",[41,3124,3125],{"class":43,"line":213},[41,3126,3127],{},"        await websocket.accept()\n",[41,3129,3130],{"class":43,"line":226},[41,3131,3132],{},"        async with self._lock:\n",[41,3134,3135],{"class":43,"line":239},[41,3136,3137],{},"            if user_id not in self._connections:\n",[41,3139,3140],{"class":43,"line":250},[41,3141,3142],{},"                self._connections[user_id] = []\n",[41,3144,3145],{"class":43,"line":256},[41,3146,3147],{},"            self._connections[user_id].append(websocket)\n",[41,3149,3150],{"class":43,"line":262},[41,3151,3152],{},"        logger.info(f\"WebSocket connected: user={user_id}, total={self.total_connections}\")\n",[41,3154,3155],{"class":43,"line":268},[41,3156,348],{"emptyLinePlaceholder":347},[41,3158,3159],{"class":43,"line":276},[41,3160,3161],{},"    async def disconnect(self, websocket: WebSocket, user_id: str) -> None:\n",[41,3163,3164],{"class":43,"line":289},[41,3165,3132],{},[41,3167,3168],{"class":43,"line":302},[41,3169,3170],{},"            if user_id in self._connections:\n",[41,3172,3173],{"class":43,"line":313},[41,3174,3175],{},"                self._connections[user_id] = [\n",[41,3177,3178],{"class":43,"line":319},[41,3179,3180],{},"                    ws for ws in self._connections[user_id] if ws != websocket\n",[41,3182,3183],{"class":43,"line":757},[41,3184,3185],{},"                ]\n",[41,3187,3188],{"class":43,"line":762},[41,3189,3190],{},"                if not self._connections[user_id]:\n",[41,3192,3193],{"class":43,"line":774},[41,3194,3195],{},"                    del self._connections[user_id]\n",[41,3197,3198],{"class":43,"line":785},[41,3199,3200],{},"        logger.info(f\"WebSocket disconnected: user={user_id}\")\n",[41,3202,3203],{"class":43,"line":798},[41,3204,348],{"emptyLinePlaceholder":347},[41,3206,3207],{"class":43,"line":809},[41,3208,3209],{},"    async def send_to_user(self, user_id: str, message: dict) -> None:\n",[41,3211,3212],{"class":43,"line":1507},[41,3213,3214],{},"        \"\"\"Sends a message to all connections belonging to a given user.\"\"\"\n",[41,3216,3217],{"class":43,"line":1516},[41,3218,3219],{},"        connections = self._connections.get(user_id, [])\n",[41,3221,3222],{"class":43,"line":2385},[41,3223,3224],{},"        dead_connections = []\n",[41,3226,3227],{"class":43,"line":2391},[41,3228,348],{"emptyLinePlaceholder":347},[41,3230,3231],{"class":43,"line":2397},[41,3232,3233],{},"        for websocket in connections:\n",[41,3235,3236],{"class":43,"line":2403},[41,3237,3238],{},"            try:\n",[41,3240,3241],{"class":43,"line":2409},[41,3242,3243],{},"                await websocket.send_json(message)\n",[41,3245,3246],{"class":43,"line":2415},[41,3247,3248],{},"            except Exception:\n",[41,3250,3251],{"class":43,"line":2421},[41,3252,3253],{},"                dead_connections.append(websocket)\n",[41,3255,3256],{"class":43,"line":2426},[41,3257,348],{"emptyLinePlaceholder":347},[41,3259,3261],{"class":43,"line":3260},43,[41,3262,3263],{},"        for ws in dead_connections:\n",[41,3265,3267],{"class":43,"line":3266},44,[41,3268,3269],{},"            await self.disconnect(ws, user_id)\n",[41,3271,3273],{"class":43,"line":3272},45,[41,3274,348],{"emptyLinePlaceholder":347},[41,3276,3278],{"class":43,"line":3277},46,[41,3279,3280],{},"    async def broadcast(self, message: dict, exclude_user: str | None = None) -> None:\n",[41,3282,3284],{"class":43,"line":3283},47,[41,3285,3286],{},"        \"\"\"Broadcasts a message to all connected users.\"\"\"\n",[41,3288,3290],{"class":43,"line":3289},48,[41,3291,3292],{},"        tasks = []\n",[41,3294,3296],{"class":43,"line":3295},49,[41,3297,3298],{},"        for user_id in list(self._connections.keys()):\n",[41,3300,3302],{"class":43,"line":3301},50,[41,3303,3304],{},"            if user_id != exclude_user:\n",[41,3306,3308],{"class":43,"line":3307},51,[41,3309,3310],{},"                tasks.append(self.send_to_user(user_id, message))\n",[41,3312,3314],{"class":43,"line":3313},52,[41,3315,3316],{},"        await asyncio.gather(*tasks, return_exceptions=True)\n",[41,3318,3320],{"class":43,"line":3319},53,[41,3321,348],{"emptyLinePlaceholder":347},[41,3323,3325],{"class":43,"line":3324},54,[41,3326,3327],{},"    @property\n",[41,3329,3331],{"class":43,"line":3330},55,[41,3332,3333],{},"    def total_connections(self) -> int:\n",[41,3335,3337],{"class":43,"line":3336},56,[41,3338,3339],{},"        return sum(len(ws_list) for ws_list in self._connections.values())\n",[41,3341,3343],{"class":43,"line":3342},57,[41,3344,348],{"emptyLinePlaceholder":347},[41,3346,3348],{"class":43,"line":3347},58,[41,3349,3350],{},"manager = ConnectionManager()\n",[15,3352,3353,3354,3357,3358,3361],{},"The ",[19,3355,3356],{},"asyncio.Lock()"," protects dictionary modifications — in Python, dict operations are not safe across concurrent coroutines. A user may have several simultaneous connections (multiple open tabs), which the ",[19,3359,3360],{},"dict[str, list[WebSocket]]"," structure handles natively.",[24,3363,3365],{"id":3364},"authenticating-a-websocket-connection","Authenticating a WebSocket Connection",[15,3367,3368,3369,3372],{},"This is where most implementations stumble. WebSockets do not support custom HTTP headers from the browser — it is not possible to send ",[19,3370,3371],{},"Authorization: Bearer ..."," in the initial handshake via the standard browser WebSocket API.",[15,3374,3375],{},"Two viable approaches:",[2207,3377,3379],{"id":3378},"approach-1-token-in-the-query-parameter","Approach 1: Token in the Query Parameter",[32,3381,3383],{"className":2213,"code":3382,"language":2215,"meta":37,"style":37},"from fastapi import WebSocket, WebSocketException, status, Depends, Query\nfrom app.core.security import verify_token\n\nasync def get_websocket_user(\n    websocket: WebSocket,\n    token: str = Query(...),\n) -> str:\n    \"\"\"Extracts and validates the user from the query parameter token.\"\"\"\n    payload = verify_token(token)\n    if payload is None:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n    return payload[\"sub\"]\n\n@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    user_id: str = Depends(get_websocket_user),\n):\n    await manager.connect(websocket, user_id)\n    try:\n        while True:\n            await websocket.receive_text()  # Keep the connection alive\n    except Exception:\n        await manager.disconnect(websocket, user_id)\n",[19,3384,3385,3390,3395,3399,3404,3409,3414,3419,3424,3429,3434,3439,3444,3449,3453,3458,3463,3467,3472,3476,3481,3486,3491,3496,3501],{"__ignoreMap":37},[41,3386,3387],{"class":43,"line":44},[41,3388,3389],{},"from fastapi import WebSocket, WebSocketException, status, Depends, Query\n",[41,3391,3392],{"class":43,"line":51},[41,3393,3394],{},"from app.core.security import verify_token\n",[41,3396,3397],{"class":43,"line":61},[41,3398,348],{"emptyLinePlaceholder":347},[41,3400,3401],{"class":43,"line":77},[41,3402,3403],{},"async def get_websocket_user(\n",[41,3405,3406],{"class":43,"line":90},[41,3407,3408],{},"    websocket: WebSocket,\n",[41,3410,3411],{"class":43,"line":101},[41,3412,3413],{},"    token: str = Query(...),\n",[41,3415,3416],{"class":43,"line":107},[41,3417,3418],{},") -> str:\n",[41,3420,3421],{"class":43,"line":116},[41,3422,3423],{},"    \"\"\"Extracts and validates the user from the query parameter token.\"\"\"\n",[41,3425,3426],{"class":43,"line":122},[41,3427,3428],{},"    payload = verify_token(token)\n",[41,3430,3431],{"class":43,"line":135},[41,3432,3433],{},"    if payload is None:\n",[41,3435,3436],{"class":43,"line":148},[41,3437,3438],{},"        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n",[41,3440,3441],{"class":43,"line":161},[41,3442,3443],{},"        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n",[41,3445,3446],{"class":43,"line":192},[41,3447,3448],{},"    return payload[\"sub\"]\n",[41,3450,3451],{"class":43,"line":205},[41,3452,348],{"emptyLinePlaceholder":347},[41,3454,3455],{"class":43,"line":213},[41,3456,3457],{},"@app.websocket(\"/ws/notifications\")\n",[41,3459,3460],{"class":43,"line":226},[41,3461,3462],{},"async def notifications_ws(\n",[41,3464,3465],{"class":43,"line":239},[41,3466,3408],{},[41,3468,3469],{"class":43,"line":250},[41,3470,3471],{},"    user_id: str = Depends(get_websocket_user),\n",[41,3473,3474],{"class":43,"line":256},[41,3475,2496],{},[41,3477,3478],{"class":43,"line":262},[41,3479,3480],{},"    await manager.connect(websocket, user_id)\n",[41,3482,3483],{"class":43,"line":268},[41,3484,3485],{},"    try:\n",[41,3487,3488],{"class":43,"line":276},[41,3489,3490],{},"        while True:\n",[41,3492,3493],{"class":43,"line":289},[41,3494,3495],{},"            await websocket.receive_text()  # Keep the connection alive\n",[41,3497,3498],{"class":43,"line":302},[41,3499,3500],{},"    except Exception:\n",[41,3502,3503],{"class":43,"line":313},[41,3504,3505],{},"        await manager.disconnect(websocket, user_id)\n",[15,3507,3508],{},"On the Vue.js client side:",[32,3510,3512],{"className":332,"code":3511,"language":334,"meta":37,"style":37},"// composables/useWebSocket.ts\nexport function useWebSocket() {\n  const token = useCookie(\"access_token\")\n  const ws = ref\u003CWebSocket | null>(null)\n\n  const connect = () => {\n    ws.value = new WebSocket(\n      `wss://api.myapp.com/ws/notifications?token=${token.value}`,\n    )\n    ws.value.onmessage = (event) => {\n      const message = JSON.parse(event.data)\n      handleMessage(message)\n    }\n    ws.value.onclose = () => {\n      // Automatic reconnection after 3 seconds\n      setTimeout(connect, 3000)\n    }\n  }\n\n  onMounted(connect)\n  onUnmounted(() => ws.value?.close())\n\n  return { ws }\n}\n",[19,3513,3514,3519,3531,3550,3576,3580,3595,3609,3626,3630,3651,3672,3680,3684,3699,3704,3717,3721,3725,3729,3737,3756,3760,3767],{"__ignoreMap":37},[41,3515,3516],{"class":43,"line":44},[41,3517,3518],{"class":341},"// composables/useWebSocket.ts\n",[41,3520,3521,3523,3525,3528],{"class":43,"line":51},[41,3522,354],{"class":353},[41,3524,1197],{"class":353},[41,3526,3527],{"class":360}," useWebSocket",[41,3529,3530],{"class":47},"() {\n",[41,3532,3533,3535,3538,3540,3543,3545,3548],{"class":43,"line":61},[41,3534,1267],{"class":353},[41,3536,3537],{"class":54}," token",[41,3539,364],{"class":353},[41,3541,3542],{"class":360}," useCookie",[41,3544,1321],{"class":47},[41,3546,3547],{"class":70},"\"access_token\"",[41,3549,1327],{"class":47},[41,3551,3552,3554,3557,3559,3561,3563,3566,3568,3570,3572,3574],{"class":43,"line":77},[41,3553,1267],{"class":353},[41,3555,3556],{"class":54}," ws",[41,3558,364],{"class":353},[41,3560,1275],{"class":360},[41,3562,907],{"class":47},[41,3564,3565],{"class":360},"WebSocket",[41,3567,370],{"class":353},[41,3569,1122],{"class":54},[41,3571,1286],{"class":47},[41,3573,1289],{"class":54},[41,3575,1327],{"class":47},[41,3577,3578],{"class":43,"line":90},[41,3579,348],{"emptyLinePlaceholder":347},[41,3581,3582,3584,3587,3589,3591,3593],{"class":43,"line":101},[41,3583,1267],{"class":353},[41,3585,3586],{"class":360}," connect",[41,3588,364],{"class":353},[41,3590,1169],{"class":47},[41,3592,1172],{"class":353},[41,3594,482],{"class":47},[41,3596,3597,3600,3602,3604,3607],{"class":43,"line":107},[41,3598,3599],{"class":47},"    ws.value ",[41,3601,1382],{"class":353},[41,3603,2027],{"class":353},[41,3605,3606],{"class":360}," WebSocket",[41,3608,1947],{"class":47},[41,3610,3611,3614,3617,3619,3621,3624],{"class":43,"line":116},[41,3612,3613],{"class":70},"      `wss://api.myapp.com/ws/notifications?token=${",[41,3615,3616],{"class":47},"token",[41,3618,1625],{"class":70},[41,3620,1832],{"class":47},[41,3622,3623],{"class":70},"}`",[41,3625,74],{"class":47},[41,3627,3628],{"class":43,"line":122},[41,3629,1964],{"class":47},[41,3631,3632,3635,3638,3640,3642,3645,3647,3649],{"class":43,"line":135},[41,3633,3634],{"class":47},"    ws.value.",[41,3636,3637],{"class":360},"onmessage",[41,3639,364],{"class":353},[41,3641,2010],{"class":47},[41,3643,3644],{"class":487},"event",[41,3646,1292],{"class":47},[41,3648,1172],{"class":353},[41,3650,482],{"class":47},[41,3652,3653,3656,3659,3661,3664,3666,3669],{"class":43,"line":148},[41,3654,3655],{"class":353},"      const",[41,3657,3658],{"class":54}," message",[41,3660,364],{"class":353},[41,3662,3663],{"class":54}," JSON",[41,3665,1625],{"class":47},[41,3667,3668],{"class":360},"parse",[41,3670,3671],{"class":47},"(event.data)\n",[41,3673,3674,3677],{"class":43,"line":161},[41,3675,3676],{"class":360},"      handleMessage",[41,3678,3679],{"class":47},"(message)\n",[41,3681,3682],{"class":43,"line":192},[41,3683,259],{"class":47},[41,3685,3686,3688,3691,3693,3695,3697],{"class":43,"line":205},[41,3687,3634],{"class":47},[41,3689,3690],{"class":360},"onclose",[41,3692,364],{"class":353},[41,3694,1169],{"class":47},[41,3696,1172],{"class":353},[41,3698,482],{"class":47},[41,3700,3701],{"class":43,"line":213},[41,3702,3703],{"class":341},"      // Automatic reconnection after 3 seconds\n",[41,3705,3706,3709,3712,3715],{"class":43,"line":226},[41,3707,3708],{"class":360},"      setTimeout",[41,3710,3711],{"class":47},"(connect, ",[41,3713,3714],{"class":54},"3000",[41,3716,1327],{"class":47},[41,3718,3719],{"class":43,"line":239},[41,3720,259],{"class":47},[41,3722,3723],{"class":43,"line":250},[41,3724,316],{"class":47},[41,3726,3727],{"class":43,"line":256},[41,3728,348],{"emptyLinePlaceholder":347},[41,3730,3731,3734],{"class":43,"line":262},[41,3732,3733],{"class":360},"  onMounted",[41,3735,3736],{"class":47},"(connect)\n",[41,3738,3739,3742,3745,3747,3750,3753],{"class":43,"line":268},[41,3740,3741],{"class":360},"  onUnmounted",[41,3743,3744],{"class":47},"(() ",[41,3746,1172],{"class":353},[41,3748,3749],{"class":47}," ws.value?.",[41,3751,3752],{"class":360},"close",[41,3754,3755],{"class":47},"())\n",[41,3757,3758],{"class":43,"line":276},[41,3759,348],{"emptyLinePlaceholder":347},[41,3761,3762,3764],{"class":43,"line":289},[41,3763,1510],{"class":353},[41,3765,3766],{"class":47}," { ws }\n",[41,3768,3769],{"class":43,"line":302},[41,3770,322],{"class":47},[2207,3772,3774],{"id":3773},"approach-2-session-cookie-more-secure","Approach 2: Session Cookie (More Secure)",[15,3776,3777],{},"If you are using the BFF pattern with HttpOnly cookies, the WebSocket connection automatically sends the domain cookies — this is the browser's native behaviour:",[32,3779,3781],{"className":2213,"code":3780,"language":2215,"meta":37,"style":37},"@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    session: dict = Depends(get_websocket_session),\n):\n    user_id = session[\"user_id\"]\n    await manager.connect(websocket, user_id)\n    # ...\n\nasync def get_websocket_session(websocket: WebSocket) -> dict:\n    session_id = websocket.cookies.get(\"session_id\")\n    if not session_id:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n\n    session = await session_manager.get_session_by_id(session_id)\n    if not session:\n        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)\n        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)\n\n    return session\n",[19,3782,3783,3787,3791,3795,3800,3804,3809,3813,3818,3822,3827,3832,3837,3841,3845,3849,3854,3858,3862,3866,3870],{"__ignoreMap":37},[41,3784,3785],{"class":43,"line":44},[41,3786,3457],{},[41,3788,3789],{"class":43,"line":51},[41,3790,3462],{},[41,3792,3793],{"class":43,"line":61},[41,3794,3408],{},[41,3796,3797],{"class":43,"line":77},[41,3798,3799],{},"    session: dict = Depends(get_websocket_session),\n",[41,3801,3802],{"class":43,"line":90},[41,3803,2496],{},[41,3805,3806],{"class":43,"line":101},[41,3807,3808],{},"    user_id = session[\"user_id\"]\n",[41,3810,3811],{"class":43,"line":107},[41,3812,3480],{},[41,3814,3815],{"class":43,"line":116},[41,3816,3817],{},"    # ...\n",[41,3819,3820],{"class":43,"line":122},[41,3821,348],{"emptyLinePlaceholder":347},[41,3823,3824],{"class":43,"line":135},[41,3825,3826],{},"async def get_websocket_session(websocket: WebSocket) -> dict:\n",[41,3828,3829],{"class":43,"line":148},[41,3830,3831],{},"    session_id = websocket.cookies.get(\"session_id\")\n",[41,3833,3834],{"class":43,"line":161},[41,3835,3836],{},"    if not session_id:\n",[41,3838,3839],{"class":43,"line":192},[41,3840,3438],{},[41,3842,3843],{"class":43,"line":205},[41,3844,3443],{},[41,3846,3847],{"class":43,"line":213},[41,3848,348],{"emptyLinePlaceholder":347},[41,3850,3851],{"class":43,"line":226},[41,3852,3853],{},"    session = await session_manager.get_session_by_id(session_id)\n",[41,3855,3856],{"class":43,"line":239},[41,3857,2637],{},[41,3859,3860],{"class":43,"line":250},[41,3861,3438],{},[41,3863,3864],{"class":43,"line":256},[41,3865,3443],{},[41,3867,3868],{"class":43,"line":262},[41,3869,348],{"emptyLinePlaceholder":347},[41,3871,3872],{"class":43,"line":268},[41,3873,3874],{},"    return session\n",[15,3876,3877],{},"The cookie approach is preferable on a BFF — the token never appears in plaintext in the URL, which would otherwise be visible in server logs.",[24,3879,3881],{"id":3880},"handling-unexpected-disconnections-cleanly","Handling Unexpected Disconnections Cleanly",[15,3883,3884],{},"A WebSocket connection can die in several ways: the user closes the tab, the network drops, the client pod restarts. These cases must be detected and cleaned up:",[32,3886,3888],{"className":2213,"code":3887,"language":2215,"meta":37,"style":37},"@app.websocket(\"/ws/notifications\")\nasync def notifications_ws(\n    websocket: WebSocket,\n    user_id: str = Depends(get_websocket_user),\n):\n    await manager.connect(websocket, user_id)\n    try:\n        while True:\n            try:\n                # Timeout on receive — detects dead connections\n                data = await asyncio.wait_for(\n                    websocket.receive_text(),\n                    timeout=30.0\n                )\n                await handle_client_message(user_id, data)\n\n            except asyncio.TimeoutError:\n                # Send a ping to verify the client is still alive\n                try:\n                    await websocket.send_json({\"type\": \"ping\"})\n                except Exception:\n                    break  # Dead connection — exit the loop\n\n    except Exception as e:\n        logger.info(f\"WebSocket closed for user={user_id}: {type(e).__name__}\")\n    finally:\n        # Cleanup guaranteed regardless of how the connection ended\n        await manager.disconnect(websocket, user_id)\n",[19,3889,3890,3894,3898,3902,3906,3910,3914,3918,3922,3926,3931,3936,3941,3946,3951,3956,3960,3965,3970,3975,3980,3985,3990,3994,3999,4004,4009,4014],{"__ignoreMap":37},[41,3891,3892],{"class":43,"line":44},[41,3893,3457],{},[41,3895,3896],{"class":43,"line":51},[41,3897,3462],{},[41,3899,3900],{"class":43,"line":61},[41,3901,3408],{},[41,3903,3904],{"class":43,"line":77},[41,3905,3471],{},[41,3907,3908],{"class":43,"line":90},[41,3909,2496],{},[41,3911,3912],{"class":43,"line":101},[41,3913,3480],{},[41,3915,3916],{"class":43,"line":107},[41,3917,3485],{},[41,3919,3920],{"class":43,"line":116},[41,3921,3490],{},[41,3923,3924],{"class":43,"line":122},[41,3925,3238],{},[41,3927,3928],{"class":43,"line":135},[41,3929,3930],{},"                # Timeout on receive — detects dead connections\n",[41,3932,3933],{"class":43,"line":148},[41,3934,3935],{},"                data = await asyncio.wait_for(\n",[41,3937,3938],{"class":43,"line":161},[41,3939,3940],{},"                    websocket.receive_text(),\n",[41,3942,3943],{"class":43,"line":192},[41,3944,3945],{},"                    timeout=30.0\n",[41,3947,3948],{"class":43,"line":205},[41,3949,3950],{},"                )\n",[41,3952,3953],{"class":43,"line":213},[41,3954,3955],{},"                await handle_client_message(user_id, data)\n",[41,3957,3958],{"class":43,"line":226},[41,3959,348],{"emptyLinePlaceholder":347},[41,3961,3962],{"class":43,"line":239},[41,3963,3964],{},"            except asyncio.TimeoutError:\n",[41,3966,3967],{"class":43,"line":250},[41,3968,3969],{},"                # Send a ping to verify the client is still alive\n",[41,3971,3972],{"class":43,"line":256},[41,3973,3974],{},"                try:\n",[41,3976,3977],{"class":43,"line":262},[41,3978,3979],{},"                    await websocket.send_json({\"type\": \"ping\"})\n",[41,3981,3982],{"class":43,"line":268},[41,3983,3984],{},"                except Exception:\n",[41,3986,3987],{"class":43,"line":276},[41,3988,3989],{},"                    break  # Dead connection — exit the loop\n",[41,3991,3992],{"class":43,"line":289},[41,3993,348],{"emptyLinePlaceholder":347},[41,3995,3996],{"class":43,"line":302},[41,3997,3998],{},"    except Exception as e:\n",[41,4000,4001],{"class":43,"line":313},[41,4002,4003],{},"        logger.info(f\"WebSocket closed for user={user_id}: {type(e).__name__}\")\n",[41,4005,4006],{"class":43,"line":319},[41,4007,4008],{},"    finally:\n",[41,4010,4011],{"class":43,"line":757},[41,4012,4013],{},"        # Cleanup guaranteed regardless of how the connection ended\n",[41,4015,4016],{"class":43,"line":762},[41,4017,3505],{},[15,4019,3353,4020,4023,4024,4027],{},[19,4021,4022],{},"try/finally"," pattern around the main loop guarantees that ",[19,4025,4026],{},"disconnect"," is always called, regardless of the reason for closure.",[24,4029,4031],{"id":4030},"broadcasting-events-from-anywhere-in-the-application","Broadcasting Events From Anywhere in the Application",[15,4033,4034],{},"The real use case: a backend process completes and needs to notify connected clients in real time.",[32,4036,4038],{"className":2213,"code":4037,"language":2215,"meta":37,"style":37},"class CertificateService:\n    def __init__(self, repo: CertificateRepository, ws_manager: ConnectionManager):\n        self.repo = repo\n        self.ws_manager = ws_manager\n\n    async def process_certificate(self, cert_id: str, user_id: str) -> Certificate:\n        certificate = await self.repo.process(cert_id)\n\n        # Notify the user in real time\n        await self.ws_manager.send_to_user(user_id, {\n            \"type\": \"certificate_processed\",\n            \"data\": {\n                \"id\": certificate.id,\n                \"status\": certificate.status,\n                \"volume\": certificate.volume,\n            }\n        })\n\n        return certificate\n",[19,4039,4040,4045,4050,4055,4060,4064,4069,4074,4078,4083,4088,4093,4098,4103,4108,4113,4117,4122,4126],{"__ignoreMap":37},[41,4041,4042],{"class":43,"line":44},[41,4043,4044],{},"class CertificateService:\n",[41,4046,4047],{"class":43,"line":51},[41,4048,4049],{},"    def __init__(self, repo: CertificateRepository, ws_manager: ConnectionManager):\n",[41,4051,4052],{"class":43,"line":61},[41,4053,4054],{},"        self.repo = repo\n",[41,4056,4057],{"class":43,"line":77},[41,4058,4059],{},"        self.ws_manager = ws_manager\n",[41,4061,4062],{"class":43,"line":90},[41,4063,348],{"emptyLinePlaceholder":347},[41,4065,4066],{"class":43,"line":101},[41,4067,4068],{},"    async def process_certificate(self, cert_id: str, user_id: str) -> Certificate:\n",[41,4070,4071],{"class":43,"line":107},[41,4072,4073],{},"        certificate = await self.repo.process(cert_id)\n",[41,4075,4076],{"class":43,"line":116},[41,4077,348],{"emptyLinePlaceholder":347},[41,4079,4080],{"class":43,"line":122},[41,4081,4082],{},"        # Notify the user in real time\n",[41,4084,4085],{"class":43,"line":135},[41,4086,4087],{},"        await self.ws_manager.send_to_user(user_id, {\n",[41,4089,4090],{"class":43,"line":148},[41,4091,4092],{},"            \"type\": \"certificate_processed\",\n",[41,4094,4095],{"class":43,"line":161},[41,4096,4097],{},"            \"data\": {\n",[41,4099,4100],{"class":43,"line":192},[41,4101,4102],{},"                \"id\": certificate.id,\n",[41,4104,4105],{"class":43,"line":205},[41,4106,4107],{},"                \"status\": certificate.status,\n",[41,4109,4110],{"class":43,"line":213},[41,4111,4112],{},"                \"volume\": certificate.volume,\n",[41,4114,4115],{"class":43,"line":226},[41,4116,2561],{},[41,4118,4119],{"class":43,"line":239},[41,4120,4121],{},"        })\n",[41,4123,4124],{"class":43,"line":250},[41,4125,348],{"emptyLinePlaceholder":347},[41,4127,4128],{"class":43,"line":256},[41,4129,4130],{},"        return certificate\n",[15,4132,3353,4133,4136],{},[19,4134,4135],{},"ConnectionManager"," is injected as a FastAPI dependency — a singleton shared across the entire process:",[32,4138,4140],{"className":2213,"code":4139,"language":2215,"meta":37,"style":37},"# app/api/dependencies.py\nfrom app.api.websockets import manager\n\ndef get_ws_manager() -> ConnectionManager:\n    return manager\n",[19,4141,4142,4147,4152,4156,4161],{"__ignoreMap":37},[41,4143,4144],{"class":43,"line":44},[41,4145,4146],{},"# app/api/dependencies.py\n",[41,4148,4149],{"class":43,"line":51},[41,4150,4151],{},"from app.api.websockets import manager\n",[41,4153,4154],{"class":43,"line":61},[41,4155,348],{"emptyLinePlaceholder":347},[41,4157,4158],{"class":43,"line":77},[41,4159,4160],{},"def get_ws_manager() -> ConnectionManager:\n",[41,4162,4163],{"class":43,"line":90},[41,4164,4165],{},"    return manager\n",[24,4167,4169],{"id":4168},"the-multi-pod-problem-distributed-state-with-redis-pubsub","The Multi-Pod Problem: Distributed State with Redis Pub/Sub",[15,4171,3353,4172,4174],{},[19,4173,4135],{}," as described above has a critical limitation: it is in-memory. On a multi-pod OpenShift deployment with three replicas, each pod has its own manager. An event processed on pod A will not be broadcast to clients connected to pod B or C.",[15,4176,4177],{},"The solution: Redis Pub/Sub as an inter-pod event bus.",[32,4179,4181],{"className":2213,"code":4180,"language":2215,"meta":37,"style":37},"import redis.asyncio as aioredis\nimport json\nimport asyncio\n\nclass DistributedConnectionManager(ConnectionManager):\n    def __init__(self, redis: aioredis.Redis):\n        super().__init__()\n        self.redis = redis\n        self.channel = \"ws:broadcast\"\n\n    async def publish_to_user(self, user_id: str, message: dict) -> None:\n        \"\"\"Publishes an event to Redis — all pods receive it.\"\"\"\n        await self.redis.publish(\n            f\"ws:user:{user_id}\",\n            json.dumps(message)\n        )\n\n    async def publish_broadcast(self, message: dict) -> None:\n        \"\"\"Publishes a broadcast to Redis.\"\"\"\n        await self.redis.publish(self.channel, json.dumps(message))\n\n    async def start_subscriber(self) -> None:\n        \"\"\"To be started at pod startup — listens for Redis events.\"\"\"\n        pubsub = self.redis.pubsub()\n        await pubsub.psubscribe(\"ws:user:*\", self.channel)\n\n        async for message in pubsub.listen():\n            if message[\"type\"] != \"pmessage\" and message[\"type\"] != \"message\":\n                continue\n\n            channel = message[\"channel\"].decode()\n            data = json.loads(message[\"data\"])\n\n            if channel == self.channel:\n                # Local broadcast to clients on THIS pod\n                await super().broadcast(data)\n            elif channel.startswith(\"ws:user:\"):\n                user_id = channel.split(\":\")[-1]\n                # Send to clients on THIS pod for this user\n                await super().send_to_user(user_id, data)\n",[19,4182,4183,4187,4191,4195,4199,4204,4209,4214,4218,4223,4227,4232,4237,4242,4247,4252,4256,4260,4265,4270,4275,4279,4284,4289,4294,4299,4303,4308,4313,4318,4322,4327,4332,4336,4341,4346,4351,4356,4361,4366],{"__ignoreMap":37},[41,4184,4185],{"class":43,"line":44},[41,4186,2242],{},[41,4188,4189],{"class":43,"line":51},[41,4190,2222],{},[41,4192,4193],{"class":43,"line":61},[41,4194,3060],{},[41,4196,4197],{"class":43,"line":77},[41,4198,348],{"emptyLinePlaceholder":347},[41,4200,4201],{"class":43,"line":90},[41,4202,4203],{},"class DistributedConnectionManager(ConnectionManager):\n",[41,4205,4206],{"class":43,"line":101},[41,4207,4208],{},"    def __init__(self, redis: aioredis.Redis):\n",[41,4210,4211],{"class":43,"line":107},[41,4212,4213],{},"        super().__init__()\n",[41,4215,4216],{"class":43,"line":116},[41,4217,2266],{},[41,4219,4220],{"class":43,"line":122},[41,4221,4222],{},"        self.channel = \"ws:broadcast\"\n",[41,4224,4225],{"class":43,"line":135},[41,4226,348],{"emptyLinePlaceholder":347},[41,4228,4229],{"class":43,"line":148},[41,4230,4231],{},"    async def publish_to_user(self, user_id: str, message: dict) -> None:\n",[41,4233,4234],{"class":43,"line":161},[41,4235,4236],{},"        \"\"\"Publishes an event to Redis — all pods receive it.\"\"\"\n",[41,4238,4239],{"class":43,"line":192},[41,4240,4241],{},"        await self.redis.publish(\n",[41,4243,4244],{"class":43,"line":205},[41,4245,4246],{},"            f\"ws:user:{user_id}\",\n",[41,4248,4249],{"class":43,"line":213},[41,4250,4251],{},"            json.dumps(message)\n",[41,4253,4254],{"class":43,"line":226},[41,4255,2334],{},[41,4257,4258],{"class":43,"line":239},[41,4259,348],{"emptyLinePlaceholder":347},[41,4261,4262],{"class":43,"line":250},[41,4263,4264],{},"    async def publish_broadcast(self, message: dict) -> None:\n",[41,4266,4267],{"class":43,"line":256},[41,4268,4269],{},"        \"\"\"Publishes a broadcast to Redis.\"\"\"\n",[41,4271,4272],{"class":43,"line":262},[41,4273,4274],{},"        await self.redis.publish(self.channel, json.dumps(message))\n",[41,4276,4277],{"class":43,"line":268},[41,4278,348],{"emptyLinePlaceholder":347},[41,4280,4281],{"class":43,"line":276},[41,4282,4283],{},"    async def start_subscriber(self) -> None:\n",[41,4285,4286],{"class":43,"line":289},[41,4287,4288],{},"        \"\"\"To be started at pod startup — listens for Redis events.\"\"\"\n",[41,4290,4291],{"class":43,"line":302},[41,4292,4293],{},"        pubsub = self.redis.pubsub()\n",[41,4295,4296],{"class":43,"line":313},[41,4297,4298],{},"        await pubsub.psubscribe(\"ws:user:*\", self.channel)\n",[41,4300,4301],{"class":43,"line":319},[41,4302,348],{"emptyLinePlaceholder":347},[41,4304,4305],{"class":43,"line":757},[41,4306,4307],{},"        async for message in pubsub.listen():\n",[41,4309,4310],{"class":43,"line":762},[41,4311,4312],{},"            if message[\"type\"] != \"pmessage\" and message[\"type\"] != \"message\":\n",[41,4314,4315],{"class":43,"line":774},[41,4316,4317],{},"                continue\n",[41,4319,4320],{"class":43,"line":785},[41,4321,348],{"emptyLinePlaceholder":347},[41,4323,4324],{"class":43,"line":798},[41,4325,4326],{},"            channel = message[\"channel\"].decode()\n",[41,4328,4329],{"class":43,"line":809},[41,4330,4331],{},"            data = json.loads(message[\"data\"])\n",[41,4333,4334],{"class":43,"line":1507},[41,4335,348],{"emptyLinePlaceholder":347},[41,4337,4338],{"class":43,"line":1516},[41,4339,4340],{},"            if channel == self.channel:\n",[41,4342,4343],{"class":43,"line":2385},[41,4344,4345],{},"                # Local broadcast to clients on THIS pod\n",[41,4347,4348],{"class":43,"line":2391},[41,4349,4350],{},"                await super().broadcast(data)\n",[41,4352,4353],{"class":43,"line":2397},[41,4354,4355],{},"            elif channel.startswith(\"ws:user:\"):\n",[41,4357,4358],{"class":43,"line":2403},[41,4359,4360],{},"                user_id = channel.split(\":\")[-1]\n",[41,4362,4363],{"class":43,"line":2409},[41,4364,4365],{},"                # Send to clients on THIS pod for this user\n",[41,4367,4368],{"class":43,"line":2415},[41,4369,4370],{},"                await super().send_to_user(user_id, data)\n",[15,4372,4373,4374,491],{},"Starting the subscriber in the FastAPI ",[19,4375,4376],{},"lifespan",[32,4378,4380],{"className":2213,"code":4379,"language":2215,"meta":37,"style":37},"@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    task = asyncio.create_task(distributed_manager.start_subscriber())\n    background_tasks.add(task)\n    task.add_done_callback(background_tasks.discard)\n\n    yield\n\n    task.cancel()\n    await asyncio.gather(task, return_exceptions=True)\n",[19,4381,4382,4387,4392,4397,4402,4407,4411,4416,4420,4425],{"__ignoreMap":37},[41,4383,4384],{"class":43,"line":44},[41,4385,4386],{},"@asynccontextmanager\n",[41,4388,4389],{"class":43,"line":51},[41,4390,4391],{},"async def lifespan(app: FastAPI):\n",[41,4393,4394],{"class":43,"line":61},[41,4395,4396],{},"    task = asyncio.create_task(distributed_manager.start_subscriber())\n",[41,4398,4399],{"class":43,"line":77},[41,4400,4401],{},"    background_tasks.add(task)\n",[41,4403,4404],{"class":43,"line":90},[41,4405,4406],{},"    task.add_done_callback(background_tasks.discard)\n",[41,4408,4409],{"class":43,"line":101},[41,4410,348],{"emptyLinePlaceholder":347},[41,4412,4413],{"class":43,"line":107},[41,4414,4415],{},"    yield\n",[41,4417,4418],{"class":43,"line":116},[41,4419,348],{"emptyLinePlaceholder":347},[41,4421,4422],{"class":43,"line":122},[41,4423,4424],{},"    task.cancel()\n",[41,4426,4427],{"class":43,"line":135},[41,4428,4429],{},"    await asyncio.gather(task, return_exceptions=True)\n",[15,4431,4432,4433,4436],{},"With this architecture, a service on pod A calls ",[19,4434,4435],{},"publish_to_user()"," — Redis propagates the event to all pods, and each pod delivers it locally to the relevant clients.",[24,4438,4440],{"id":4439},"key-takeaways","Key Takeaways",[15,4442,4443,4444,4446,4447,4450,4451,4454],{},"Production-grade WebSockets require solving four distinct problems: authentication (cookie or query parameter depending on the architecture), dead connection handling (ping/timeout with ",[19,4445,4022],{},"), broadcasting to multiple connections per user (",[19,4448,4449],{},"list[WebSocket]"," per ",[19,4452,4453],{},"user_id","), and multi-pod distribution (Redis Pub/Sub). Each problem is straightforward in isolation — it is their combination that determines whether a WebSocket implementation holds up under real-world conditions.",[2097,4456,4457],{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}",{"title":37,"searchDepth":51,"depth":51,"links":4459},[4460,4461,4462,4466,4467,4468,4469],{"id":2999,"depth":51,"text":3000},{"id":3046,"depth":51,"text":3047},{"id":3364,"depth":51,"text":3365,"children":4463},[4464,4465],{"id":3378,"depth":61,"text":3379},{"id":3773,"depth":61,"text":3774},{"id":3880,"depth":51,"text":3881},{"id":4030,"depth":51,"text":4031},{"id":4168,"depth":51,"text":4169},{"id":4439,"depth":51,"text":4440},{},"/en/blog/websockets-fastapi",{"title":2987,"description":2996},"websockets-fastapi","en/blog/websockets-fastapi",[2980,4476,4477,4478],"WebSockets","Python","Real-time","9P-B3idvAfGVELqd9UDMeKY_PpsqrJBfZCceq7bvBvg",{"id":4481,"title":4482,"body":4483,"date":5467,"description":5468,"excerpt":2112,"extension":2113,"meta":5469,"navigation":347,"path":5470,"readTime":135,"seo":5471,"slug":5472,"stem":5473,"tags":5474,"__hash__":5476},"en_blog/en/blog/fastapi-architecture.md","Building a FastAPI Project Structure That Scales and Lasts",{"type":8,"value":4484,"toc":5457},[4485,4489,4496,4500,4506,4509,4513,4669,4672,4676,4817,4820,4824,5032,5038,5042,5130,5137,5141,5220,5223,5227,5399,5406,5410,5413,5452,5455],[11,4486,4488],{"id":4487},"structuring-a-fastapi-project-that-lasts-architecture-layers-and-dependencies","Structuring a FastAPI Project That Lasts: Architecture, Layers, and Dependencies",[15,4490,4491,4492,4495],{},"Most FastAPI tutorials put everything in ",[19,4493,4494],{},"main.py",". That works for a demonstration, not for an application maintained by a team over several years. Here is the architecture applied in practice on projects built to last, along with the reasoning behind each decision.",[24,4497,4499],{"id":4498},"project-structure","Project Structure",[32,4501,4504],{"className":4502,"code":4503,"language":2196},[2194],"app/\n├── api/\n│   ├── dependencies.py       # Injected dependencies (auth, db, etc.)\n│   ├── routers/\n│   │   ├── certificates.py\n│   │   └── accounts.py\n│   └── schemas/\n│       ├── certificate.py    # Pydantic models — API input/output\n│       └── account.py\n├── core/\n│   ├── config.py             # Settings (pydantic-settings)\n│   └── security.py           # JWT, hashing, etc.\n├── domain/\n│   ├── models.py             # Dataclasses — internal business objects\n│   └── exceptions.py         # Business exceptions\n├── infrastructure/\n│   ├── database.py           # SQLAlchemy session\n│   └── repositories/\n│       ├── certificate_repo.py\n│       └── account_repo.py\n├── services/\n│   ├── certificate_service.py\n│   └── account_service.py\n└── main.py\n",[19,4505,4503],{"__ignoreMap":37},[15,4507,4508],{},"This structure separates four layers: API (HTTP), domain (business logic), infrastructure (database, external services), and services (orchestration between the two).",[24,4510,4512],{"id":4511},"the-api-layer-routers-and-schemas","The API Layer: Routers and Schemas",[32,4514,4516],{"className":2213,"code":4515,"language":2215,"meta":37,"style":37},"# app/api/routers/certificates.py\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom app.api.schemas.certificate import CertificateCreate, CertificateResponse\nfrom app.api.dependencies import get_certificate_service, get_current_user\nfrom app.services.certificate_service import CertificateService\nfrom app.domain.exceptions import CertificateNotFound, InsufficientVolume\n\nrouter = APIRouter(prefix=\"/certificates\", tags=[\"certificates\"])\n\n@router.post(\"/\", response_model=CertificateResponse, status_code=status.HTTP_201_CREATED)\nasync def create_certificate(\n    payload: CertificateCreate,\n    service: CertificateService = Depends(get_certificate_service),\n    current_user: str = Depends(get_current_user),\n):\n    try:\n        certificate = await service.create(payload, owner=current_user)\n        return CertificateResponse.model_validate(certificate)\n    except InsufficientVolume as e:\n        raise HTTPException(status_code=422, detail=str(e))\n\n@router.get(\"/{certificate_id}\", response_model=CertificateResponse)\nasync def get_certificate(\n    certificate_id: str,\n    service: CertificateService = Depends(get_certificate_service),\n):\n    try:\n        return CertificateResponse.model_validate(\n            await service.get_by_id(certificate_id)\n        )\n    except CertificateNotFound:\n        raise HTTPException(status_code=404, detail=\"Certificate not found\")\n",[19,4517,4518,4523,4528,4533,4538,4543,4548,4552,4557,4561,4566,4571,4576,4581,4586,4590,4594,4599,4604,4609,4614,4618,4623,4628,4633,4637,4641,4645,4650,4655,4659,4664],{"__ignoreMap":37},[41,4519,4520],{"class":43,"line":44},[41,4521,4522],{},"# app/api/routers/certificates.py\n",[41,4524,4525],{"class":43,"line":51},[41,4526,4527],{},"from fastapi import APIRouter, Depends, HTTPException, status\n",[41,4529,4530],{"class":43,"line":61},[41,4531,4532],{},"from app.api.schemas.certificate import CertificateCreate, CertificateResponse\n",[41,4534,4535],{"class":43,"line":77},[41,4536,4537],{},"from app.api.dependencies import get_certificate_service, get_current_user\n",[41,4539,4540],{"class":43,"line":90},[41,4541,4542],{},"from app.services.certificate_service import CertificateService\n",[41,4544,4545],{"class":43,"line":101},[41,4546,4547],{},"from app.domain.exceptions import CertificateNotFound, InsufficientVolume\n",[41,4549,4550],{"class":43,"line":107},[41,4551,348],{"emptyLinePlaceholder":347},[41,4553,4554],{"class":43,"line":116},[41,4555,4556],{},"router = APIRouter(prefix=\"/certificates\", tags=[\"certificates\"])\n",[41,4558,4559],{"class":43,"line":122},[41,4560,348],{"emptyLinePlaceholder":347},[41,4562,4563],{"class":43,"line":135},[41,4564,4565],{},"@router.post(\"/\", response_model=CertificateResponse, status_code=status.HTTP_201_CREATED)\n",[41,4567,4568],{"class":43,"line":148},[41,4569,4570],{},"async def create_certificate(\n",[41,4572,4573],{"class":43,"line":161},[41,4574,4575],{},"    payload: CertificateCreate,\n",[41,4577,4578],{"class":43,"line":192},[41,4579,4580],{},"    service: CertificateService = Depends(get_certificate_service),\n",[41,4582,4583],{"class":43,"line":205},[41,4584,4585],{},"    current_user: str = Depends(get_current_user),\n",[41,4587,4588],{"class":43,"line":213},[41,4589,2496],{},[41,4591,4592],{"class":43,"line":226},[41,4593,3485],{},[41,4595,4596],{"class":43,"line":239},[41,4597,4598],{},"        certificate = await service.create(payload, owner=current_user)\n",[41,4600,4601],{"class":43,"line":250},[41,4602,4603],{},"        return CertificateResponse.model_validate(certificate)\n",[41,4605,4606],{"class":43,"line":256},[41,4607,4608],{},"    except InsufficientVolume as e:\n",[41,4610,4611],{"class":43,"line":262},[41,4612,4613],{},"        raise HTTPException(status_code=422, detail=str(e))\n",[41,4615,4616],{"class":43,"line":268},[41,4617,348],{"emptyLinePlaceholder":347},[41,4619,4620],{"class":43,"line":276},[41,4621,4622],{},"@router.get(\"/{certificate_id}\", response_model=CertificateResponse)\n",[41,4624,4625],{"class":43,"line":289},[41,4626,4627],{},"async def get_certificate(\n",[41,4629,4630],{"class":43,"line":302},[41,4631,4632],{},"    certificate_id: str,\n",[41,4634,4635],{"class":43,"line":313},[41,4636,4580],{},[41,4638,4639],{"class":43,"line":319},[41,4640,2496],{},[41,4642,4643],{"class":43,"line":757},[41,4644,3485],{},[41,4646,4647],{"class":43,"line":762},[41,4648,4649],{},"        return CertificateResponse.model_validate(\n",[41,4651,4652],{"class":43,"line":774},[41,4653,4654],{},"            await service.get_by_id(certificate_id)\n",[41,4656,4657],{"class":43,"line":785},[41,4658,2334],{},[41,4660,4661],{"class":43,"line":798},[41,4662,4663],{},"    except CertificateNotFound:\n",[41,4665,4666],{"class":43,"line":809},[41,4667,4668],{},"        raise HTTPException(status_code=404, detail=\"Certificate not found\")\n",[15,4670,4671],{},"The router contains no business logic — only HTTP mapping: request deserialisation, service call, response serialisation, and translation of business exceptions into HTTP status codes.",[24,4673,4675],{"id":4674},"the-service-layer-business-logic","The Service Layer: Business Logic",[32,4677,4679],{"className":2213,"code":4678,"language":2215,"meta":37,"style":37},"# app/services/certificate_service.py\nfrom app.domain.models import Certificate\nfrom app.domain.exceptions import CertificateNotFound, InsufficientVolume, DuplicateCertificate\nfrom app.infrastructure.repositories.certificate_repo import CertificateRepository\nfrom app.api.schemas.certificate import CertificateCreate\n\nclass CertificateService:\n    def __init__(self, repo: CertificateRepository):\n        self.repo = repo\n\n    async def create(self, payload: CertificateCreate, owner: str) -> Certificate:\n        if payload.volume \u003C= 0:\n            raise InsufficientVolume(f\"Invalid volume: {payload.volume}\")\n\n        existing = await self.repo.find_by_period(\n            owner=owner,\n            period_from=payload.period_from,\n            period_to=payload.period_to\n        )\n        if existing:\n            raise DuplicateCertificate(\"A certificate already exists for this period\")\n\n        return await self.repo.create(payload, owner=owner)\n\n    async def get_by_id(self, certificate_id: str) -> Certificate:\n        certificate = await self.repo.find_by_id(certificate_id)\n        if not certificate:\n            raise CertificateNotFound(certificate_id)\n        return certificate\n",[19,4680,4681,4686,4691,4696,4701,4706,4710,4714,4719,4723,4727,4732,4737,4742,4746,4751,4756,4761,4766,4770,4775,4780,4784,4789,4793,4798,4803,4808,4813],{"__ignoreMap":37},[41,4682,4683],{"class":43,"line":44},[41,4684,4685],{},"# app/services/certificate_service.py\n",[41,4687,4688],{"class":43,"line":51},[41,4689,4690],{},"from app.domain.models import Certificate\n",[41,4692,4693],{"class":43,"line":61},[41,4694,4695],{},"from app.domain.exceptions import CertificateNotFound, InsufficientVolume, DuplicateCertificate\n",[41,4697,4698],{"class":43,"line":77},[41,4699,4700],{},"from app.infrastructure.repositories.certificate_repo import CertificateRepository\n",[41,4702,4703],{"class":43,"line":90},[41,4704,4705],{},"from app.api.schemas.certificate import CertificateCreate\n",[41,4707,4708],{"class":43,"line":101},[41,4709,348],{"emptyLinePlaceholder":347},[41,4711,4712],{"class":43,"line":107},[41,4713,4044],{},[41,4715,4716],{"class":43,"line":116},[41,4717,4718],{},"    def __init__(self, repo: CertificateRepository):\n",[41,4720,4721],{"class":43,"line":122},[41,4722,4054],{},[41,4724,4725],{"class":43,"line":135},[41,4726,348],{"emptyLinePlaceholder":347},[41,4728,4729],{"class":43,"line":148},[41,4730,4731],{},"    async def create(self, payload: CertificateCreate, owner: str) -> Certificate:\n",[41,4733,4734],{"class":43,"line":161},[41,4735,4736],{},"        if payload.volume \u003C= 0:\n",[41,4738,4739],{"class":43,"line":192},[41,4740,4741],{},"            raise InsufficientVolume(f\"Invalid volume: {payload.volume}\")\n",[41,4743,4744],{"class":43,"line":205},[41,4745,348],{"emptyLinePlaceholder":347},[41,4747,4748],{"class":43,"line":213},[41,4749,4750],{},"        existing = await self.repo.find_by_period(\n",[41,4752,4753],{"class":43,"line":226},[41,4754,4755],{},"            owner=owner,\n",[41,4757,4758],{"class":43,"line":239},[41,4759,4760],{},"            period_from=payload.period_from,\n",[41,4762,4763],{"class":43,"line":250},[41,4764,4765],{},"            period_to=payload.period_to\n",[41,4767,4768],{"class":43,"line":256},[41,4769,2334],{},[41,4771,4772],{"class":43,"line":262},[41,4773,4774],{},"        if existing:\n",[41,4776,4777],{"class":43,"line":268},[41,4778,4779],{},"            raise DuplicateCertificate(\"A certificate already exists for this period\")\n",[41,4781,4782],{"class":43,"line":276},[41,4783,348],{"emptyLinePlaceholder":347},[41,4785,4786],{"class":43,"line":289},[41,4787,4788],{},"        return await self.repo.create(payload, owner=owner)\n",[41,4790,4791],{"class":43,"line":302},[41,4792,348],{"emptyLinePlaceholder":347},[41,4794,4795],{"class":43,"line":313},[41,4796,4797],{},"    async def get_by_id(self, certificate_id: str) -> Certificate:\n",[41,4799,4800],{"class":43,"line":319},[41,4801,4802],{},"        certificate = await self.repo.find_by_id(certificate_id)\n",[41,4804,4805],{"class":43,"line":757},[41,4806,4807],{},"        if not certificate:\n",[41,4809,4810],{"class":43,"line":762},[41,4811,4812],{},"            raise CertificateNotFound(certificate_id)\n",[41,4814,4815],{"class":43,"line":774},[41,4816,4130],{},[15,4818,4819],{},"The service layer is testable without a database or HTTP — mocking the repository is sufficient. This is where the value of the separation is most tangible.",[24,4821,4823],{"id":4822},"the-repository-layer-data-access","The Repository Layer: Data Access",[32,4825,4827],{"className":2213,"code":4826,"language":2215,"meta":37,"style":37},"# app/infrastructure/repositories/certificate_repo.py\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlalchemy import select\nfrom app.domain.models import Certificate\nfrom app.infrastructure.database import CertificateORM\n\nclass CertificateRepository:\n    def __init__(self, session: AsyncSession):\n        self.session = session\n\n    async def find_by_id(self, certificate_id: str) -> Certificate | None:\n        result = await self.session.execute(\n            select(CertificateORM).where(CertificateORM.id == certificate_id)\n        )\n        orm_obj = result.scalar_one_or_none()\n        if orm_obj is None:\n            return None\n        return self._to_domain(orm_obj)\n\n    async def create(self, payload, owner: str) -> Certificate:\n        orm_obj = CertificateORM(\n            volume=payload.volume,\n            technology=payload.technology,\n            owner=owner,\n            period_from=payload.period_from,\n            period_to=payload.period_to,\n        )\n        self.session.add(orm_obj)\n        await self.session.commit()\n        await self.session.refresh(orm_obj)\n        return self._to_domain(orm_obj)\n\n    def _to_domain(self, orm_obj: CertificateORM) -> Certificate:\n        \"\"\"Maps an ORM object to a domain object.\"\"\"\n        return Certificate(\n            id=str(orm_obj.id),\n            volume=orm_obj.volume,\n            technology=orm_obj.technology,\n            status=orm_obj.status,\n            owner=orm_obj.owner,\n            period_from=orm_obj.period_from.isoformat(),\n            period_to=orm_obj.period_to.isoformat(),\n        )\n",[19,4828,4829,4834,4839,4844,4848,4853,4857,4862,4867,4872,4876,4881,4886,4891,4895,4900,4905,4909,4914,4918,4923,4928,4933,4938,4942,4946,4951,4955,4960,4965,4970,4974,4978,4983,4988,4993,4998,5003,5008,5013,5018,5023,5028],{"__ignoreMap":37},[41,4830,4831],{"class":43,"line":44},[41,4832,4833],{},"# app/infrastructure/repositories/certificate_repo.py\n",[41,4835,4836],{"class":43,"line":51},[41,4837,4838],{},"from sqlalchemy.ext.asyncio import AsyncSession\n",[41,4840,4841],{"class":43,"line":61},[41,4842,4843],{},"from sqlalchemy import select\n",[41,4845,4846],{"class":43,"line":77},[41,4847,4690],{},[41,4849,4850],{"class":43,"line":90},[41,4851,4852],{},"from app.infrastructure.database import CertificateORM\n",[41,4854,4855],{"class":43,"line":101},[41,4856,348],{"emptyLinePlaceholder":347},[41,4858,4859],{"class":43,"line":107},[41,4860,4861],{},"class CertificateRepository:\n",[41,4863,4864],{"class":43,"line":116},[41,4865,4866],{},"    def __init__(self, session: AsyncSession):\n",[41,4868,4869],{"class":43,"line":122},[41,4870,4871],{},"        self.session = session\n",[41,4873,4874],{"class":43,"line":135},[41,4875,348],{"emptyLinePlaceholder":347},[41,4877,4878],{"class":43,"line":148},[41,4879,4880],{},"    async def find_by_id(self, certificate_id: str) -> Certificate | None:\n",[41,4882,4883],{"class":43,"line":161},[41,4884,4885],{},"        result = await self.session.execute(\n",[41,4887,4888],{"class":43,"line":192},[41,4889,4890],{},"            select(CertificateORM).where(CertificateORM.id == certificate_id)\n",[41,4892,4893],{"class":43,"line":205},[41,4894,2334],{},[41,4896,4897],{"class":43,"line":213},[41,4898,4899],{},"        orm_obj = result.scalar_one_or_none()\n",[41,4901,4902],{"class":43,"line":226},[41,4903,4904],{},"        if orm_obj is None:\n",[41,4906,4907],{"class":43,"line":239},[41,4908,2406],{},[41,4910,4911],{"class":43,"line":250},[41,4912,4913],{},"        return self._to_domain(orm_obj)\n",[41,4915,4916],{"class":43,"line":256},[41,4917,348],{"emptyLinePlaceholder":347},[41,4919,4920],{"class":43,"line":262},[41,4921,4922],{},"    async def create(self, payload, owner: str) -> Certificate:\n",[41,4924,4925],{"class":43,"line":268},[41,4926,4927],{},"        orm_obj = CertificateORM(\n",[41,4929,4930],{"class":43,"line":276},[41,4931,4932],{},"            volume=payload.volume,\n",[41,4934,4935],{"class":43,"line":289},[41,4936,4937],{},"            technology=payload.technology,\n",[41,4939,4940],{"class":43,"line":302},[41,4941,4755],{},[41,4943,4944],{"class":43,"line":313},[41,4945,4760],{},[41,4947,4948],{"class":43,"line":319},[41,4949,4950],{},"            period_to=payload.period_to,\n",[41,4952,4953],{"class":43,"line":757},[41,4954,2334],{},[41,4956,4957],{"class":43,"line":762},[41,4958,4959],{},"        self.session.add(orm_obj)\n",[41,4961,4962],{"class":43,"line":774},[41,4963,4964],{},"        await self.session.commit()\n",[41,4966,4967],{"class":43,"line":785},[41,4968,4969],{},"        await self.session.refresh(orm_obj)\n",[41,4971,4972],{"class":43,"line":798},[41,4973,4913],{},[41,4975,4976],{"class":43,"line":809},[41,4977,348],{"emptyLinePlaceholder":347},[41,4979,4980],{"class":43,"line":1507},[41,4981,4982],{},"    def _to_domain(self, orm_obj: CertificateORM) -> Certificate:\n",[41,4984,4985],{"class":43,"line":1516},[41,4986,4987],{},"        \"\"\"Maps an ORM object to a domain object.\"\"\"\n",[41,4989,4990],{"class":43,"line":2385},[41,4991,4992],{},"        return Certificate(\n",[41,4994,4995],{"class":43,"line":2391},[41,4996,4997],{},"            id=str(orm_obj.id),\n",[41,4999,5000],{"class":43,"line":2397},[41,5001,5002],{},"            volume=orm_obj.volume,\n",[41,5004,5005],{"class":43,"line":2403},[41,5006,5007],{},"            technology=orm_obj.technology,\n",[41,5009,5010],{"class":43,"line":2409},[41,5011,5012],{},"            status=orm_obj.status,\n",[41,5014,5015],{"class":43,"line":2415},[41,5016,5017],{},"            owner=orm_obj.owner,\n",[41,5019,5020],{"class":43,"line":2421},[41,5021,5022],{},"            period_from=orm_obj.period_from.isoformat(),\n",[41,5024,5025],{"class":43,"line":2426},[41,5026,5027],{},"            period_to=orm_obj.period_to.isoformat(),\n",[41,5029,5030],{"class":43,"line":3260},[41,5031,2334],{},[15,5033,5034,5035,5037],{},"The repository is the only layer that knows about SQLAlchemy. The rest of the codebase works with domain objects (",[19,5036,910],{}," dataclass) — not ORM models. This decoupling means switching the ORM or the database does not touch the business logic.",[24,5039,5041],{"id":5040},"dependency-injection","Dependency Injection",[32,5043,5045],{"className":2213,"code":5044,"language":2215,"meta":37,"style":37},"# app/api/dependencies.py\nfrom fastapi import Depends, Request, HTTPException\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom app.infrastructure.database import get_db_session\nfrom app.infrastructure.repositories.certificate_repo import CertificateRepository\nfrom app.services.certificate_service import CertificateService\n\nasync def get_certificate_service(\n    session: AsyncSession = Depends(get_db_session)\n) -> CertificateService:\n    repo = CertificateRepository(session)\n    return CertificateService(repo)\n\nasync def get_current_user(request: Request) -> str:\n    session_data = await session_manager.get_session(request)\n    if not session_data:\n        raise HTTPException(status_code=401)\n    return session_data[\"user_id\"]\n",[19,5046,5047,5051,5056,5060,5065,5069,5073,5077,5082,5087,5092,5097,5102,5106,5111,5116,5121,5125],{"__ignoreMap":37},[41,5048,5049],{"class":43,"line":44},[41,5050,4146],{},[41,5052,5053],{"class":43,"line":51},[41,5054,5055],{},"from fastapi import Depends, Request, HTTPException\n",[41,5057,5058],{"class":43,"line":61},[41,5059,4838],{},[41,5061,5062],{"class":43,"line":77},[41,5063,5064],{},"from app.infrastructure.database import get_db_session\n",[41,5066,5067],{"class":43,"line":90},[41,5068,4700],{},[41,5070,5071],{"class":43,"line":101},[41,5072,4542],{},[41,5074,5075],{"class":43,"line":107},[41,5076,348],{"emptyLinePlaceholder":347},[41,5078,5079],{"class":43,"line":116},[41,5080,5081],{},"async def get_certificate_service(\n",[41,5083,5084],{"class":43,"line":122},[41,5085,5086],{},"    session: AsyncSession = Depends(get_db_session)\n",[41,5088,5089],{"class":43,"line":135},[41,5090,5091],{},") -> CertificateService:\n",[41,5093,5094],{"class":43,"line":148},[41,5095,5096],{},"    repo = CertificateRepository(session)\n",[41,5098,5099],{"class":43,"line":161},[41,5100,5101],{},"    return CertificateService(repo)\n",[41,5103,5104],{"class":43,"line":192},[41,5105,348],{"emptyLinePlaceholder":347},[41,5107,5108],{"class":43,"line":205},[41,5109,5110],{},"async def get_current_user(request: Request) -> str:\n",[41,5112,5113],{"class":43,"line":213},[41,5114,5115],{},"    session_data = await session_manager.get_session(request)\n",[41,5117,5118],{"class":43,"line":226},[41,5119,5120],{},"    if not session_data:\n",[41,5122,5123],{"class":43,"line":239},[41,5124,2642],{},[41,5126,5127],{"class":43,"line":250},[41,5128,5129],{},"    return session_data[\"user_id\"]\n",[15,5131,5132,5133,5136],{},"FastAPI resolves dependencies automatically and manages their lifecycle. ",[19,5134,5135],{},"get_db_session"," creates one session per request and closes it cleanly afterwards — even in the event of an exception.",[24,5138,5140],{"id":5139},"business-exceptions","Business Exceptions",[32,5142,5144],{"className":2213,"code":5143,"language":2215,"meta":37,"style":37},"# app/domain/exceptions.py\n\nclass DomainException(Exception):\n    \"\"\"Base class for all business exceptions.\"\"\"\n    pass\n\nclass CertificateNotFound(DomainException):\n    def __init__(self, certificate_id: str):\n        super().__init__(f\"Certificate '{certificate_id}' not found\")\n        self.certificate_id = certificate_id\n\nclass InsufficientVolume(DomainException):\n    pass\n\nclass DuplicateCertificate(DomainException):\n    pass\n",[19,5145,5146,5151,5155,5160,5165,5170,5174,5179,5184,5189,5194,5198,5203,5207,5211,5216],{"__ignoreMap":37},[41,5147,5148],{"class":43,"line":44},[41,5149,5150],{},"# app/domain/exceptions.py\n",[41,5152,5153],{"class":43,"line":51},[41,5154,348],{"emptyLinePlaceholder":347},[41,5156,5157],{"class":43,"line":61},[41,5158,5159],{},"class DomainException(Exception):\n",[41,5161,5162],{"class":43,"line":77},[41,5163,5164],{},"    \"\"\"Base class for all business exceptions.\"\"\"\n",[41,5166,5167],{"class":43,"line":90},[41,5168,5169],{},"    pass\n",[41,5171,5172],{"class":43,"line":101},[41,5173,348],{"emptyLinePlaceholder":347},[41,5175,5176],{"class":43,"line":107},[41,5177,5178],{},"class CertificateNotFound(DomainException):\n",[41,5180,5181],{"class":43,"line":116},[41,5182,5183],{},"    def __init__(self, certificate_id: str):\n",[41,5185,5186],{"class":43,"line":122},[41,5187,5188],{},"        super().__init__(f\"Certificate '{certificate_id}' not found\")\n",[41,5190,5191],{"class":43,"line":135},[41,5192,5193],{},"        self.certificate_id = certificate_id\n",[41,5195,5196],{"class":43,"line":148},[41,5197,348],{"emptyLinePlaceholder":347},[41,5199,5200],{"class":43,"line":161},[41,5201,5202],{},"class InsufficientVolume(DomainException):\n",[41,5204,5205],{"class":43,"line":192},[41,5206,5169],{},[41,5208,5209],{"class":43,"line":205},[41,5210,348],{"emptyLinePlaceholder":347},[41,5212,5213],{"class":43,"line":213},[41,5214,5215],{},"class DuplicateCertificate(DomainException):\n",[41,5217,5218],{"class":43,"line":226},[41,5219,5169],{},[15,5221,5222],{},"Business exceptions do not depend on FastAPI — they express what can go wrong in the domain. Translating them into HTTP status codes is the router's responsibility.",[24,5224,5226],{"id":5225},"testing-services-without-infrastructure","Testing Services Without Infrastructure",[32,5228,5230],{"className":2213,"code":5229,"language":2215,"meta":37,"style":37},"# tests/services/test_certificate_service.py\nimport pytest\nfrom unittest.mock import AsyncMock\nfrom app.services.certificate_service import CertificateService\nfrom app.domain.exceptions import InsufficientVolume\nfrom app.api.schemas.certificate import CertificateCreate\n\n@pytest.fixture\ndef mock_repo():\n    repo = AsyncMock()\n    repo.find_by_period.return_value = None  # No duplicate by default\n    return repo\n\n@pytest.fixture\ndef service(mock_repo):\n    return CertificateService(repo=mock_repo)\n\nasync def test_create_certificate_invalid_volume(service):\n    payload = CertificateCreate(\n        volume=-10,\n        technology=\"WIND\",\n        period_from=\"2024-01-01\",\n        period_to=\"2024-01-31\"\n    )\n    with pytest.raises(InsufficientVolume):\n        await service.create(payload, owner=\"user-1\")\n\nasync def test_create_certificate_success(service, mock_repo):\n    payload = CertificateCreate(\n        volume=1500,\n        technology=\"WIND\",\n        period_from=\"2024-01-01\",\n        period_to=\"2024-01-31\"\n    )\n    await service.create(payload, owner=\"user-1\")\n    mock_repo.create.assert_called_once()\n",[19,5231,5232,5237,5242,5247,5251,5256,5260,5264,5269,5274,5279,5284,5289,5293,5297,5302,5307,5311,5316,5321,5326,5331,5336,5341,5345,5350,5355,5359,5364,5368,5373,5377,5381,5385,5389,5394],{"__ignoreMap":37},[41,5233,5234],{"class":43,"line":44},[41,5235,5236],{},"# tests/services/test_certificate_service.py\n",[41,5238,5239],{"class":43,"line":51},[41,5240,5241],{},"import pytest\n",[41,5243,5244],{"class":43,"line":61},[41,5245,5246],{},"from unittest.mock import AsyncMock\n",[41,5248,5249],{"class":43,"line":77},[41,5250,4542],{},[41,5252,5253],{"class":43,"line":90},[41,5254,5255],{},"from app.domain.exceptions import InsufficientVolume\n",[41,5257,5258],{"class":43,"line":101},[41,5259,4705],{},[41,5261,5262],{"class":43,"line":107},[41,5263,348],{"emptyLinePlaceholder":347},[41,5265,5266],{"class":43,"line":116},[41,5267,5268],{},"@pytest.fixture\n",[41,5270,5271],{"class":43,"line":122},[41,5272,5273],{},"def mock_repo():\n",[41,5275,5276],{"class":43,"line":135},[41,5277,5278],{},"    repo = AsyncMock()\n",[41,5280,5281],{"class":43,"line":148},[41,5282,5283],{},"    repo.find_by_period.return_value = None  # No duplicate by default\n",[41,5285,5286],{"class":43,"line":161},[41,5287,5288],{},"    return repo\n",[41,5290,5291],{"class":43,"line":192},[41,5292,348],{"emptyLinePlaceholder":347},[41,5294,5295],{"class":43,"line":205},[41,5296,5268],{},[41,5298,5299],{"class":43,"line":213},[41,5300,5301],{},"def service(mock_repo):\n",[41,5303,5304],{"class":43,"line":226},[41,5305,5306],{},"    return CertificateService(repo=mock_repo)\n",[41,5308,5309],{"class":43,"line":239},[41,5310,348],{"emptyLinePlaceholder":347},[41,5312,5313],{"class":43,"line":250},[41,5314,5315],{},"async def test_create_certificate_invalid_volume(service):\n",[41,5317,5318],{"class":43,"line":256},[41,5319,5320],{},"    payload = CertificateCreate(\n",[41,5322,5323],{"class":43,"line":262},[41,5324,5325],{},"        volume=-10,\n",[41,5327,5328],{"class":43,"line":268},[41,5329,5330],{},"        technology=\"WIND\",\n",[41,5332,5333],{"class":43,"line":276},[41,5334,5335],{},"        period_from=\"2024-01-01\",\n",[41,5337,5338],{"class":43,"line":289},[41,5339,5340],{},"        period_to=\"2024-01-31\"\n",[41,5342,5343],{"class":43,"line":302},[41,5344,1964],{},[41,5346,5347],{"class":43,"line":313},[41,5348,5349],{},"    with pytest.raises(InsufficientVolume):\n",[41,5351,5352],{"class":43,"line":319},[41,5353,5354],{},"        await service.create(payload, owner=\"user-1\")\n",[41,5356,5357],{"class":43,"line":757},[41,5358,348],{"emptyLinePlaceholder":347},[41,5360,5361],{"class":43,"line":762},[41,5362,5363],{},"async def test_create_certificate_success(service, mock_repo):\n",[41,5365,5366],{"class":43,"line":774},[41,5367,5320],{},[41,5369,5370],{"class":43,"line":785},[41,5371,5372],{},"        volume=1500,\n",[41,5374,5375],{"class":43,"line":798},[41,5376,5330],{},[41,5378,5379],{"class":43,"line":809},[41,5380,5335],{},[41,5382,5383],{"class":43,"line":1507},[41,5384,5340],{},[41,5386,5387],{"class":43,"line":1516},[41,5388,1964],{},[41,5390,5391],{"class":43,"line":2385},[41,5392,5393],{},"    await service.create(payload, owner=\"user-1\")\n",[41,5395,5396],{"class":43,"line":2391},[41,5397,5398],{},"    mock_repo.create.assert_called_once()\n",[15,5400,5401,5402,5405],{},"Service tests are fast, deterministic, and require no database. ",[19,5403,5404],{},"AsyncMock"," replaces the repository — we are testing the logic, not the infrastructure.",[24,5407,5409],{"id":5408},"what-this-architecture-delivers","What This Architecture Delivers",[15,5411,5412],{},"Layered separation is not gratuitous complexity. It addresses concrete problems on a project that must remain maintainable over time:",[2065,5414,5415,5421,5438,5446],{},[2068,5416,5417,5420],{},[2071,5418,5419],{},"Testability"," — services test without a database, routers with an HTTP test client",[2068,5422,5423,5426,5427,5430,5431,5434,5435],{},[2071,5424,5425],{},"Readability"," — a new developer knows exactly where to look: business logic in ",[19,5428,5429],{},"services/",", data access in ",[19,5432,5433],{},"repositories/",", HTTP concerns in ",[19,5436,5437],{},"routers/",[2068,5439,5440,5443,5444],{},[2071,5441,5442],{},"Flexibility"," — replacing PostgreSQL with another database only touches ",[19,5445,5433],{},[2068,5447,5448,5451],{},[2071,5449,5450],{},"Separation of concerns"," — an HTTP bug stays in the router, a business bug stays in the service",[15,5453,5454],{},"The trade-off: more files, more layers to traverse for a simple feature. On a one-week throwaway project, it is over-engineered. On a project maintained by a team for months, it is the difference between code that is still comprehensible at the six-month mark and a codebase nobody wants to touch.",[2097,5456,2961],{},{"title":37,"searchDepth":51,"depth":51,"links":5458},[5459,5460,5461,5462,5463,5464,5465,5466],{"id":4498,"depth":51,"text":4499},{"id":4511,"depth":51,"text":4512},{"id":4674,"depth":51,"text":4675},{"id":4822,"depth":51,"text":4823},{"id":5040,"depth":51,"text":5041},{"id":5139,"depth":51,"text":5140},{"id":5225,"depth":51,"text":5226},{"id":5408,"depth":51,"text":5409},"2025-03-01","Most FastAPI tutorials put everything in main.py. That works for a demonstration, not for an application maintained by a team over several years. Here is the architecture applied in practice on projects built to last, along with the reasoning behind each decision.",{},"/en/blog/fastapi-architecture",{"title":4482,"description":5468},"fastapi-project-architecture","en/blog/fastapi-architecture",[2980,4477,2982,5475],"Clean Code","Za1f4WtVl6o_dsfLr_oSx1xZ1EKI_PcBK7TQUyq42Ic",{"id":5478,"title":5479,"body":5480,"date":7607,"description":5488,"excerpt":2112,"extension":2113,"meta":7608,"navigation":347,"path":7609,"readTime":135,"seo":7610,"slug":7611,"stem":7612,"tags":7613,"__hash__":7616},"en_blog/en/blog/vueuse-essentiels.md","VueUse: The Composables That Actually Make a Difference",{"type":8,"value":5481,"toc":7585},[5482,5486,5489,5493,5511,5514,5521,5524,5699,5704,5844,5852,5888,5897,5907,5910,6047,6052,6120,6123,6133,6234,6241,6244,6360,6377,6384,6387,6573,6576,6666,6672,6679,6824,6838,6845,6893,6952,6963,6970,7086,7089,7096,7099,7264,7267,7356,7362,7369,7375,7486,7554,7560,7562,7582],[11,5483,5485],{"id":5484},"vueuse-in-production-the-composables-that-actually-make-a-difference","VueUse in Production: The Composables That Actually Make a Difference",[15,5487,5488],{},"VueUse ships over 200 composables. The documentation lists all of them, which does not help you determine which ones are genuinely worth learning. Here are the ones that appear consistently on professional projects, along with the concrete situations where they save meaningful time.",[24,5490,5492],{"id":5491},"installation","Installation",[32,5494,5498],{"className":5495,"code":5496,"language":5497,"meta":37,"style":37},"language-bash shiki shiki-themes github-dark github-light","npm install @vueuse/core\n","bash",[19,5499,5500],{"__ignoreMap":37},[41,5501,5502,5505,5508],{"class":43,"line":44},[41,5503,5504],{"class":360},"npm",[41,5506,5507],{"class":70}," install",[41,5509,5510],{"class":70}," @vueuse/core\n",[15,5512,5513],{},"VueUse is compatible with Vue 3 and Nuxt 3/4. All composables are tree-shakable — only those you import are included in the bundle.",[24,5515,5517,5520],{"id":5516},"useasyncstate-replacing-the-loadingerrordata-pattern",[19,5518,5519],{},"useAsyncState",": Replacing the loading/error/data Pattern",[15,5522,5523],{},"The most repetitive pattern in Vue.js:",[32,5525,5527],{"className":332,"code":5526,"language":334,"meta":37,"style":37},"// What we write without VueUse — over and over\nconst data = ref(null)\nconst loading = ref(false)\nconst error = ref(null)\n\nconst fetch = async () => {\n  loading.value = true\n  error.value = null\n  try {\n    data.value = await api.getCertificates()\n  } catch (e) {\n    error.value = e\n  } finally {\n    loading.value = false\n  }\n}\n\nonMounted(fetch)\n",[19,5528,5529,5534,5550,5566,5582,5586,5603,5612,5621,5628,5645,5654,5663,5671,5679,5683,5687,5691],{"__ignoreMap":37},[41,5530,5531],{"class":43,"line":44},[41,5532,5533],{"class":341},"// What we write without VueUse — over and over\n",[41,5535,5536,5538,5540,5542,5544,5546,5548],{"class":43,"line":51},[41,5537,1531],{"class":353},[41,5539,1270],{"class":54},[41,5541,364],{"class":353},[41,5543,1275],{"class":360},[41,5545,1321],{"class":47},[41,5547,1289],{"class":54},[41,5549,1327],{"class":47},[41,5551,5552,5554,5556,5558,5560,5562,5564],{"class":43,"line":61},[41,5553,1531],{"class":353},[41,5555,1314],{"class":54},[41,5557,364],{"class":353},[41,5559,1275],{"class":360},[41,5561,1321],{"class":47},[41,5563,1324],{"class":54},[41,5565,1327],{"class":47},[41,5567,5568,5570,5572,5574,5576,5578,5580],{"class":43,"line":77},[41,5569,1531],{"class":353},[41,5571,1334],{"class":54},[41,5573,364],{"class":353},[41,5575,1275],{"class":360},[41,5577,1321],{"class":47},[41,5579,1289],{"class":54},[41,5581,1327],{"class":47},[41,5583,5584],{"class":43,"line":90},[41,5585,348],{"emptyLinePlaceholder":347},[41,5587,5588,5590,5593,5595,5597,5599,5601],{"class":43,"line":101},[41,5589,1531],{"class":353},[41,5591,5592],{"class":360}," fetch",[41,5594,364],{"class":353},[41,5596,1368],{"class":353},[41,5598,1169],{"class":47},[41,5600,1172],{"class":353},[41,5602,482],{"class":47},[41,5604,5605,5608,5610],{"class":43,"line":107},[41,5606,5607],{"class":47},"  loading.value ",[41,5609,1382],{"class":353},[41,5611,1385],{"class":54},[41,5613,5614,5617,5619],{"class":43,"line":116},[41,5615,5616],{"class":47},"  error.value ",[41,5618,1382],{"class":353},[41,5620,1395],{"class":54},[41,5622,5623,5626],{"class":43,"line":122},[41,5624,5625],{"class":353},"  try",[41,5627,482],{"class":47},[41,5629,5630,5633,5635,5637,5640,5643],{"class":43,"line":135},[41,5631,5632],{"class":47},"    data.value ",[41,5634,1382],{"class":353},[41,5636,1412],{"class":353},[41,5638,5639],{"class":47}," api.",[41,5641,5642],{"class":360},"getCertificates",[41,5644,1418],{"class":47},[41,5646,5647,5650,5652],{"class":43,"line":148},[41,5648,5649],{"class":47},"  } ",[41,5651,1426],{"class":353},[41,5653,1429],{"class":47},[41,5655,5656,5658,5660],{"class":43,"line":161},[41,5657,1390],{"class":47},[41,5659,1382],{"class":353},[41,5661,5662],{"class":47}," e\n",[41,5664,5665,5667,5669],{"class":43,"line":192},[41,5666,5649],{"class":47},[41,5668,1463],{"class":353},[41,5670,482],{"class":47},[41,5672,5673,5675,5677],{"class":43,"line":205},[41,5674,1379],{"class":47},[41,5676,1382],{"class":353},[41,5678,1475],{"class":54},[41,5680,5681],{"class":43,"line":213},[41,5682,316],{"class":47},[41,5684,5685],{"class":43,"line":226},[41,5686,322],{"class":47},[41,5688,5689],{"class":43,"line":239},[41,5690,348],{"emptyLinePlaceholder":347},[41,5692,5693,5696],{"class":43,"line":250},[41,5694,5695],{"class":360},"onMounted",[41,5697,5698],{"class":47},"(fetch)\n",[15,5700,5701,5702,491],{},"With ",[19,5703,5519],{},[32,5705,5707],{"className":332,"code":5706,"language":334,"meta":37,"style":37},"import { useAsyncState } from \"@vueuse/core\"\n\nconst { state, isLoading, error, execute } = useAsyncState(\n  () => api.getCertificates(),\n  [], // Initial value\n  {\n    immediate: true, // Execute on mount\n    resetOnExecute: true, // Reset to initial value before each execution\n    onError: (e) => logger.error(\"Fetch failed\", e),\n  },\n)\n",[19,5708,5709,5721,5725,5758,5771,5779,5784,5796,5808,5836,5840],{"__ignoreMap":37},[41,5710,5711,5713,5716,5718],{"class":43,"line":44},[41,5712,1074],{"class":353},[41,5714,5715],{"class":47}," { useAsyncState } ",[41,5717,1080],{"class":353},[41,5719,5720],{"class":70}," \"@vueuse/core\"\n",[41,5722,5723],{"class":43,"line":51},[41,5724,348],{"emptyLinePlaceholder":347},[41,5726,5727,5729,5731,5734,5736,5739,5741,5744,5746,5748,5751,5753,5756],{"class":43,"line":61},[41,5728,1531],{"class":353},[41,5730,1237],{"class":47},[41,5732,5733],{"class":54},"state",[41,5735,178],{"class":47},[41,5737,5738],{"class":54},"isLoading",[41,5740,178],{"class":47},[41,5742,5743],{"class":54},"error",[41,5745,178],{"class":47},[41,5747,1498],{"class":54},[41,5749,5750],{"class":47}," } ",[41,5752,1382],{"class":353},[41,5754,5755],{"class":360}," useAsyncState",[41,5757,1947],{"class":47},[41,5759,5760,5762,5764,5766,5768],{"class":43,"line":77},[41,5761,1583],{"class":47},[41,5763,1172],{"class":353},[41,5765,5639],{"class":47},[41,5767,5642],{"class":360},[41,5769,5770],{"class":47},"(),\n",[41,5772,5773,5776],{"class":43,"line":90},[41,5774,5775],{"class":47},"  [], ",[41,5777,5778],{"class":341},"// Initial value\n",[41,5780,5781],{"class":43,"line":101},[41,5782,5783],{"class":47},"  {\n",[41,5785,5786,5789,5791,5793],{"class":43,"line":107},[41,5787,5788],{"class":47},"    immediate: ",[41,5790,1604],{"class":54},[41,5792,178],{"class":47},[41,5794,5795],{"class":341},"// Execute on mount\n",[41,5797,5798,5801,5803,5805],{"class":43,"line":116},[41,5799,5800],{"class":47},"    resetOnExecute: ",[41,5802,1604],{"class":54},[41,5804,178],{"class":47},[41,5806,5807],{"class":341},"// Reset to initial value before each execution\n",[41,5809,5810,5813,5816,5819,5821,5823,5826,5828,5830,5833],{"class":43,"line":122},[41,5811,5812],{"class":360},"    onError",[41,5814,5815],{"class":47},": (",[41,5817,5818],{"class":487},"e",[41,5820,1292],{"class":47},[41,5822,1172],{"class":353},[41,5824,5825],{"class":47}," logger.",[41,5827,5743],{"class":360},[41,5829,1321],{"class":47},[41,5831,5832],{"class":70},"\"Fetch failed\"",[41,5834,5835],{"class":47},", e),\n",[41,5837,5838],{"class":43,"line":135},[41,5839,104],{"class":47},[41,5841,5842],{"class":43,"line":148},[41,5843,1327],{"class":47},[15,5845,5846,5848,5849,5851],{},[19,5847,5733],{}," is typed from the return type of the async function. ",[19,5850,1498],{}," allows re-triggering manually with different parameters:",[32,5853,5855],{"className":332,"code":5854,"language":334,"meta":37,"style":37},"// Re-fetch with a different filter\nawait execute(0, { status: \"ACTIVE\", period: \"2024-01\" })\n",[19,5856,5857,5862],{"__ignoreMap":37},[41,5858,5859],{"class":43,"line":44},[41,5860,5861],{"class":341},"// Re-fetch with a different filter\n",[41,5863,5864,5867,5869,5871,5874,5877,5879,5882,5885],{"class":43,"line":51},[41,5865,5866],{"class":353},"await",[41,5868,1363],{"class":360},[41,5870,1321],{"class":47},[41,5872,5873],{"class":54},"0",[41,5875,5876],{"class":47},", { status: ",[41,5878,200],{"class":70},[41,5880,5881],{"class":47},", period: ",[41,5883,5884],{"class":70},"\"2024-01\"",[41,5886,5887],{"class":47}," })\n",[15,5889,5890,5891,5893,5894,5896],{},"The second argument to ",[19,5892,1498],{}," (the delay) is a legacy of the API — pass ",[19,5895,5873],{}," for immediate execution.",[24,5898,5900,1799,5903,5906],{"id":5899},"usedebouncefn-and-usethrottlefn-performance-on-frequent-events",[19,5901,5902],{},"useDebounceFn",[19,5904,5905],{},"useThrottleFn",": Performance on Frequent Events",[15,5908,5909],{},"On a search field that calls an API on every keystroke:",[32,5911,5913],{"className":332,"code":5912,"language":334,"meta":37,"style":37},"import { useDebounceFn } from \"@vueuse/core\"\n\nconst search = ref(\"\")\n\nconst searchApi = useDebounceFn(async (query: string) => {\n  if (query.length \u003C 2) return\n  results.value = await api.search(query)\n}, 350) // 350ms after the last keystroke\n\nwatch(search, searchApi)\n",[19,5914,5915,5926,5930,5948,5952,5984,6005,6022,6035,6039],{"__ignoreMap":37},[41,5916,5917,5919,5922,5924],{"class":43,"line":44},[41,5918,1074],{"class":353},[41,5920,5921],{"class":47}," { useDebounceFn } ",[41,5923,1080],{"class":353},[41,5925,5720],{"class":70},[41,5927,5928],{"class":43,"line":51},[41,5929,348],{"emptyLinePlaceholder":347},[41,5931,5932,5934,5937,5939,5941,5943,5946],{"class":43,"line":61},[41,5933,1531],{"class":353},[41,5935,5936],{"class":54}," search",[41,5938,364],{"class":353},[41,5940,1275],{"class":360},[41,5942,1321],{"class":47},[41,5944,5945],{"class":70},"\"\"",[41,5947,1327],{"class":47},[41,5949,5950],{"class":43,"line":77},[41,5951,348],{"emptyLinePlaceholder":347},[41,5953,5954,5956,5959,5961,5964,5966,5969,5971,5974,5976,5978,5980,5982],{"class":43,"line":90},[41,5955,1531],{"class":353},[41,5957,5958],{"class":54}," searchApi",[41,5960,364],{"class":353},[41,5962,5963],{"class":360}," useDebounceFn",[41,5965,1321],{"class":47},[41,5967,5968],{"class":353},"async",[41,5970,2010],{"class":47},[41,5972,5973],{"class":487},"query",[41,5975,491],{"class":353},[41,5977,494],{"class":54},[41,5979,1292],{"class":47},[41,5981,1172],{"class":353},[41,5983,482],{"class":47},[41,5985,5986,5988,5991,5994,5997,6000,6002],{"class":43,"line":101},[41,5987,1492],{"class":353},[41,5989,5990],{"class":47}," (query.",[41,5992,5993],{"class":54},"length",[41,5995,5996],{"class":353}," \u003C",[41,5998,5999],{"class":54}," 2",[41,6001,1292],{"class":47},[41,6003,6004],{"class":353},"return\n",[41,6006,6007,6010,6012,6014,6016,6019],{"class":43,"line":107},[41,6008,6009],{"class":47},"  results.value ",[41,6011,1382],{"class":353},[41,6013,1412],{"class":353},[41,6015,5639],{"class":47},[41,6017,6018],{"class":360},"search",[41,6020,6021],{"class":47},"(query)\n",[41,6023,6024,6027,6030,6032],{"class":43,"line":116},[41,6025,6026],{"class":47},"}, ",[41,6028,6029],{"class":54},"350",[41,6031,1292],{"class":47},[41,6033,6034],{"class":341},"// 350ms after the last keystroke\n",[41,6036,6037],{"class":43,"line":122},[41,6038,348],{"emptyLinePlaceholder":347},[41,6040,6041,6044],{"class":43,"line":135},[41,6042,6043],{"class":360},"watch",[41,6045,6046],{"class":47},"(search, searchApi)\n",[15,6048,6049,6051],{},[19,6050,5905],{}," for cases where you want to guarantee at most one execution per interval (scroll, resize, mousemove):",[32,6053,6055],{"className":332,"code":6054,"language":334,"meta":37,"style":37},"import { useThrottleFn } from \"@vueuse/core\"\n\nconst onScroll = useThrottleFn((event: Event) => {\n  updateScrollPosition(window.scrollY)\n}, 100) // At most one execution per 100ms\n",[19,6056,6057,6068,6072,6100,6108],{"__ignoreMap":37},[41,6058,6059,6061,6064,6066],{"class":43,"line":44},[41,6060,1074],{"class":353},[41,6062,6063],{"class":47}," { useThrottleFn } ",[41,6065,1080],{"class":353},[41,6067,5720],{"class":70},[41,6069,6070],{"class":43,"line":51},[41,6071,348],{"emptyLinePlaceholder":347},[41,6073,6074,6076,6079,6081,6084,6087,6089,6091,6094,6096,6098],{"class":43,"line":61},[41,6075,1531],{"class":353},[41,6077,6078],{"class":54}," onScroll",[41,6080,364],{"class":353},[41,6082,6083],{"class":360}," useThrottleFn",[41,6085,6086],{"class":47},"((",[41,6088,3644],{"class":487},[41,6090,491],{"class":353},[41,6092,6093],{"class":360}," Event",[41,6095,1292],{"class":47},[41,6097,1172],{"class":353},[41,6099,482],{"class":47},[41,6101,6102,6105],{"class":43,"line":77},[41,6103,6104],{"class":360},"  updateScrollPosition",[41,6106,6107],{"class":47},"(window.scrollY)\n",[41,6109,6110,6112,6115,6117],{"class":43,"line":90},[41,6111,6026],{"class":47},[41,6113,6114],{"class":54},"100",[41,6116,1292],{"class":47},[41,6118,6119],{"class":341},"// At most one execution per 100ms\n",[15,6121,6122],{},"The distinction: debounce waits for activity to stop, throttle executes at regular intervals during activity. The practical rule: debounce for search, throttle for scroll.",[24,6124,6126,1799,6129,6132],{"id":6125},"uselocalstorage-and-usesessionstorage-reactive-persistent-state",[19,6127,6128],{},"useLocalStorage",[19,6130,6131],{},"useSessionStorage",": Reactive Persistent State",[32,6134,6136],{"className":332,"code":6135,"language":334,"meta":37,"style":37},"import { useLocalStorage } from \"@vueuse/core\"\n\n// Replaces localStorage.getItem / setItem / JSON.parse / JSON.stringify\nconst filters = useLocalStorage(\"certificate-filters\", {\n  status: \"ACTIVE\",\n  technology: null,\n  period: null,\n})\n\n// filters is a Ref — any modification is persisted automatically\nfilters.value.status = \"CANCELLED\"\n// localStorage.setItem('certificate-filters', '{\"status\":\"CANCELLED\",...}') called automatically\n",[19,6137,6138,6149,6153,6158,6178,6187,6196,6205,6210,6214,6219,6229],{"__ignoreMap":37},[41,6139,6140,6142,6145,6147],{"class":43,"line":44},[41,6141,1074],{"class":353},[41,6143,6144],{"class":47}," { useLocalStorage } ",[41,6146,1080],{"class":353},[41,6148,5720],{"class":70},[41,6150,6151],{"class":43,"line":51},[41,6152,348],{"emptyLinePlaceholder":347},[41,6154,6155],{"class":43,"line":61},[41,6156,6157],{"class":341},"// Replaces localStorage.getItem / setItem / JSON.parse / JSON.stringify\n",[41,6159,6160,6162,6165,6167,6170,6172,6175],{"class":43,"line":77},[41,6161,1531],{"class":353},[41,6163,6164],{"class":54}," filters",[41,6166,364],{"class":353},[41,6168,6169],{"class":360}," useLocalStorage",[41,6171,1321],{"class":47},[41,6173,6174],{"class":70},"\"certificate-filters\"",[41,6176,6177],{"class":47},", {\n",[41,6179,6180,6183,6185],{"class":43,"line":90},[41,6181,6182],{"class":47},"  status: ",[41,6184,200],{"class":70},[41,6186,74],{"class":47},[41,6188,6189,6192,6194],{"class":43,"line":101},[41,6190,6191],{"class":47},"  technology: ",[41,6193,1289],{"class":54},[41,6195,74],{"class":47},[41,6197,6198,6201,6203],{"class":43,"line":107},[41,6199,6200],{"class":47},"  period: ",[41,6202,1289],{"class":54},[41,6204,74],{"class":47},[41,6206,6207],{"class":43,"line":116},[41,6208,6209],{"class":47},"})\n",[41,6211,6212],{"class":43,"line":122},[41,6213,348],{"emptyLinePlaceholder":347},[41,6215,6216],{"class":43,"line":135},[41,6217,6218],{"class":341},"// filters is a Ref — any modification is persisted automatically\n",[41,6220,6221,6224,6226],{"class":43,"line":148},[41,6222,6223],{"class":47},"filters.value.status ",[41,6225,1382],{"class":353},[41,6227,6228],{"class":70}," \"CANCELLED\"\n",[41,6230,6231],{"class":43,"line":161},[41,6232,6233],{"class":341},"// localStorage.setItem('certificate-filters', '{\"status\":\"CANCELLED\",...}') called automatically\n",[15,6235,6236,6237,6240],{},"VueUse handles JSON serialisation, cross-tab synchronisation (via the ",[19,6238,6239],{},"storage"," event), and default values when the key does not yet exist.",[15,6242,6243],{},"With an explicit type for autocomplete:",[32,6245,6247],{"className":332,"code":6246,"language":334,"meta":37,"style":37},"interface FilterState {\n  status: \"ACTIVE\" | \"CANCELLED\" | \"TRANSFERRED\" | null\n  technology: string | null\n  period: string | null\n}\n\nconst filters = useLocalStorage\u003CFilterState>(\"certificate-filters\", {\n  status: null,\n  technology: null,\n  period: null,\n})\n",[19,6248,6249,6258,6279,6291,6303,6307,6311,6332,6340,6348,6356],{"__ignoreMap":37},[41,6250,6251,6253,6256],{"class":43,"line":44},[41,6252,1092],{"class":353},[41,6254,6255],{"class":360}," FilterState",[41,6257,482],{"class":47},[41,6259,6260,6262,6264,6266,6268,6270,6272,6275,6277],{"class":43,"line":51},[41,6261,689],{"class":487},[41,6263,491],{"class":353},[41,6265,392],{"class":70},[41,6267,370],{"class":353},[41,6269,397],{"class":70},[41,6271,370],{"class":353},[41,6273,6274],{"class":70}," \"TRANSFERRED\"",[41,6276,370],{"class":353},[41,6278,1395],{"class":54},[41,6280,6281,6283,6285,6287,6289],{"class":43,"line":61},[41,6282,598],{"class":487},[41,6284,491],{"class":353},[41,6286,494],{"class":54},[41,6288,370],{"class":353},[41,6290,1395],{"class":54},[41,6292,6293,6295,6297,6299,6301],{"class":43,"line":77},[41,6294,679],{"class":487},[41,6296,491],{"class":353},[41,6298,494],{"class":54},[41,6300,370],{"class":353},[41,6302,1395],{"class":54},[41,6304,6305],{"class":43,"line":90},[41,6306,322],{"class":47},[41,6308,6309],{"class":43,"line":101},[41,6310,348],{"emptyLinePlaceholder":347},[41,6312,6313,6315,6317,6319,6321,6323,6326,6328,6330],{"class":43,"line":107},[41,6314,1531],{"class":353},[41,6316,6164],{"class":54},[41,6318,364],{"class":353},[41,6320,6169],{"class":360},[41,6322,907],{"class":47},[41,6324,6325],{"class":360},"FilterState",[41,6327,1286],{"class":47},[41,6329,6174],{"class":70},[41,6331,6177],{"class":47},[41,6333,6334,6336,6338],{"class":43,"line":116},[41,6335,6182],{"class":47},[41,6337,1289],{"class":54},[41,6339,74],{"class":47},[41,6341,6342,6344,6346],{"class":43,"line":122},[41,6343,6191],{"class":47},[41,6345,1289],{"class":54},[41,6347,74],{"class":47},[41,6349,6350,6352,6354],{"class":43,"line":135},[41,6351,6200],{"class":47},[41,6353,1289],{"class":54},[41,6355,74],{"class":47},[41,6357,6358],{"class":43,"line":148},[41,6359,6209],{"class":47},[15,6361,6362,6363,6365,6366,6369,6370,6372,6373,6376],{},"The pitfall: ",[19,6364,6128],{}," is not available server-side (SSR/Nuxt). Use ",[19,6367,6368],{},"import.meta.client"," or the ",[19,6371,6128],{}," wrapper from ",[19,6374,6375],{},"@vueuse/nuxt",", which handles SSR correctly.",[24,6378,6380,6383],{"id":6379},"useintersectionobserver-lazy-loading-and-scroll-animations",[19,6381,6382],{},"useIntersectionObserver",": Lazy Loading and Scroll Animations",[15,6385,6386],{},"To load data only when an element enters the viewport:",[32,6388,6390],{"className":332,"code":6389,"language":334,"meta":37,"style":37},"import { useIntersectionObserver } from \"@vueuse/core\"\nimport { ref } from \"vue\"\n\nconst target = ref\u003CHTMLElement | null>(null)\nconst dataLoaded = ref(false)\n\nconst { stop } = useIntersectionObserver(\n  target,\n  ([{ isIntersecting }]) => {\n    if (isIntersecting && !dataLoaded.value) {\n      loadHeavyData()\n      dataLoaded.value = true\n      stop() // Observe only once\n    }\n  },\n  { threshold: 0.1 }, // Trigger when 10% of the element is visible\n)\n",[19,6391,6392,6403,6414,6418,6444,6461,6465,6483,6488,6503,6520,6527,6536,6547,6551,6555,6569],{"__ignoreMap":37},[41,6393,6394,6396,6399,6401],{"class":43,"line":44},[41,6395,1074],{"class":353},[41,6397,6398],{"class":47}," { useIntersectionObserver } ",[41,6400,1080],{"class":353},[41,6402,5720],{"class":70},[41,6404,6405,6407,6410,6412],{"class":43,"line":51},[41,6406,1074],{"class":353},[41,6408,6409],{"class":47}," { ref } ",[41,6411,1080],{"class":353},[41,6413,1083],{"class":70},[41,6415,6416],{"class":43,"line":61},[41,6417,348],{"emptyLinePlaceholder":347},[41,6419,6420,6422,6425,6427,6429,6431,6434,6436,6438,6440,6442],{"class":43,"line":77},[41,6421,1531],{"class":353},[41,6423,6424],{"class":54}," target",[41,6426,364],{"class":353},[41,6428,1275],{"class":360},[41,6430,907],{"class":47},[41,6432,6433],{"class":360},"HTMLElement",[41,6435,370],{"class":353},[41,6437,1122],{"class":54},[41,6439,1286],{"class":47},[41,6441,1289],{"class":54},[41,6443,1327],{"class":47},[41,6445,6446,6448,6451,6453,6455,6457,6459],{"class":43,"line":90},[41,6447,1531],{"class":353},[41,6449,6450],{"class":54}," dataLoaded",[41,6452,364],{"class":353},[41,6454,1275],{"class":360},[41,6456,1321],{"class":47},[41,6458,1324],{"class":54},[41,6460,1327],{"class":47},[41,6462,6463],{"class":43,"line":101},[41,6464,348],{"emptyLinePlaceholder":347},[41,6466,6467,6469,6471,6474,6476,6478,6481],{"class":43,"line":107},[41,6468,1531],{"class":353},[41,6470,1237],{"class":47},[41,6472,6473],{"class":54},"stop",[41,6475,5750],{"class":47},[41,6477,1382],{"class":353},[41,6479,6480],{"class":360}," useIntersectionObserver",[41,6482,1947],{"class":47},[41,6484,6485],{"class":43,"line":116},[41,6486,6487],{"class":47},"  target,\n",[41,6489,6490,6493,6496,6499,6501],{"class":43,"line":122},[41,6491,6492],{"class":47},"  ([{ ",[41,6494,6495],{"class":487},"isIntersecting",[41,6497,6498],{"class":47}," }]) ",[41,6500,1172],{"class":353},[41,6502,482],{"class":47},[41,6504,6505,6508,6511,6514,6517],{"class":43,"line":135},[41,6506,6507],{"class":353},"    if",[41,6509,6510],{"class":47}," (isIntersecting ",[41,6512,6513],{"class":353},"&&",[41,6515,6516],{"class":353}," !",[41,6518,6519],{"class":47},"dataLoaded.value) {\n",[41,6521,6522,6525],{"class":43,"line":148},[41,6523,6524],{"class":360},"      loadHeavyData",[41,6526,1418],{"class":47},[41,6528,6529,6532,6534],{"class":43,"line":161},[41,6530,6531],{"class":47},"      dataLoaded.value ",[41,6533,1382],{"class":353},[41,6535,1385],{"class":54},[41,6537,6538,6541,6544],{"class":43,"line":192},[41,6539,6540],{"class":360},"      stop",[41,6542,6543],{"class":47},"() ",[41,6545,6546],{"class":341},"// Observe only once\n",[41,6548,6549],{"class":43,"line":205},[41,6550,259],{"class":47},[41,6552,6553],{"class":43,"line":213},[41,6554,104],{"class":47},[41,6556,6557,6560,6563,6566],{"class":43,"line":226},[41,6558,6559],{"class":47},"  { threshold: ",[41,6561,6562],{"class":54},"0.1",[41,6564,6565],{"class":47}," }, ",[41,6567,6568],{"class":341},"// Trigger when 10% of the element is visible\n",[41,6570,6571],{"class":43,"line":239},[41,6572,1327],{"class":47},[15,6574,6575],{},"In the template:",[32,6577,6581],{"className":6578,"code":6579,"language":6580,"meta":37,"style":37},"language-vue shiki shiki-themes github-dark github-light","\u003Ctemplate>\n  \u003Cdiv ref=\"target\">\n    \u003CSpinner v-if=\"!dataLoaded\" />\n    \u003CHeavyChart v-else :data=\"chartData\" />\n  \u003C/div>\n\u003C/template>\n","vue",[19,6582,6583,6593,6610,6629,6649,6658],{"__ignoreMap":37},[41,6584,6585,6587,6591],{"class":43,"line":44},[41,6586,907],{"class":47},[41,6588,6590],{"class":6589},"sZkSk","template",[41,6592,882],{"class":47},[41,6594,6595,6598,6601,6603,6605,6608],{"class":43,"line":51},[41,6596,6597],{"class":47},"  \u003C",[41,6599,6600],{"class":6589},"div",[41,6602,1275],{"class":360},[41,6604,1382],{"class":47},[41,6606,6607],{"class":70},"\"target\"",[41,6609,882],{"class":47},[41,6611,6612,6615,6618,6621,6623,6626],{"class":43,"line":61},[41,6613,6614],{"class":47},"    \u003C",[41,6616,6617],{"class":6589},"Spinner",[41,6619,6620],{"class":360}," v-if",[41,6622,1382],{"class":47},[41,6624,6625],{"class":70},"\"!dataLoaded\"",[41,6627,6628],{"class":47}," />\n",[41,6630,6631,6633,6636,6639,6642,6644,6647],{"class":43,"line":77},[41,6632,6614],{"class":47},[41,6634,6635],{"class":6589},"HeavyChart",[41,6637,6638],{"class":360}," v-else",[41,6640,6641],{"class":360}," :data",[41,6643,1382],{"class":47},[41,6645,6646],{"class":70},"\"chartData\"",[41,6648,6628],{"class":47},[41,6650,6651,6654,6656],{"class":43,"line":90},[41,6652,6653],{"class":47},"  \u003C/",[41,6655,6600],{"class":6589},[41,6657,882],{"class":47},[41,6659,6660,6662,6664],{"class":43,"line":101},[41,6661,1788],{"class":47},[41,6663,6590],{"class":6589},[41,6665,882],{"class":47},[15,6667,6668,6671],{},[19,6669,6670],{},"stop()"," halts observation after the first trigger — avoids unnecessary repeated calls. Also useful for entrance animations: applying a CSS class when an element becomes visible.",[24,6673,6675,6678],{"id":6674},"useeventlistener-clean-dom-event-management",[19,6676,6677],{},"useEventListener",": Clean DOM Event Management",[32,6680,6682],{"className":332,"code":6681,"language":334,"meta":37,"style":37},"import { useEventListener } from \"@vueuse/core\"\n\n// Automatically cleaned up when the component unmounts\nuseEventListener(window, \"keydown\", (event: KeyboardEvent) => {\n  if (event.key === \"Escape\") closeModal()\n  if (event.ctrlKey && event.key === \"s\") saveForm()\n})\n\n// On a reactive element ref\nconst tableRef = ref\u003CHTMLElement | null>(null)\nuseEventListener(tableRef, \"click\", handleCellClick)\n",[19,6683,6684,6695,6699,6704,6730,6749,6773,6777,6781,6786,6811],{"__ignoreMap":37},[41,6685,6686,6688,6691,6693],{"class":43,"line":44},[41,6687,1074],{"class":353},[41,6689,6690],{"class":47}," { useEventListener } ",[41,6692,1080],{"class":353},[41,6694,5720],{"class":70},[41,6696,6697],{"class":43,"line":51},[41,6698,348],{"emptyLinePlaceholder":347},[41,6700,6701],{"class":43,"line":61},[41,6702,6703],{"class":341},"// Automatically cleaned up when the component unmounts\n",[41,6705,6706,6708,6711,6714,6717,6719,6721,6724,6726,6728],{"class":43,"line":77},[41,6707,6677],{"class":360},[41,6709,6710],{"class":47},"(window, ",[41,6712,6713],{"class":70},"\"keydown\"",[41,6715,6716],{"class":47},", (",[41,6718,3644],{"class":487},[41,6720,491],{"class":353},[41,6722,6723],{"class":360}," KeyboardEvent",[41,6725,1292],{"class":47},[41,6727,1172],{"class":353},[41,6729,482],{"class":47},[41,6731,6732,6734,6737,6739,6742,6744,6747],{"class":43,"line":90},[41,6733,1492],{"class":353},[41,6735,6736],{"class":47}," (event.key ",[41,6738,1869],{"class":353},[41,6740,6741],{"class":70}," \"Escape\"",[41,6743,1292],{"class":47},[41,6745,6746],{"class":360},"closeModal",[41,6748,1418],{"class":47},[41,6750,6751,6753,6756,6758,6761,6763,6766,6768,6771],{"class":43,"line":101},[41,6752,1492],{"class":353},[41,6754,6755],{"class":47}," (event.ctrlKey ",[41,6757,6513],{"class":353},[41,6759,6760],{"class":47}," event.key ",[41,6762,1869],{"class":353},[41,6764,6765],{"class":70}," \"s\"",[41,6767,1292],{"class":47},[41,6769,6770],{"class":360},"saveForm",[41,6772,1418],{"class":47},[41,6774,6775],{"class":43,"line":107},[41,6776,6209],{"class":47},[41,6778,6779],{"class":43,"line":116},[41,6780,348],{"emptyLinePlaceholder":347},[41,6782,6783],{"class":43,"line":122},[41,6784,6785],{"class":341},"// On a reactive element ref\n",[41,6787,6788,6790,6793,6795,6797,6799,6801,6803,6805,6807,6809],{"class":43,"line":135},[41,6789,1531],{"class":353},[41,6791,6792],{"class":54}," tableRef",[41,6794,364],{"class":353},[41,6796,1275],{"class":360},[41,6798,907],{"class":47},[41,6800,6433],{"class":360},[41,6802,370],{"class":353},[41,6804,1122],{"class":54},[41,6806,1286],{"class":47},[41,6808,1289],{"class":54},[41,6810,1327],{"class":47},[41,6812,6813,6815,6818,6821],{"class":43,"line":148},[41,6814,6677],{"class":360},[41,6816,6817],{"class":47},"(tableRef, ",[41,6819,6820],{"class":70},"\"click\"",[41,6822,6823],{"class":47},", handleCellClick)\n",[15,6825,6826,6827,6830,6831,6834,6835,6837],{},"Without VueUse, you must remember to call ",[19,6828,6829],{},"removeEventListener"," in ",[19,6832,6833],{},"onUnmounted"," — easy to forget, and a reliable source of memory leaks. ",[19,6836,6677],{}," handles this automatically.",[24,6839,6841,6844],{"id":6840},"useclipboard-copying-to-the-clipboard",[19,6842,6843],{},"useClipboard",": Copying to the Clipboard",[32,6846,6848],{"className":332,"code":6847,"language":334,"meta":37,"style":37},"import { useClipboard } from \"@vueuse/core\"\n\nconst { copy, copied, isSupported } = useClipboard()\n",[19,6849,6850,6861,6865],{"__ignoreMap":37},[41,6851,6852,6854,6857,6859],{"class":43,"line":44},[41,6853,1074],{"class":353},[41,6855,6856],{"class":47}," { useClipboard } ",[41,6858,1080],{"class":353},[41,6860,5720],{"class":70},[41,6862,6863],{"class":43,"line":51},[41,6864,348],{"emptyLinePlaceholder":347},[41,6866,6867,6869,6871,6874,6876,6879,6881,6884,6886,6888,6891],{"class":43,"line":61},[41,6868,1531],{"class":353},[41,6870,1237],{"class":47},[41,6872,6873],{"class":54},"copy",[41,6875,178],{"class":47},[41,6877,6878],{"class":54},"copied",[41,6880,178],{"class":47},[41,6882,6883],{"class":54},"isSupported",[41,6885,5750],{"class":47},[41,6887,1382],{"class":353},[41,6889,6890],{"class":360}," useClipboard",[41,6892,1418],{"class":47},[32,6894,6896],{"className":6578,"code":6895,"language":6580,"meta":37,"style":37},"\u003Ctemplate>\n  \u003Cbutton @click=\"copy(certificateId)\" :disabled=\"!isSupported\">\n    {{ copied ? \"✓ Copied\" : \"Copy ID\" }}\n  \u003C/button>\n\u003C/template>\n",[19,6897,6898,6906,6931,6936,6944],{"__ignoreMap":37},[41,6899,6900,6902,6904],{"class":43,"line":44},[41,6901,907],{"class":47},[41,6903,6590],{"class":6589},[41,6905,882],{"class":47},[41,6907,6908,6910,6913,6916,6918,6921,6924,6926,6929],{"class":43,"line":51},[41,6909,6597],{"class":47},[41,6911,6912],{"class":6589},"button",[41,6914,6915],{"class":360}," @click",[41,6917,1382],{"class":47},[41,6919,6920],{"class":70},"\"copy(certificateId)\"",[41,6922,6923],{"class":360}," :disabled",[41,6925,1382],{"class":47},[41,6927,6928],{"class":70},"\"!isSupported\"",[41,6930,882],{"class":47},[41,6932,6933],{"class":43,"line":61},[41,6934,6935],{"class":47},"    {{ copied ? \"✓ Copied\" : \"Copy ID\" }}\n",[41,6937,6938,6940,6942],{"class":43,"line":77},[41,6939,6653],{"class":47},[41,6941,6912],{"class":6589},[41,6943,882],{"class":47},[41,6945,6946,6948,6950],{"class":43,"line":90},[41,6947,1788],{"class":47},[41,6949,6590],{"class":6589},[41,6951,882],{"class":47},[15,6953,6954,6956,6957,6959,6960,6962],{},[19,6955,6878],{}," automatically reverts to ",[19,6958,1324],{}," after 1.5 seconds (configurable). ",[19,6961,6883],{}," checks whether the Clipboard API is available in the browser — useful for providing a fallback.",[24,6964,6966,6969],{"id":6965},"usemediaquery-responsive-logic-beyond-css",[19,6967,6968],{},"useMediaQuery",": Responsive Logic Beyond CSS",[32,6971,6973],{"className":332,"code":6972,"language":334,"meta":37,"style":37},"import { useMediaQuery } from \"@vueuse/core\"\n\nconst isMobile = useMediaQuery(\"(max-width: 768px)\")\nconst prefersReducedMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\")\nconst isDarkMode = useMediaQuery(\"(prefers-color-scheme: dark)\")\n\n// Reactive — updates when the window is resized\nwatch(isMobile, (mobile) => {\n  if (mobile) collapseNavigation()\n})\n",[19,6974,6975,6986,6990,7009,7027,7045,7049,7054,7070,7082],{"__ignoreMap":37},[41,6976,6977,6979,6982,6984],{"class":43,"line":44},[41,6978,1074],{"class":353},[41,6980,6981],{"class":47}," { useMediaQuery } ",[41,6983,1080],{"class":353},[41,6985,5720],{"class":70},[41,6987,6988],{"class":43,"line":51},[41,6989,348],{"emptyLinePlaceholder":347},[41,6991,6992,6994,6997,6999,7002,7004,7007],{"class":43,"line":61},[41,6993,1531],{"class":353},[41,6995,6996],{"class":54}," isMobile",[41,6998,364],{"class":353},[41,7000,7001],{"class":360}," useMediaQuery",[41,7003,1321],{"class":47},[41,7005,7006],{"class":70},"\"(max-width: 768px)\"",[41,7008,1327],{"class":47},[41,7010,7011,7013,7016,7018,7020,7022,7025],{"class":43,"line":77},[41,7012,1531],{"class":353},[41,7014,7015],{"class":54}," prefersReducedMotion",[41,7017,364],{"class":353},[41,7019,7001],{"class":360},[41,7021,1321],{"class":47},[41,7023,7024],{"class":70},"\"(prefers-reduced-motion: reduce)\"",[41,7026,1327],{"class":47},[41,7028,7029,7031,7034,7036,7038,7040,7043],{"class":43,"line":90},[41,7030,1531],{"class":353},[41,7032,7033],{"class":54}," isDarkMode",[41,7035,364],{"class":353},[41,7037,7001],{"class":360},[41,7039,1321],{"class":47},[41,7041,7042],{"class":70},"\"(prefers-color-scheme: dark)\"",[41,7044,1327],{"class":47},[41,7046,7047],{"class":43,"line":101},[41,7048,348],{"emptyLinePlaceholder":347},[41,7050,7051],{"class":43,"line":107},[41,7052,7053],{"class":341},"// Reactive — updates when the window is resized\n",[41,7055,7056,7058,7061,7064,7066,7068],{"class":43,"line":116},[41,7057,6043],{"class":360},[41,7059,7060],{"class":47},"(isMobile, (",[41,7062,7063],{"class":487},"mobile",[41,7065,1292],{"class":47},[41,7067,1172],{"class":353},[41,7069,482],{"class":47},[41,7071,7072,7074,7077,7080],{"class":43,"line":122},[41,7073,1492],{"class":353},[41,7075,7076],{"class":47}," (mobile) ",[41,7078,7079],{"class":360},"collapseNavigation",[41,7081,1418],{"class":47},[41,7083,7084],{"class":43,"line":135},[41,7085,6209],{"class":47},[15,7087,7088],{},"Useful when JavaScript behaviour must adapt to screen size — not just CSS. For instance, disabling complex animations on mobile, or reducing the amount of data loaded on smaller viewports.",[24,7090,7092,7095],{"id":7091},"useeventsource-consuming-an-sse-stream",[19,7093,7094],{},"useEventSource",": Consuming an SSE Stream",[15,7097,7098],{},"Server-Sent Events is often preferable to WebSockets for unidirectional streams (notifications, status updates) — simpler, with native automatic reconnection, and compatible with HTTP proxies.",[32,7100,7102],{"className":332,"code":7101,"language":334,"meta":37,"style":37},"import { useEventSource } from \"@vueuse/core\"\n\nconst { data, status, error, close } = useEventSource(\n  \"/api/events/certificates\",\n  [\"certificate_updated\", \"certificate_created\"], // Events to listen to\n  { withCredentials: true },\n)\n\n// data holds the most recently received payload\nwatch(data, (raw) => {\n  if (!raw) return\n  const event = JSON.parse(raw)\n  updateCertificateInList(event)\n})\n\n// status: 'CONNECTING' | 'OPEN' | 'CLOSED'\n",[19,7103,7104,7115,7119,7148,7155,7174,7183,7187,7191,7196,7212,7225,7243,7251,7255,7259],{"__ignoreMap":37},[41,7105,7106,7108,7111,7113],{"class":43,"line":44},[41,7107,1074],{"class":353},[41,7109,7110],{"class":47}," { useEventSource } ",[41,7112,1080],{"class":353},[41,7114,5720],{"class":70},[41,7116,7117],{"class":43,"line":51},[41,7118,348],{"emptyLinePlaceholder":347},[41,7120,7121,7123,7125,7127,7129,7131,7133,7135,7137,7139,7141,7143,7146],{"class":43,"line":61},[41,7122,1531],{"class":353},[41,7124,1237],{"class":47},[41,7126,1616],{"class":54},[41,7128,178],{"class":47},[41,7130,1772],{"class":54},[41,7132,178],{"class":47},[41,7134,5743],{"class":54},[41,7136,178],{"class":47},[41,7138,3752],{"class":54},[41,7140,5750],{"class":47},[41,7142,1382],{"class":353},[41,7144,7145],{"class":360}," useEventSource",[41,7147,1947],{"class":47},[41,7149,7150,7153],{"class":43,"line":77},[41,7151,7152],{"class":70},"  \"/api/events/certificates\"",[41,7154,74],{"class":47},[41,7156,7157,7160,7163,7165,7168,7171],{"class":43,"line":90},[41,7158,7159],{"class":47},"  [",[41,7161,7162],{"class":70},"\"certificate_updated\"",[41,7164,178],{"class":47},[41,7166,7167],{"class":70},"\"certificate_created\"",[41,7169,7170],{"class":47},"], ",[41,7172,7173],{"class":341},"// Events to listen to\n",[41,7175,7176,7179,7181],{"class":43,"line":101},[41,7177,7178],{"class":47},"  { withCredentials: ",[41,7180,1604],{"class":54},[41,7182,189],{"class":47},[41,7184,7185],{"class":43,"line":107},[41,7186,1327],{"class":47},[41,7188,7189],{"class":43,"line":116},[41,7190,348],{"emptyLinePlaceholder":347},[41,7192,7193],{"class":43,"line":122},[41,7194,7195],{"class":341},"// data holds the most recently received payload\n",[41,7197,7198,7200,7203,7206,7208,7210],{"class":43,"line":135},[41,7199,6043],{"class":360},[41,7201,7202],{"class":47},"(data, (",[41,7204,7205],{"class":487},"raw",[41,7207,1292],{"class":47},[41,7209,1172],{"class":353},[41,7211,482],{"class":47},[41,7213,7214,7216,7218,7220,7223],{"class":43,"line":148},[41,7215,1492],{"class":353},[41,7217,2010],{"class":47},[41,7219,2013],{"class":353},[41,7221,7222],{"class":47},"raw) ",[41,7224,6004],{"class":353},[41,7226,7227,7229,7232,7234,7236,7238,7240],{"class":43,"line":161},[41,7228,1267],{"class":353},[41,7230,7231],{"class":54}," event",[41,7233,364],{"class":353},[41,7235,3663],{"class":54},[41,7237,1625],{"class":47},[41,7239,3668],{"class":360},[41,7241,7242],{"class":47},"(raw)\n",[41,7244,7245,7248],{"class":43,"line":192},[41,7246,7247],{"class":360},"  updateCertificateInList",[41,7249,7250],{"class":47},"(event)\n",[41,7252,7253],{"class":43,"line":205},[41,7254,6209],{"class":47},[41,7256,7257],{"class":43,"line":213},[41,7258,348],{"emptyLinePlaceholder":347},[41,7260,7261],{"class":43,"line":226},[41,7262,7263],{"class":341},"// status: 'CONNECTING' | 'OPEN' | 'CLOSED'\n",[15,7265,7266],{},"On the FastAPI side, a minimal SSE endpoint:",[32,7268,7270],{"className":2213,"code":7269,"language":2215,"meta":37,"style":37},"from fastapi.responses import StreamingResponse\nimport asyncio\nimport json\n\n@router.get(\"/api/events/certificates\")\nasync def certificate_events(request: Request):\n    async def event_generator():\n        while True:\n            if await request.is_disconnected():\n                break\n            event = await event_queue.get()\n            yield f\"event: {event['type']}\\ndata: {json.dumps(event)}\\n\\n\"\n\n    return StreamingResponse(\n        event_generator(),\n        media_type=\"text/event-stream\",\n        headers={\"Cache-Control\": \"no-cache\", \"X-Accel-Buffering\": \"no\"}\n    )\n",[19,7271,7272,7277,7281,7285,7289,7294,7299,7304,7308,7313,7318,7323,7328,7332,7337,7342,7347,7352],{"__ignoreMap":37},[41,7273,7274],{"class":43,"line":44},[41,7275,7276],{},"from fastapi.responses import StreamingResponse\n",[41,7278,7279],{"class":43,"line":51},[41,7280,3060],{},[41,7282,7283],{"class":43,"line":61},[41,7284,2222],{},[41,7286,7287],{"class":43,"line":77},[41,7288,348],{"emptyLinePlaceholder":347},[41,7290,7291],{"class":43,"line":90},[41,7292,7293],{},"@router.get(\"/api/events/certificates\")\n",[41,7295,7296],{"class":43,"line":101},[41,7297,7298],{},"async def certificate_events(request: Request):\n",[41,7300,7301],{"class":43,"line":107},[41,7302,7303],{},"    async def event_generator():\n",[41,7305,7306],{"class":43,"line":116},[41,7307,3490],{},[41,7309,7310],{"class":43,"line":122},[41,7311,7312],{},"            if await request.is_disconnected():\n",[41,7314,7315],{"class":43,"line":135},[41,7316,7317],{},"                break\n",[41,7319,7320],{"class":43,"line":148},[41,7321,7322],{},"            event = await event_queue.get()\n",[41,7324,7325],{"class":43,"line":161},[41,7326,7327],{},"            yield f\"event: {event['type']}\\ndata: {json.dumps(event)}\\n\\n\"\n",[41,7329,7330],{"class":43,"line":192},[41,7331,348],{"emptyLinePlaceholder":347},[41,7333,7334],{"class":43,"line":205},[41,7335,7336],{},"    return StreamingResponse(\n",[41,7338,7339],{"class":43,"line":213},[41,7340,7341],{},"        event_generator(),\n",[41,7343,7344],{"class":43,"line":226},[41,7345,7346],{},"        media_type=\"text/event-stream\",\n",[41,7348,7349],{"class":43,"line":239},[41,7350,7351],{},"        headers={\"Cache-Control\": \"no-cache\", \"X-Accel-Buffering\": \"no\"}\n",[41,7353,7354],{"class":43,"line":250},[41,7355,1964],{},[15,7357,7358,7361],{},[19,7359,7360],{},"X-Accel-Buffering: no"," is critical behind nginx or an OpenShift ingress — without it, events are buffered and do not arrive in real time.",[24,7363,7365,7368],{"id":7364},"usevmodel-simplifying-form-components",[19,7366,7367],{},"useVModel",": Simplifying Form Components",[15,7370,7371,7372,491],{},"For a component that wraps an input and needs to support ",[19,7373,7374],{},"v-model",[32,7376,7378],{"className":332,"code":7377,"language":334,"meta":37,"style":37},"import { useVModel } from \"@vueuse/core\"\n\n// InputField.vue\nconst props = defineProps\u003C{\n  modelValue: string\n  label: string\n}>()\nconst emit = defineEmits([\"update:modelValue\"])\n\nconst value = useVModel(props, \"modelValue\", emit)\n\n// value is a writable Ref — usable directly in the template\n",[19,7379,7380,7391,7395,7400,7412,7421,7430,7434,7453,7457,7477,7481],{"__ignoreMap":37},[41,7381,7382,7384,7387,7389],{"class":43,"line":44},[41,7383,1074],{"class":353},[41,7385,7386],{"class":47}," { useVModel } ",[41,7388,1080],{"class":353},[41,7390,5720],{"class":70},[41,7392,7393],{"class":43,"line":51},[41,7394,348],{"emptyLinePlaceholder":347},[41,7396,7397],{"class":43,"line":61},[41,7398,7399],{"class":341},"// InputField.vue\n",[41,7401,7402,7404,7406,7408,7410],{"class":43,"line":77},[41,7403,1531],{"class":353},[41,7405,1681],{"class":54},[41,7407,364],{"class":353},[41,7409,1686],{"class":360},[41,7411,1689],{"class":47},[41,7413,7414,7417,7419],{"class":43,"line":90},[41,7415,7416],{"class":487},"  modelValue",[41,7418,491],{"class":353},[41,7420,507],{"class":54},[41,7422,7423,7426,7428],{"class":43,"line":101},[41,7424,7425],{"class":487},"  label",[41,7427,491],{"class":353},[41,7429,507],{"class":54},[41,7431,7432],{"class":43,"line":107},[41,7433,1714],{"class":47},[41,7435,7436,7438,7440,7442,7444,7447,7450],{"class":43,"line":116},[41,7437,1531],{"class":353},[41,7439,1725],{"class":54},[41,7441,364],{"class":353},[41,7443,1730],{"class":360},[41,7445,7446],{"class":47},"([",[41,7448,7449],{"class":70},"\"update:modelValue\"",[41,7451,7452],{"class":47},"])\n",[41,7454,7455],{"class":43,"line":122},[41,7456,348],{"emptyLinePlaceholder":347},[41,7458,7459,7461,7463,7465,7468,7471,7474],{"class":43,"line":135},[41,7460,1531],{"class":353},[41,7462,1844],{"class":54},[41,7464,364],{"class":353},[41,7466,7467],{"class":360}," useVModel",[41,7469,7470],{"class":47},"(props, ",[41,7472,7473],{"class":70},"\"modelValue\"",[41,7475,7476],{"class":47},", emit)\n",[41,7478,7479],{"class":43,"line":148},[41,7480,348],{"emptyLinePlaceholder":347},[41,7482,7483],{"class":43,"line":161},[41,7484,7485],{"class":341},"// value is a writable Ref — usable directly in the template\n",[32,7487,7489],{"className":6578,"code":7488,"language":6580,"meta":37,"style":37},"\u003Ctemplate>\n  \u003Cdiv>\n    \u003Clabel>{{ label }}\u003C/label>\n    \u003Cinput v-model=\"value\" />\n  \u003C/div>\n\u003C/template>\n",[19,7490,7491,7499,7507,7521,7538,7546],{"__ignoreMap":37},[41,7492,7493,7495,7497],{"class":43,"line":44},[41,7494,907],{"class":47},[41,7496,6590],{"class":6589},[41,7498,882],{"class":47},[41,7500,7501,7503,7505],{"class":43,"line":51},[41,7502,6597],{"class":47},[41,7504,6600],{"class":6589},[41,7506,882],{"class":47},[41,7508,7509,7511,7514,7517,7519],{"class":43,"line":61},[41,7510,6614],{"class":47},[41,7512,7513],{"class":6589},"label",[41,7515,7516],{"class":47},">{{ label }}\u003C/",[41,7518,7513],{"class":6589},[41,7520,882],{"class":47},[41,7522,7523,7525,7528,7531,7533,7536],{"class":43,"line":77},[41,7524,6614],{"class":47},[41,7526,7527],{"class":6589},"input",[41,7529,7530],{"class":360}," v-model",[41,7532,1382],{"class":47},[41,7534,7535],{"class":70},"\"value\"",[41,7537,6628],{"class":47},[41,7539,7540,7542,7544],{"class":43,"line":90},[41,7541,6653],{"class":47},[41,7543,6600],{"class":6589},[41,7545,882],{"class":47},[41,7547,7548,7550,7552],{"class":43,"line":101},[41,7549,1788],{"class":47},[41,7551,6590],{"class":6589},[41,7553,882],{"class":47},[15,7555,7556,7557,7559],{},"Without ",[19,7558,7367],{},", you must manually manage the prop and the emit — two extra lines, and the risk of accidentally mutating the prop directly.",[24,7561,4440],{"id":4439},[15,7563,7564,7565,178,7567,7569,7570,178,7572,178,7574,7576,7577,178,7579,7581],{},"VueUse is most valuable across three categories: composables that eliminate recurring boilerplate (",[19,7566,5519],{},[19,7568,7367],{},"), composables that wrap verbose browser APIs (",[19,7571,6382],{},[19,7573,6677],{},[19,7575,6843],{},"), and composables that address performance concerns (",[19,7578,5902],{},[19,7580,5905],{},"). The rest is situationally useful — but these ten appear on virtually every professional Vue.js project.",[2097,7583,7584],{},"html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}html pre.shiki code .sZkSk, html code.shiki .sZkSk{--shiki-dark:#85E89D;--shiki-default:#22863A}",{"title":37,"searchDepth":51,"depth":51,"links":7586},[7587,7588,7590,7592,7594,7596,7598,7600,7602,7604,7606],{"id":5491,"depth":51,"text":5492},{"id":5516,"depth":51,"text":7589},"useAsyncState: Replacing the loading/error/data Pattern",{"id":5899,"depth":51,"text":7591},"useDebounceFn and useThrottleFn: Performance on Frequent Events",{"id":6125,"depth":51,"text":7593},"useLocalStorage and useSessionStorage: Reactive Persistent State",{"id":6379,"depth":51,"text":7595},"useIntersectionObserver: Lazy Loading and Scroll Animations",{"id":6674,"depth":51,"text":7597},"useEventListener: Clean DOM Event Management",{"id":6840,"depth":51,"text":7599},"useClipboard: Copying to the Clipboard",{"id":6965,"depth":51,"text":7601},"useMediaQuery: Responsive Logic Beyond CSS",{"id":7091,"depth":51,"text":7603},"useEventSource: Consuming an SSE Stream",{"id":7364,"depth":51,"text":7605},"useVModel: Simplifying Form Components",{"id":4439,"depth":51,"text":4440},"2025-01-10",{},"/en/blog/vueuse-essentiels",{"title":5479,"description":5488},"vueuse-essentiels","en/blog/vueuse-essentiels",[2121,7614,7615,2123],"VueUse","Composition API","g-rrYJc07LY3uzlMGHOyWf6cggd0-6YCVVZ09GfnLXg",{"id":7618,"title":7619,"body":7620,"date":8314,"description":7627,"excerpt":2112,"extension":2113,"meta":8315,"navigation":347,"path":8316,"readTime":107,"seo":8317,"slug":8318,"stem":8319,"tags":8320,"__hash__":8323},"en_blog/en/blog/dataclasses-pydantic-typeddict.md","Dataclasses, Pydantic, TypedDict: Which to Choose and Why",{"type":8,"value":7621,"toc":8306},[7622,7625,7628,7632,7635,7641,7658,7664,7667,7671,7739,7742,7749,7753,7826,7829,7835,7879,7885,7889,8001,8004,8061,8067,8071,8074,8139,8142,8145,8149,8152,8301,8304],[11,7623,7619],{"id":7624},"dataclasses-pydantic-typeddict-which-to-choose-and-why",[15,7626,7627],{},"This is a question every Python team eventually confronts. The answers found online tend toward the superficial: \"Pydantic for APIs, dataclasses for everything else\" — which is a starting point, but fails to address the situations where the choice actually matters. Here are the decision rules applied in practice.",[24,7629,7631],{"id":7630},"understanding-what-each-tool-actually-does","Understanding What Each Tool Actually Does",[15,7633,7634],{},"Before the rules, a clear-headed reminder of what each tool is for:",[15,7636,7637,7640],{},[2071,7638,7639],{},"TypedDict"," is a pure type annotation. It does nothing at runtime — it informs the type checker (mypy, pyright) about the shape of a dictionary. Zero overhead, zero validation.",[15,7642,7643,7646,7647,178,7650,7653,7654,7657],{},[2071,7644,7645],{},"Dataclass"," is a Python class generator. It automatically creates ",[19,7648,7649],{},"__init__",[19,7651,7652],{},"__repr__",", and ",[19,7655,7656],{},"__eq__"," from annotations. No runtime type validation.",[15,7659,7660,7663],{},[2071,7661,7662],{},"Pydantic BaseModel"," is a complete validation system. It converts and validates data at runtime, raises detailed errors, serialises and deserialises JSON, and generates JSON Schema.",[15,7665,7666],{},"These are not three ways to accomplish the same thing — they are three tools with distinct responsibilities.",[24,7668,7670],{"id":7669},"rule-1-typeddict-for-dictionaries-you-do-not-control","Rule 1: TypedDict for Dictionaries You Do Not Control",[32,7672,7674],{"className":2213,"code":7673,"language":2215,"meta":37,"style":37},"from typing import TypedDict\n\n# Data returned by an external API — you read it, you don't construct it\nclass GrxCertificate(TypedDict):\n    id: str\n    volume: float\n    period_from: str\n    period_to: str\n    status: str\n\n# Usage\ndef process_certificate(cert: GrxCertificate) -> float:\n    return cert[\"volume\"] * 1.05  # Type checker validates the field access\n",[19,7675,7676,7681,7685,7690,7695,7700,7705,7710,7715,7720,7724,7729,7734],{"__ignoreMap":37},[41,7677,7678],{"class":43,"line":44},[41,7679,7680],{},"from typing import TypedDict\n",[41,7682,7683],{"class":43,"line":51},[41,7684,348],{"emptyLinePlaceholder":347},[41,7686,7687],{"class":43,"line":61},[41,7688,7689],{},"# Data returned by an external API — you read it, you don't construct it\n",[41,7691,7692],{"class":43,"line":77},[41,7693,7694],{},"class GrxCertificate(TypedDict):\n",[41,7696,7697],{"class":43,"line":90},[41,7698,7699],{},"    id: str\n",[41,7701,7702],{"class":43,"line":101},[41,7703,7704],{},"    volume: float\n",[41,7706,7707],{"class":43,"line":107},[41,7708,7709],{},"    period_from: str\n",[41,7711,7712],{"class":43,"line":116},[41,7713,7714],{},"    period_to: str\n",[41,7716,7717],{"class":43,"line":122},[41,7718,7719],{},"    status: str\n",[41,7721,7722],{"class":43,"line":135},[41,7723,348],{"emptyLinePlaceholder":347},[41,7725,7726],{"class":43,"line":148},[41,7727,7728],{},"# Usage\n",[41,7730,7731],{"class":43,"line":161},[41,7732,7733],{},"def process_certificate(cert: GrxCertificate) -> float:\n",[41,7735,7736],{"class":43,"line":192},[41,7737,7738],{},"    return cert[\"volume\"] * 1.05  # Type checker validates the field access\n",[15,7740,7741],{},"TypedDict is ideal for typing dictionaries that come from outside the system — JSON API responses, SQL query results, YAML configs — without converting them into objects. The runtime overhead is zero; it is purely a static construct.",[15,7743,7744,7745,7748],{},"The limitation: TypedDict validates nothing at runtime. If the API returns ",[19,7746,7747],{},"volume"," as a string, the code will fail further downstream rather than at deserialisation.",[24,7750,7752],{"id":7751},"rule-2-dataclasses-for-internal-models-without-validation","Rule 2: Dataclasses for Internal Models Without Validation",[32,7754,7756],{"className":2213,"code":7755,"language":2215,"meta":37,"style":37},"from dataclasses import dataclass, field\nfrom datetime import datetime\n\n@dataclass\nclass CertificateAggregate:\n    account_id: str\n    total_volume: float\n    certificate_count: int\n    computed_at: datetime = field(default_factory=datetime.now)\n\n    def average_volume(self) -> float:\n        if self.certificate_count == 0:\n            return 0.0\n        return self.total_volume / self.certificate_count\n",[19,7757,7758,7763,7768,7772,7777,7782,7787,7792,7797,7802,7806,7811,7816,7821],{"__ignoreMap":37},[41,7759,7760],{"class":43,"line":44},[41,7761,7762],{},"from dataclasses import dataclass, field\n",[41,7764,7765],{"class":43,"line":51},[41,7766,7767],{},"from datetime import datetime\n",[41,7769,7770],{"class":43,"line":61},[41,7771,348],{"emptyLinePlaceholder":347},[41,7773,7774],{"class":43,"line":77},[41,7775,7776],{},"@dataclass\n",[41,7778,7779],{"class":43,"line":90},[41,7780,7781],{},"class CertificateAggregate:\n",[41,7783,7784],{"class":43,"line":101},[41,7785,7786],{},"    account_id: str\n",[41,7788,7789],{"class":43,"line":107},[41,7790,7791],{},"    total_volume: float\n",[41,7793,7794],{"class":43,"line":116},[41,7795,7796],{},"    certificate_count: int\n",[41,7798,7799],{"class":43,"line":122},[41,7800,7801],{},"    computed_at: datetime = field(default_factory=datetime.now)\n",[41,7803,7804],{"class":43,"line":135},[41,7805,348],{"emptyLinePlaceholder":347},[41,7807,7808],{"class":43,"line":148},[41,7809,7810],{},"    def average_volume(self) -> float:\n",[41,7812,7813],{"class":43,"line":161},[41,7814,7815],{},"        if self.certificate_count == 0:\n",[41,7817,7818],{"class":43,"line":192},[41,7819,7820],{},"            return 0.0\n",[41,7822,7823],{"class":43,"line":205},[41,7824,7825],{},"        return self.total_volume / self.certificate_count\n",[15,7827,7828],{},"Dataclasses are the right choice for objects you construct yourself within business logic — aggregation results, intermediate processing objects, domain value objects. They are lighter than Pydantic and more explicit than raw dictionaries.",[15,7830,7831,7834],{},[19,7832,7833],{},"@dataclass(frozen=True)"," makes them immutable — useful for value objects:",[32,7836,7838],{"className":2213,"code":7837,"language":2215,"meta":37,"style":37},"@dataclass(frozen=True)\nclass DateRange:\n    start: str\n    end: str\n\n    def __post_init__(self):\n        if self.start > self.end:\n            raise ValueError(f\"start ({self.start}) must be before end ({self.end})\")\n",[19,7839,7840,7845,7850,7855,7860,7864,7869,7874],{"__ignoreMap":37},[41,7841,7842],{"class":43,"line":44},[41,7843,7844],{},"@dataclass(frozen=True)\n",[41,7846,7847],{"class":43,"line":51},[41,7848,7849],{},"class DateRange:\n",[41,7851,7852],{"class":43,"line":61},[41,7853,7854],{},"    start: str\n",[41,7856,7857],{"class":43,"line":77},[41,7858,7859],{},"    end: str\n",[41,7861,7862],{"class":43,"line":90},[41,7863,348],{"emptyLinePlaceholder":347},[41,7865,7866],{"class":43,"line":101},[41,7867,7868],{},"    def __post_init__(self):\n",[41,7870,7871],{"class":43,"line":107},[41,7872,7873],{},"        if self.start > self.end:\n",[41,7875,7876],{"class":43,"line":116},[41,7877,7878],{},"            raise ValueError(f\"start ({self.start}) must be before end ({self.end})\")\n",[15,7880,7881,7884],{},[19,7882,7883],{},"__post_init__"," allows adding simple invariant validation without Pydantic — sufficient for most domain-level constraints.",[24,7886,7888],{"id":7887},"rule-3-pydantic-for-everything-that-touches-system-boundaries","Rule 3: Pydantic for Everything That Touches System Boundaries",[32,7890,7892],{"className":2213,"code":7891,"language":2215,"meta":37,"style":37},"from pydantic import BaseModel, Field, field_validator\nfrom typing import Literal\n\nclass CertificateRequest(BaseModel):\n    account_id: str = Field(min_length=3, max_length=50)\n    volume: float = Field(gt=0, description=\"Volume in MWh\")\n    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n    period_from: str\n    period_to: str\n\n    @field_validator(\"period_to\")\n    @classmethod\n    def period_to_after_from(cls, v: str, info) -> str:\n        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n            raise ValueError(\"period_to must be after period_from\")\n        return v\n\nclass CertificateResponse(BaseModel):\n    id: str\n    volume: float\n    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n\n    model_config = {\"from_attributes\": True}  # ORM compatibility\n",[19,7893,7894,7899,7904,7908,7913,7918,7923,7928,7932,7936,7940,7945,7950,7955,7960,7965,7970,7974,7979,7983,7987,7992,7996],{"__ignoreMap":37},[41,7895,7896],{"class":43,"line":44},[41,7897,7898],{},"from pydantic import BaseModel, Field, field_validator\n",[41,7900,7901],{"class":43,"line":51},[41,7902,7903],{},"from typing import Literal\n",[41,7905,7906],{"class":43,"line":61},[41,7907,348],{"emptyLinePlaceholder":347},[41,7909,7910],{"class":43,"line":77},[41,7911,7912],{},"class CertificateRequest(BaseModel):\n",[41,7914,7915],{"class":43,"line":90},[41,7916,7917],{},"    account_id: str = Field(min_length=3, max_length=50)\n",[41,7919,7920],{"class":43,"line":101},[41,7921,7922],{},"    volume: float = Field(gt=0, description=\"Volume in MWh\")\n",[41,7924,7925],{"class":43,"line":107},[41,7926,7927],{},"    technology: Literal[\"WIND\", \"SOLAR\", \"HYDRO\", \"BIOMASS\"]\n",[41,7929,7930],{"class":43,"line":116},[41,7931,7709],{},[41,7933,7934],{"class":43,"line":122},[41,7935,7714],{},[41,7937,7938],{"class":43,"line":135},[41,7939,348],{"emptyLinePlaceholder":347},[41,7941,7942],{"class":43,"line":148},[41,7943,7944],{},"    @field_validator(\"period_to\")\n",[41,7946,7947],{"class":43,"line":161},[41,7948,7949],{},"    @classmethod\n",[41,7951,7952],{"class":43,"line":192},[41,7953,7954],{},"    def period_to_after_from(cls, v: str, info) -> str:\n",[41,7956,7957],{"class":43,"line":205},[41,7958,7959],{},"        if \"period_from\" in info.data and v \u003C= info.data[\"period_from\"]:\n",[41,7961,7962],{"class":43,"line":213},[41,7963,7964],{},"            raise ValueError(\"period_to must be after period_from\")\n",[41,7966,7967],{"class":43,"line":226},[41,7968,7969],{},"        return v\n",[41,7971,7972],{"class":43,"line":239},[41,7973,348],{"emptyLinePlaceholder":347},[41,7975,7976],{"class":43,"line":250},[41,7977,7978],{},"class CertificateResponse(BaseModel):\n",[41,7980,7981],{"class":43,"line":256},[41,7982,7699],{},[41,7984,7985],{"class":43,"line":262},[41,7986,7704],{},[41,7988,7989],{"class":43,"line":268},[41,7990,7991],{},"    status: Literal[\"ACTIVE\", \"CANCELLED\", \"TRANSFERRED\"]\n",[41,7993,7994],{"class":43,"line":276},[41,7995,348],{"emptyLinePlaceholder":347},[41,7997,7998],{"class":43,"line":289},[41,7999,8000],{},"    model_config = {\"from_attributes\": True}  # ORM compatibility\n",[15,8002,8003],{},"Pydantic wins at every system boundary: HTTP inputs (request bodies, query parameters), JSON responses, configuration files, environment variables.",[32,8005,8007],{"className":2213,"code":8006,"language":2215,"meta":37,"style":37},"from pydantic_settings import BaseSettings\n\nclass Settings(BaseSettings):\n    database_url: str\n    redis_url: str\n    secret_key: str\n    debug: bool = False\n\n    model_config = {\"env_file\": \".env\"}\n\nsettings = Settings()  # Raises a clear error if DATABASE_URL is missing\n",[19,8008,8009,8014,8018,8023,8028,8033,8038,8043,8047,8052,8056],{"__ignoreMap":37},[41,8010,8011],{"class":43,"line":44},[41,8012,8013],{},"from pydantic_settings import BaseSettings\n",[41,8015,8016],{"class":43,"line":51},[41,8017,348],{"emptyLinePlaceholder":347},[41,8019,8020],{"class":43,"line":61},[41,8021,8022],{},"class Settings(BaseSettings):\n",[41,8024,8025],{"class":43,"line":77},[41,8026,8027],{},"    database_url: str\n",[41,8029,8030],{"class":43,"line":90},[41,8031,8032],{},"    redis_url: str\n",[41,8034,8035],{"class":43,"line":101},[41,8036,8037],{},"    secret_key: str\n",[41,8039,8040],{"class":43,"line":107},[41,8041,8042],{},"    debug: bool = False\n",[41,8044,8045],{"class":43,"line":116},[41,8046,348],{"emptyLinePlaceholder":347},[41,8048,8049],{"class":43,"line":122},[41,8050,8051],{},"    model_config = {\"env_file\": \".env\"}\n",[41,8053,8054],{"class":43,"line":135},[41,8055,348],{"emptyLinePlaceholder":347},[41,8057,8058],{"class":43,"line":148},[41,8059,8060],{},"settings = Settings()  # Raises a clear error if DATABASE_URL is missing\n",[15,8062,8063,8066],{},[19,8064,8065],{},"pydantic-settings"," is particularly valuable — it reads environment variables, casts them to the correct types, and raises explicit errors at startup if a required variable is absent.",[24,8068,8070],{"id":8069},"performance-when-it-actually-matters","Performance: When It Actually Matters",[15,8072,8073],{},"Pydantic v2 (rewritten in Rust) is substantially faster than v1, but still slower than dataclasses for object construction:",[2877,8075,8076,8092],{},[2880,8077,8078],{},[2883,8079,8080,8083,8086,8089],{},[2886,8081,8082],{},"Tool",[2886,8084,8085],{},"Construction (relative)",[2886,8087,8088],{},"Validation",[2886,8090,8091],{},"JSON Serialisation",[2896,8093,8094,8107,8123],{},[2883,8095,8096,8098,8101,8104],{},[2901,8097,7639],{},[2901,8099,8100],{},"1x",[2901,8102,8103],{},"None",[2901,8105,8106],{},"Manual",[2883,8108,8109,8111,8114,8118],{},[2901,8110,7645],{},[2901,8112,8113],{},"1.2x",[2901,8115,8116],{},[19,8117,7883],{},[2901,8119,8120],{},[19,8121,8122],{},"dataclasses.asdict()",[2883,8124,8125,8128,8131,8134],{},[2901,8126,8127],{},"Pydantic v2",[2901,8129,8130],{},"3–5x",[2901,8132,8133],{},"Complete",[2901,8135,8136],{},[19,8137,8138],{},".model_dump_json()",[15,8140,8141],{},"On a FastAPI endpoint handling 1,000 requests per second with simple models, the difference between Pydantic and dataclasses is negligible. It becomes visible in data processing pipelines that instantiate millions of objects — ETL jobs, large file processing.",[15,8143,8144],{},"The practical rule: do not optimise prematurely. Pydantic at boundaries, dataclasses internally — this separation delivers good performance by default without micro-optimisation.",[24,8146,8148],{"id":8147},"combining-all-three","Combining All Three",[15,8150,8151],{},"In a real project, all three coexist naturally:",[32,8153,8155],{"className":2213,"code":8154,"language":2215,"meta":37,"style":37},"from typing import TypedDict\nfrom dataclasses import dataclass\nfrom pydantic import BaseModel\n\n# TypedDict: raw response from the external API\nclass RawApiResponse(TypedDict):\n    data: list[dict]\n    meta: dict\n\n# Pydantic: validates and parses the response at the boundary\nclass Certificate(BaseModel):\n    id: str\n    volume: float\n    status: str\n\n# Dataclass: internal business object after processing\n@dataclass\nclass CertificateReport:\n    total_volume: float\n    active_count: int\n    cancelled_count: int\n\ndef process_response(raw: RawApiResponse) -> CertificateReport:\n    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n    active = [c for c in certificates if c.status == \"ACTIVE\"]\n    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n    return CertificateReport(\n        total_volume=sum(c.volume for c in certificates),\n        active_count=len(active),\n        cancelled_count=len(cancelled),\n    )\n",[19,8156,8157,8161,8166,8171,8175,8180,8185,8190,8195,8199,8204,8209,8213,8217,8221,8225,8230,8234,8239,8243,8248,8253,8257,8262,8267,8272,8277,8282,8287,8292,8297],{"__ignoreMap":37},[41,8158,8159],{"class":43,"line":44},[41,8160,7680],{},[41,8162,8163],{"class":43,"line":51},[41,8164,8165],{},"from dataclasses import dataclass\n",[41,8167,8168],{"class":43,"line":61},[41,8169,8170],{},"from pydantic import BaseModel\n",[41,8172,8173],{"class":43,"line":77},[41,8174,348],{"emptyLinePlaceholder":347},[41,8176,8177],{"class":43,"line":90},[41,8178,8179],{},"# TypedDict: raw response from the external API\n",[41,8181,8182],{"class":43,"line":101},[41,8183,8184],{},"class RawApiResponse(TypedDict):\n",[41,8186,8187],{"class":43,"line":107},[41,8188,8189],{},"    data: list[dict]\n",[41,8191,8192],{"class":43,"line":116},[41,8193,8194],{},"    meta: dict\n",[41,8196,8197],{"class":43,"line":122},[41,8198,348],{"emptyLinePlaceholder":347},[41,8200,8201],{"class":43,"line":135},[41,8202,8203],{},"# Pydantic: validates and parses the response at the boundary\n",[41,8205,8206],{"class":43,"line":148},[41,8207,8208],{},"class Certificate(BaseModel):\n",[41,8210,8211],{"class":43,"line":161},[41,8212,7699],{},[41,8214,8215],{"class":43,"line":192},[41,8216,7704],{},[41,8218,8219],{"class":43,"line":205},[41,8220,7719],{},[41,8222,8223],{"class":43,"line":213},[41,8224,348],{"emptyLinePlaceholder":347},[41,8226,8227],{"class":43,"line":226},[41,8228,8229],{},"# Dataclass: internal business object after processing\n",[41,8231,8232],{"class":43,"line":239},[41,8233,7776],{},[41,8235,8236],{"class":43,"line":250},[41,8237,8238],{},"class CertificateReport:\n",[41,8240,8241],{"class":43,"line":256},[41,8242,7791],{},[41,8244,8245],{"class":43,"line":262},[41,8246,8247],{},"    active_count: int\n",[41,8249,8250],{"class":43,"line":268},[41,8251,8252],{},"    cancelled_count: int\n",[41,8254,8255],{"class":43,"line":276},[41,8256,348],{"emptyLinePlaceholder":347},[41,8258,8259],{"class":43,"line":289},[41,8260,8261],{},"def process_response(raw: RawApiResponse) -> CertificateReport:\n",[41,8263,8264],{"class":43,"line":302},[41,8265,8266],{},"    certificates = [Certificate.model_validate(item) for item in raw[\"data\"]]\n",[41,8268,8269],{"class":43,"line":313},[41,8270,8271],{},"    active = [c for c in certificates if c.status == \"ACTIVE\"]\n",[41,8273,8274],{"class":43,"line":319},[41,8275,8276],{},"    cancelled = [c for c in certificates if c.status == \"CANCELLED\"]\n",[41,8278,8279],{"class":43,"line":757},[41,8280,8281],{},"    return CertificateReport(\n",[41,8283,8284],{"class":43,"line":762},[41,8285,8286],{},"        total_volume=sum(c.volume for c in certificates),\n",[41,8288,8289],{"class":43,"line":774},[41,8290,8291],{},"        active_count=len(active),\n",[41,8293,8294],{"class":43,"line":785},[41,8295,8296],{},"        cancelled_count=len(cancelled),\n",[41,8298,8299],{"class":43,"line":798},[41,8300,1964],{},[15,8302,8303],{},"Each tool in its place: TypedDict for raw external data, Pydantic for boundary validation, dataclass for internal logic. This separation is what keeps the code readable and maintainable over the long term.",[2097,8305,2961],{},{"title":37,"searchDepth":51,"depth":51,"links":8307},[8308,8309,8310,8311,8312,8313],{"id":7630,"depth":51,"text":7631},{"id":7669,"depth":51,"text":7670},{"id":7751,"depth":51,"text":7752},{"id":7887,"depth":51,"text":7888},{"id":8069,"depth":51,"text":8070},{"id":8147,"depth":51,"text":8148},"2025-01-06",{},"/en/blog/dataclasses-pydantic-typeddict",{"title":7619,"description":7627},"dataclasses-pydantic-typeddict","en/blog/dataclasses-pydantic-typeddict",[4477,8321,8322,2980],"Pydantic","Typing","jLHOcPkFi8d7wu19zJKqlx0ZpaaqmasUdrgixQie4v0",{"id":8325,"title":8326,"body":8327,"date":8967,"description":8335,"excerpt":2112,"extension":2113,"meta":8968,"navigation":347,"path":8969,"readTime":107,"seo":8970,"slug":8971,"stem":8972,"tags":8973,"__hash__":8976},"en_blog/en/blog/nuxt4-introduction.md","Nuxt 4: What’s New and What Actually Changes for Developers",{"type":8,"value":8328,"toc":8956},[8329,8333,8336,8340,8343,8346,8354,8357,8363,8369,8375,8385,8399,8409,8412,8421,8504,8507,8511,8518,8627,8640,8644,8651,8751,8754,8758,8761,8859,8864,8935,8937,8943,8953],[11,8330,8332],{"id":8331},"nuxt-4-what-actually-changes","Nuxt 4: What Actually Changes",[15,8334,8335],{},"If you work with Vue.js and have not yet looked at Nuxt 4, now is a good time to do so. The release is not a ground-up rewrite, but its structural changes are substantial enough to warrant a thorough overview before embarking on a new project.",[24,8337,8339],{"id":8338},"what-nuxt-is-in-brief","What Nuxt Is, in Brief",[15,8341,8342],{},"Nuxt is a meta-framework built on top of Vue.js. It handles routing, server-side rendering (SSR), static site generation (SSG), data fetching, and a great deal more — all out of the box. The premise is straightforward: you write Vue components, Nuxt takes care of the infrastructure around them.",[15,8344,8345],{},"Version 3 introduced Vue 3's Composition API, the Nitro server engine, and an architecture centred on automatic imports. Nuxt 4 refines each of these pillars and introduces a handful of structural decisions that have a meaningful impact on how projects are organised and maintained.",[24,8347,8349,8350,8353],{"id":8348},"the-central-change-the-app-directory","The Central Change: the ",[19,8351,8352],{},"app/"," Directory",[15,8355,8356],{},"In Nuxt 3, a typical project is laid out as follows:",[32,8358,8361],{"className":8359,"code":8360,"language":2196},[2194],"├── components/\n├── composables/\n├── layouts/\n├── middleware/\n├── pages/\n├── plugins/\n├── server/\n├── nuxt.config.ts\n",[19,8362,8360],{"__ignoreMap":37},[15,8364,8365,8366,8368],{},"In Nuxt 4, all application code is consolidated under a dedicated ",[19,8367,8352],{}," directory:",[32,8370,8373],{"className":8371,"code":8372,"language":2196},[2194],"├── app/\n│   ├── components/\n│   ├── composables/\n│   ├── layouts/\n│   ├── middleware/\n│   ├── pages/\n│   ├── plugins/\n│   └── app.vue\n├── server/\n├── public/\n└── nuxt.config.ts\n",[19,8374,8372],{"__ignoreMap":37},[15,8376,8377,8378,8380,8381,8384],{},"This is not a cosmetic change. The explicit separation between application code (",[19,8379,8352],{},") and server-side logic (",[19,8382,8383],{},"server/",") enforces a cleaner boundary between concerns — one that becomes increasingly valuable as a codebase scales or as more contributors join a project.",[8386,8387,8388],"blockquote",{},[15,8389,8390,8391,8394,8395,8398],{},"This behaviour was already available in Nuxt 3.x via the ",[19,8392,8393],{},"future.compatibilityVersion: 4"," flag in ",[19,8396,8397],{},"nuxt.config.ts",". In Nuxt 4, it is the default.",[24,8400,8402,8403,1799,8406],{"id":8401},"data-fetching-useasyncdata-and-usefetch","Data Fetching: ",[19,8404,8405],{},"useAsyncData",[19,8407,8408],{},"useFetch",[15,8410,8411],{},"The data-fetching primitives themselves remain familiar, but Nuxt 4 tightens their behaviour around reactivity and cache key management.",[15,8413,8414,8415,8417,8418,8420],{},"In Nuxt 3, ",[19,8416,8405],{}," would occasionally fail to re-trigger when a reactive dependency changed — a subtle source of stale data that was difficult to diagnose. In Nuxt 4, internal ",[19,8419,6043],{}," handling is more consistent and predictable:",[32,8422,8426],{"className":8423,"code":8424,"language":8425,"meta":37,"style":37},"language-ts shiki shiki-themes github-dark github-light","// Automatically reactive to changes in `route.params.id`\nconst { data } = await useAsyncData(`product-${route.params.id}`, () =>\n  $fetch(`/api/products/${route.params.id}`),\n)\n","ts",[19,8427,8428,8433,8475,8500],{"__ignoreMap":37},[41,8429,8430],{"class":43,"line":44},[41,8431,8432],{"class":341},"// Automatically reactive to changes in `route.params.id`\n",[41,8434,8435,8437,8439,8441,8443,8445,8447,8450,8452,8455,8458,8460,8463,8465,8467,8469,8472],{"class":43,"line":51},[41,8436,1531],{"class":353},[41,8438,1237],{"class":47},[41,8440,1616],{"class":54},[41,8442,5750],{"class":47},[41,8444,1382],{"class":353},[41,8446,1412],{"class":353},[41,8448,8449],{"class":360}," useAsyncData",[41,8451,1321],{"class":47},[41,8453,8454],{"class":70},"`product-${",[41,8456,8457],{"class":47},"route",[41,8459,1625],{"class":70},[41,8461,8462],{"class":47},"params",[41,8464,1625],{"class":70},[41,8466,1745],{"class":47},[41,8468,3623],{"class":70},[41,8470,8471],{"class":47},", () ",[41,8473,8474],{"class":353},"=>\n",[41,8476,8477,8480,8482,8485,8487,8489,8491,8493,8495,8497],{"class":43,"line":61},[41,8478,8479],{"class":360},"  $fetch",[41,8481,1321],{"class":47},[41,8483,8484],{"class":70},"`/api/products/${",[41,8486,8457],{"class":47},[41,8488,1625],{"class":70},[41,8490,8462],{"class":47},[41,8492,1625],{"class":70},[41,8494,1745],{"class":47},[41,8496,3623],{"class":70},[41,8498,8499],{"class":47},"),\n",[41,8501,8502],{"class":43,"line":77},[41,8503,1327],{"class":47},[15,8505,8506],{},"The governing principle is straightforward: cache keys must be unique and must reflect every dynamic parameter they depend on. A static key paired with dynamic data will produce cache collisions — this was equally true in Nuxt 3, but Nuxt 4 surfaces the problem more clearly, which encourages better habits from the outset.",[24,8508,8510],{"id":8509},"nitro-and-server-routes","Nitro and Server Routes",[15,8512,8513,8514,8517],{},"Nuxt 4 continues to ship Nitro as its server runtime. API routes are defined declaratively in ",[19,8515,8516],{},"server/api/",", with file naming encoding both the path and the HTTP method:",[32,8519,8521],{"className":8423,"code":8520,"language":8425,"meta":37,"style":37},"// server/api/products/[id].get.ts\nexport default defineEventHandler(async (event) => {\n  const id = getRouterParam(event, \"id\")\n  const product = await db.products.findById(id)\n  if (!product) throw createError({ statusCode: 404 })\n  return product\n})\n",[19,8522,8523,8528,8552,8571,8591,8616,8623],{"__ignoreMap":37},[41,8524,8525],{"class":43,"line":44},[41,8526,8527],{"class":341},"// server/api/products/[id].get.ts\n",[41,8529,8530,8532,8535,8538,8540,8542,8544,8546,8548,8550],{"class":43,"line":51},[41,8531,354],{"class":353},[41,8533,8534],{"class":353}," default",[41,8536,8537],{"class":360}," defineEventHandler",[41,8539,1321],{"class":47},[41,8541,5968],{"class":353},[41,8543,2010],{"class":47},[41,8545,3644],{"class":487},[41,8547,1292],{"class":47},[41,8549,1172],{"class":353},[41,8551,482],{"class":47},[41,8553,8554,8556,8559,8561,8564,8567,8569],{"class":43,"line":61},[41,8555,1267],{"class":353},[41,8557,8558],{"class":54}," id",[41,8560,364],{"class":353},[41,8562,8563],{"class":360}," getRouterParam",[41,8565,8566],{"class":47},"(event, ",[41,8568,915],{"class":70},[41,8570,1327],{"class":47},[41,8572,8573,8575,8578,8580,8582,8585,8588],{"class":43,"line":77},[41,8574,1267],{"class":353},[41,8576,8577],{"class":54}," product",[41,8579,364],{"class":353},[41,8581,1412],{"class":353},[41,8583,8584],{"class":47}," db.products.",[41,8586,8587],{"class":360},"findById",[41,8589,8590],{"class":47},"(id)\n",[41,8592,8593,8595,8597,8599,8602,8605,8608,8611,8614],{"class":43,"line":90},[41,8594,1492],{"class":353},[41,8596,2010],{"class":47},[41,8598,2013],{"class":353},[41,8600,8601],{"class":47},"product) ",[41,8603,8604],{"class":353},"throw",[41,8606,8607],{"class":360}," createError",[41,8609,8610],{"class":47},"({ statusCode: ",[41,8612,8613],{"class":54},"404",[41,8615,5887],{"class":47},[41,8617,8618,8620],{"class":43,"line":101},[41,8619,1510],{"class":353},[41,8621,8622],{"class":47}," product\n",[41,8624,8625],{"class":43,"line":107},[41,8626,6209],{"class":47},[15,8628,8629,8630,178,8633,7653,8636,8639],{},"Nitro compiles these handlers into a self-contained, portable bundle that can be deployed across a range of targets: Node.js, edge runtimes such as Cloudflare Workers or Vercel Edge Functions, or as a fully static build. In Nuxt 4, Nitro's increased maturity is tangible — type inference is sharper, and utility helpers such as ",[19,8631,8632],{},"getRouterParam",[19,8634,8635],{},"readBody",[19,8637,8638],{},"getCookie"," behave more reliably across deployment targets.",[24,8641,8643],{"id":8642},"auto-imported-composables","Auto-Imported Composables",[15,8645,8646,8647,8650],{},"Any composable placed in ",[19,8648,8649],{},"app/composables/"," is automatically available throughout the application without an explicit import statement:",[32,8652,8654],{"className":8423,"code":8653,"language":8425,"meta":37,"style":37},"// app/composables/useApi.ts\nexport const useApi = () => {\n  const config = useRuntimeConfig()\n  return $fetch.create({ baseURL: config.public.apiBase })\n}\n\n// Available directly in any component or page:\nconst api = useApi()\nconst data = await api(\"/products\")\n",[19,8655,8656,8661,8679,8693,8706,8710,8714,8719,8732],{"__ignoreMap":37},[41,8657,8658],{"class":43,"line":44},[41,8659,8660],{"class":341},"// app/composables/useApi.ts\n",[41,8662,8663,8665,8668,8671,8673,8675,8677],{"class":43,"line":51},[41,8664,354],{"class":353},[41,8666,8667],{"class":353}," const",[41,8669,8670],{"class":360}," useApi",[41,8672,364],{"class":353},[41,8674,1169],{"class":47},[41,8676,1172],{"class":353},[41,8678,482],{"class":47},[41,8680,8681,8683,8686,8688,8691],{"class":43,"line":61},[41,8682,1267],{"class":353},[41,8684,8685],{"class":54}," config",[41,8687,364],{"class":353},[41,8689,8690],{"class":360}," useRuntimeConfig",[41,8692,1418],{"class":47},[41,8694,8695,8697,8700,8703],{"class":43,"line":77},[41,8696,1510],{"class":353},[41,8698,8699],{"class":47}," $fetch.",[41,8701,8702],{"class":360},"create",[41,8704,8705],{"class":47},"({ baseURL: config.public.apiBase })\n",[41,8707,8708],{"class":43,"line":90},[41,8709,322],{"class":47},[41,8711,8712],{"class":43,"line":101},[41,8713,348],{"emptyLinePlaceholder":347},[41,8715,8716],{"class":43,"line":107},[41,8717,8718],{"class":341},"// Available directly in any component or page:\n",[41,8720,8721,8723,8726,8728,8730],{"class":43,"line":116},[41,8722,1531],{"class":353},[41,8724,8725],{"class":54}," api",[41,8727,364],{"class":353},[41,8729,8670],{"class":360},[41,8731,1418],{"class":47},[41,8733,8734,8736,8738,8740,8742,8744,8746,8749],{"class":43,"line":122},[41,8735,1531],{"class":353},[41,8737,1270],{"class":54},[41,8739,364],{"class":353},[41,8741,1412],{"class":353},[41,8743,8725],{"class":360},[41,8745,1321],{"class":47},[41,8747,8748],{"class":70},"\"/products\"",[41,8750,1327],{"class":47},[15,8752,8753],{},"This is a genuine productivity gain, though it comes with a caveat: on larger projects, liberal use of auto-imports can obscure where functionality originates, making code harder to navigate for developers unfamiliar with the codebase. A practical convention — one composable per file, named with precision — goes a long way toward mitigating this.",[24,8755,8757],{"id":8756},"nuxt-content-v3","Nuxt Content v3",[15,8759,8760],{},"For projects that use Nuxt as a content platform — blogs, documentation sites, knowledge bases — Nuxt Content v3 is fully compatible with Nuxt 4 and represents a significant step forward. MDC parsing (Markdown enriched with inline Vue components) is noticeably faster, and the content query API is now fully typed:",[32,8762,8764],{"className":8423,"code":8763,"language":8425,"meta":37,"style":37},"const { data } = await useAsyncData(\"articles\", () =>\n  queryCollection(\"blog\")\n    .where(\"published\", \"=\", true)\n    .order(\"date\", \"DESC\")\n    .all(),\n)\n",[19,8765,8766,8791,8803,8827,8846,8855],{"__ignoreMap":37},[41,8767,8768,8770,8772,8774,8776,8778,8780,8782,8784,8787,8789],{"class":43,"line":44},[41,8769,1531],{"class":353},[41,8771,1237],{"class":47},[41,8773,1616],{"class":54},[41,8775,5750],{"class":47},[41,8777,1382],{"class":353},[41,8779,1412],{"class":353},[41,8781,8449],{"class":360},[41,8783,1321],{"class":47},[41,8785,8786],{"class":70},"\"articles\"",[41,8788,8471],{"class":47},[41,8790,8474],{"class":353},[41,8792,8793,8796,8798,8801],{"class":43,"line":51},[41,8794,8795],{"class":360},"  queryCollection",[41,8797,1321],{"class":47},[41,8799,8800],{"class":70},"\"blog\"",[41,8802,1327],{"class":47},[41,8804,8805,8808,8811,8813,8816,8818,8821,8823,8825],{"class":43,"line":61},[41,8806,8807],{"class":47},"    .",[41,8809,8810],{"class":360},"where",[41,8812,1321],{"class":47},[41,8814,8815],{"class":70},"\"published\"",[41,8817,178],{"class":47},[41,8819,8820],{"class":70},"\"=\"",[41,8822,178],{"class":47},[41,8824,1604],{"class":54},[41,8826,1327],{"class":47},[41,8828,8829,8831,8834,8836,8839,8841,8844],{"class":43,"line":77},[41,8830,8807],{"class":47},[41,8832,8833],{"class":360},"order",[41,8835,1321],{"class":47},[41,8837,8838],{"class":70},"\"date\"",[41,8840,178],{"class":47},[41,8842,8843],{"class":70},"\"DESC\"",[41,8845,1327],{"class":47},[41,8847,8848,8850,8853],{"class":43,"line":90},[41,8849,8807],{"class":47},[41,8851,8852],{"class":360},"all",[41,8854,5770],{"class":47},[41,8856,8857],{"class":43,"line":101},[41,8858,1327],{"class":47},[15,8860,8861,8862,491],{},"Configuration is handled in ",[19,8863,8397],{},[32,8865,8867],{"className":8423,"code":8866,"language":8425,"meta":37,"style":37},"export default defineNuxtConfig({\n  modules: [\"@nuxt/content\"],\n  content: {\n    build: {\n      markdown: {\n        highlight: { theme: \"github-dark\" },\n      },\n    },\n  },\n})\n",[19,8868,8869,8881,8892,8897,8902,8907,8917,8922,8927,8931],{"__ignoreMap":37},[41,8870,8871,8873,8875,8878],{"class":43,"line":44},[41,8872,354],{"class":353},[41,8874,8534],{"class":353},[41,8876,8877],{"class":360}," defineNuxtConfig",[41,8879,8880],{"class":47},"({\n",[41,8882,8883,8886,8889],{"class":43,"line":51},[41,8884,8885],{"class":47},"  modules: [",[41,8887,8888],{"class":70},"\"@nuxt/content\"",[41,8890,8891],{"class":47},"],\n",[41,8893,8894],{"class":43,"line":61},[41,8895,8896],{"class":47},"  content: {\n",[41,8898,8899],{"class":43,"line":77},[41,8900,8901],{"class":47},"    build: {\n",[41,8903,8904],{"class":43,"line":90},[41,8905,8906],{"class":47},"      markdown: {\n",[41,8908,8909,8912,8915],{"class":43,"line":101},[41,8910,8911],{"class":47},"        highlight: { theme: ",[41,8913,8914],{"class":70},"\"github-dark\"",[41,8916,189],{"class":47},[41,8918,8919],{"class":43,"line":107},[41,8920,8921],{"class":47},"      },\n",[41,8923,8924],{"class":43,"line":116},[41,8925,8926],{"class":47},"    },\n",[41,8928,8929],{"class":43,"line":122},[41,8930,104],{"class":47},[41,8932,8933],{"class":43,"line":135},[41,8934,6209],{"class":47},[24,8936,4440],{"id":4439},[15,8938,8939,8940,8942],{},"Nuxt 4 is best understood as a consolidation release: it sharpens what Nuxt 3 introduced rather than replacing it. The ",[19,8941,8352],{}," directory enforces better project organisation, reactivity in data fetching is more dependable, and Nitro has matured into a genuinely robust server runtime. For any new Vue.js project requiring SSR, it is the most defensible starting point available today.",[15,8944,8945,8946,8949,8950,8952],{},"Migration from Nuxt 3 is designed to be incremental. The ",[19,8947,8948],{},"compatibilityVersion: 4"," flag allows teams to adopt new behaviours selectively, without a disruptive big-bang migration. In practice — on my own portfolio project — the transition amounted to an afternoon of work, the bulk of which involved reorganising files into the ",[19,8951,8352],{}," directory and updating a handful of imports.",[2097,8954,8955],{},"html pre.shiki code .sryI4, html code.shiki .sryI4{--shiki-dark:#6A737D;--shiki-default:#6A737D}html pre.shiki code .scx8i, html code.shiki .scx8i{--shiki-dark:#F97583;--shiki-default:#D73A49}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .s0DvM, html code.shiki .s0DvM{--shiki-dark:#79B8FF;--shiki-default:#005CC5}html pre.shiki code .s-Z4r, html code.shiki .s-Z4r{--shiki-dark:#B392F0;--shiki-default:#6F42C1}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sFbx2, html code.shiki .sFbx2{--shiki-dark:#FFAB70;--shiki-default:#E36209}",{"title":37,"searchDepth":51,"depth":51,"links":8957},[8958,8959,8961,8963,8964,8965,8966],{"id":8338,"depth":51,"text":8339},{"id":8348,"depth":51,"text":8960},"The Central Change: the app/ Directory",{"id":8401,"depth":51,"text":8962},"Data Fetching: useAsyncData and useFetch",{"id":8509,"depth":51,"text":8510},{"id":8642,"depth":51,"text":8643},{"id":8756,"depth":51,"text":8757},{"id":4439,"depth":51,"text":4440},"2024-12-10",{},"/en/blog/nuxt4-introduction",{"title":8326,"description":8335},"nuxt4-introduction","en/blog/nuxt4-introduction",[8974,2121,2123,8975],"Nuxt","SSR","mI6rI4U7TeWsdZb2s83lb0AwKxK0rh38SJru2ItSaM8",{"id":8978,"title":8979,"body":8980,"date":9829,"description":9830,"excerpt":2112,"extension":2113,"meta":9831,"navigation":347,"path":9832,"readTime":122,"seo":9833,"slug":9834,"stem":9835,"tags":9836,"__hash__":9839},"en_blog/en/blog/asyncio-production.md","Asyncio in Production: The Pitfalls Tutorials Never Cover",{"type":8,"value":8981,"toc":9818},[8982,8986,8993,9001,9008,9093,9102,9109,9167,9171,9181,9248,9258,9262,9269,9299,9306,9309,9354,9360,9364,9367,9461,9471,9475,9478,9555,9571,9575,9578,9694,9701,9705,9708,9766,9769,9796,9799,9801,9816],[11,8983,8985],{"id":8984},"asyncio-in-production-the-pitfalls-tutorials-never-cover","asyncio in Production: The Pitfalls Tutorials Never Cover",[15,8987,8988,8989,8992],{},"asyncio tutorials almost invariably stop at the same point: ",[19,8990,8991],{},"await asyncio.gather(task1(), task2())",", a handful of coroutine examples, and nothing further. Production is more demanding. Silent exceptions, tasks that never complete, shutdowns that hang — here are the real problems and how to address them.",[24,8994,8996,8997,9000],{"id":8995},"the-problem-with-asynciogather-and-exceptions","The Problem with ",[19,8998,8999],{},"asyncio.gather"," and Exceptions",[15,9002,9003,9004,9007],{},"The default behaviour of ",[19,9005,9006],{},"gather"," is counterintuitive:",[32,9009,9011],{"className":2213,"code":9010,"language":2215,"meta":37,"style":37},"import asyncio\n\nasync def task_ok():\n    await asyncio.sleep(1)\n    return \"ok\"\n\nasync def task_fail():\n    await asyncio.sleep(0.5)\n    raise ValueError(\"something went wrong\")\n\nasync def main():\n    results = await asyncio.gather(task_ok(), task_fail())\n    print(results)  # Never reached\n\nasyncio.run(main())\n# ValueError: something went wrong\n# task_ok() was silently cancelled\n",[19,9012,9013,9017,9021,9026,9031,9036,9040,9045,9050,9055,9059,9064,9069,9074,9078,9083,9088],{"__ignoreMap":37},[41,9014,9015],{"class":43,"line":44},[41,9016,3060],{},[41,9018,9019],{"class":43,"line":51},[41,9020,348],{"emptyLinePlaceholder":347},[41,9022,9023],{"class":43,"line":61},[41,9024,9025],{},"async def task_ok():\n",[41,9027,9028],{"class":43,"line":77},[41,9029,9030],{},"    await asyncio.sleep(1)\n",[41,9032,9033],{"class":43,"line":90},[41,9034,9035],{},"    return \"ok\"\n",[41,9037,9038],{"class":43,"line":101},[41,9039,348],{"emptyLinePlaceholder":347},[41,9041,9042],{"class":43,"line":107},[41,9043,9044],{},"async def task_fail():\n",[41,9046,9047],{"class":43,"line":116},[41,9048,9049],{},"    await asyncio.sleep(0.5)\n",[41,9051,9052],{"class":43,"line":122},[41,9053,9054],{},"    raise ValueError(\"something went wrong\")\n",[41,9056,9057],{"class":43,"line":135},[41,9058,348],{"emptyLinePlaceholder":347},[41,9060,9061],{"class":43,"line":148},[41,9062,9063],{},"async def main():\n",[41,9065,9066],{"class":43,"line":161},[41,9067,9068],{},"    results = await asyncio.gather(task_ok(), task_fail())\n",[41,9070,9071],{"class":43,"line":192},[41,9072,9073],{},"    print(results)  # Never reached\n",[41,9075,9076],{"class":43,"line":205},[41,9077,348],{"emptyLinePlaceholder":347},[41,9079,9080],{"class":43,"line":213},[41,9081,9082],{},"asyncio.run(main())\n",[41,9084,9085],{"class":43,"line":226},[41,9086,9087],{},"# ValueError: something went wrong\n",[41,9089,9090],{"class":43,"line":239},[41,9091,9092],{},"# task_ok() was silently cancelled\n",[15,9094,9095,9097,9098,9101],{},[19,9096,9006],{}," raises the first exception and cancels the remaining tasks without any warning. If ",[19,9099,9100],{},"task_ok()"," was writing to a database, the result is partially committed.",[15,9103,9104,9105,9108],{},"The solution: ",[19,9106,9107],{},"return_exceptions=True"," collects all exceptions without interrupting sibling tasks:",[32,9110,9112],{"className":2213,"code":9111,"language":2215,"meta":37,"style":37},"async def main():\n    results = await asyncio.gather(\n        task_ok(),\n        task_fail(),\n        return_exceptions=True\n    )\n    for result in results:\n        if isinstance(result, Exception):\n            logger.error(f\"Task failed: {result}\")\n        else:\n            logger.info(f\"Result: {result}\")\n",[19,9113,9114,9118,9123,9128,9133,9138,9142,9147,9152,9157,9162],{"__ignoreMap":37},[41,9115,9116],{"class":43,"line":44},[41,9117,9063],{},[41,9119,9120],{"class":43,"line":51},[41,9121,9122],{},"    results = await asyncio.gather(\n",[41,9124,9125],{"class":43,"line":61},[41,9126,9127],{},"        task_ok(),\n",[41,9129,9130],{"class":43,"line":77},[41,9131,9132],{},"        task_fail(),\n",[41,9134,9135],{"class":43,"line":90},[41,9136,9137],{},"        return_exceptions=True\n",[41,9139,9140],{"class":43,"line":101},[41,9141,1964],{},[41,9143,9144],{"class":43,"line":107},[41,9145,9146],{},"    for result in results:\n",[41,9148,9149],{"class":43,"line":116},[41,9150,9151],{},"        if isinstance(result, Exception):\n",[41,9153,9154],{"class":43,"line":122},[41,9155,9156],{},"            logger.error(f\"Task failed: {result}\")\n",[41,9158,9159],{"class":43,"line":135},[41,9160,9161],{},"        else:\n",[41,9163,9164],{"class":43,"line":148},[41,9165,9166],{},"            logger.info(f\"Result: {result}\")\n",[24,9168,9170],{"id":9169},"taskgroup-the-better-api-since-python-311","TaskGroup: The Better API Since Python 3.11",[15,9172,9173,9174,9177,9178,9180],{},"Python 3.11 introduced ",[19,9175,9176],{},"asyncio.TaskGroup",", which corrects ",[19,9179,9006],{},"'s behaviour in a structured way:",[32,9182,9184],{"className":2213,"code":9183,"language":2215,"meta":37,"style":37},"async def main():\n    results = []\n    try:\n        async with asyncio.TaskGroup() as tg:\n            t1 = tg.create_task(task_ok())\n            t2 = tg.create_task(task_fail())\n    except* ValueError as eg:\n        for exc in eg.exceptions:\n            logger.error(f\"Error: {exc}\")\n\n    # t1 and t2 are guaranteed to be complete here\n    if not t1.cancelled():\n        results.append(t1.result())\n",[19,9185,9186,9190,9195,9199,9204,9209,9214,9219,9224,9229,9233,9238,9243],{"__ignoreMap":37},[41,9187,9188],{"class":43,"line":44},[41,9189,9063],{},[41,9191,9192],{"class":43,"line":51},[41,9193,9194],{},"    results = []\n",[41,9196,9197],{"class":43,"line":61},[41,9198,3485],{},[41,9200,9201],{"class":43,"line":77},[41,9202,9203],{},"        async with asyncio.TaskGroup() as tg:\n",[41,9205,9206],{"class":43,"line":90},[41,9207,9208],{},"            t1 = tg.create_task(task_ok())\n",[41,9210,9211],{"class":43,"line":101},[41,9212,9213],{},"            t2 = tg.create_task(task_fail())\n",[41,9215,9216],{"class":43,"line":107},[41,9217,9218],{},"    except* ValueError as eg:\n",[41,9220,9221],{"class":43,"line":116},[41,9222,9223],{},"        for exc in eg.exceptions:\n",[41,9225,9226],{"class":43,"line":122},[41,9227,9228],{},"            logger.error(f\"Error: {exc}\")\n",[41,9230,9231],{"class":43,"line":135},[41,9232,348],{"emptyLinePlaceholder":347},[41,9234,9235],{"class":43,"line":148},[41,9236,9237],{},"    # t1 and t2 are guaranteed to be complete here\n",[41,9239,9240],{"class":43,"line":161},[41,9241,9242],{},"    if not t1.cancelled():\n",[41,9244,9245],{"class":43,"line":192},[41,9246,9247],{},"        results.append(t1.result())\n",[15,9249,9250,9253,9254,9257],{},[19,9251,9252],{},"TaskGroup"," uses the ",[19,9255,9256],{},"except*"," syntax (ExceptionGroup) — all exceptions are collected, and the group waits for every task to finish or be cancelled before propagating. No more phantom tasks.",[24,9259,9261],{"id":9260},"background-tasks-in-fastapi","Background Tasks in FastAPI",[15,9263,9264,9265,9268],{},"A common FastAPI pattern is launching background work with ",[19,9266,9267],{},"asyncio.create_task",". The classic pitfall:",[32,9270,9272],{"className":2213,"code":9271,"language":2215,"meta":37,"style":37},"# WRONG — the task can be silently garbage-collected\n@app.post(\"/process\")\nasync def process(data: dict):\n    asyncio.create_task(long_running_task(data))  # Reference lost\n    return {\"status\": \"started\"}\n",[19,9273,9274,9279,9284,9289,9294],{"__ignoreMap":37},[41,9275,9276],{"class":43,"line":44},[41,9277,9278],{},"# WRONG — the task can be silently garbage-collected\n",[41,9280,9281],{"class":43,"line":51},[41,9282,9283],{},"@app.post(\"/process\")\n",[41,9285,9286],{"class":43,"line":61},[41,9287,9288],{},"async def process(data: dict):\n",[41,9290,9291],{"class":43,"line":77},[41,9292,9293],{},"    asyncio.create_task(long_running_task(data))  # Reference lost\n",[41,9295,9296],{"class":43,"line":90},[41,9297,9298],{},"    return {\"status\": \"started\"}\n",[15,9300,9301,9302,9305],{},"asyncio does not hold a strong reference to tasks created with ",[19,9303,9304],{},"create_task",". If the garbage collector runs at the right moment, the task is silently cancelled.",[15,9307,9308],{},"The correct approach:",[32,9310,9312],{"className":2213,"code":9311,"language":2215,"meta":37,"style":37},"# In the application state\nbackground_tasks: set[asyncio.Task] = set()\n\n@app.post(\"/process\")\nasync def process(data: dict):\n    task = asyncio.create_task(long_running_task(data))\n    background_tasks.add(task)\n    task.add_done_callback(background_tasks.discard)  # Automatic cleanup\n    return {\"status\": \"started\"}\n",[19,9313,9314,9319,9324,9328,9332,9336,9341,9345,9350],{"__ignoreMap":37},[41,9315,9316],{"class":43,"line":44},[41,9317,9318],{},"# In the application state\n",[41,9320,9321],{"class":43,"line":51},[41,9322,9323],{},"background_tasks: set[asyncio.Task] = set()\n",[41,9325,9326],{"class":43,"line":61},[41,9327,348],{"emptyLinePlaceholder":347},[41,9329,9330],{"class":43,"line":77},[41,9331,9283],{},[41,9333,9334],{"class":43,"line":90},[41,9335,9288],{},[41,9337,9338],{"class":43,"line":101},[41,9339,9340],{},"    task = asyncio.create_task(long_running_task(data))\n",[41,9342,9343],{"class":43,"line":107},[41,9344,4401],{},[41,9346,9347],{"class":43,"line":116},[41,9348,9349],{},"    task.add_done_callback(background_tasks.discard)  # Automatic cleanup\n",[41,9351,9352],{"class":43,"line":122},[41,9353,9298],{},[15,9355,9356,9359],{},[19,9357,9358],{},"add_done_callback"," removes the task from the set when it completes — no memory leak, no silent cancellation.",[24,9361,9363],{"id":9362},"timeouts-on-network-operations","Timeouts on Network Operations",[15,9365,9366],{},"Without an explicit timeout, a network operation can suspend a coroutine indefinitely:",[32,9368,9370],{"className":2213,"code":9369,"language":2215,"meta":37,"style":37},"import asyncio\nfrom httpx import AsyncClient\n\n# WRONG — can block forever\nasync def fetch_data(url: str) -> dict:\n    async with AsyncClient() as client:\n        response = await client.get(url)\n        return response.json()\n\n# CORRECT — explicit timeout\nasync def fetch_data(url: str, timeout: float = 10.0) -> dict:\n    try:\n        async with asyncio.timeout(timeout):  # Python 3.11+\n            async with AsyncClient() as client:\n                response = await client.get(url)\n                return response.json()\n    except asyncio.TimeoutError:\n        logger.error(f\"Timed out after {timeout}s on {url}\")\n        raise\n",[19,9371,9372,9376,9380,9384,9389,9394,9398,9403,9408,9412,9417,9422,9426,9431,9436,9441,9446,9451,9456],{"__ignoreMap":37},[41,9373,9374],{"class":43,"line":44},[41,9375,3060],{},[41,9377,9378],{"class":43,"line":51},[41,9379,2448],{},[41,9381,9382],{"class":43,"line":61},[41,9383,348],{"emptyLinePlaceholder":347},[41,9385,9386],{"class":43,"line":77},[41,9387,9388],{},"# WRONG — can block forever\n",[41,9390,9391],{"class":43,"line":90},[41,9392,9393],{},"async def fetch_data(url: str) -> dict:\n",[41,9395,9396],{"class":43,"line":101},[41,9397,2506],{},[41,9399,9400],{"class":43,"line":107},[41,9401,9402],{},"        response = await client.get(url)\n",[41,9404,9405],{"class":43,"line":116},[41,9406,9407],{},"        return response.json()\n",[41,9409,9410],{"class":43,"line":122},[41,9411,348],{"emptyLinePlaceholder":347},[41,9413,9414],{"class":43,"line":135},[41,9415,9416],{},"# CORRECT — explicit timeout\n",[41,9418,9419],{"class":43,"line":148},[41,9420,9421],{},"async def fetch_data(url: str, timeout: float = 10.0) -> dict:\n",[41,9423,9424],{"class":43,"line":161},[41,9425,3485],{},[41,9427,9428],{"class":43,"line":192},[41,9429,9430],{},"        async with asyncio.timeout(timeout):  # Python 3.11+\n",[41,9432,9433],{"class":43,"line":205},[41,9434,9435],{},"            async with AsyncClient() as client:\n",[41,9437,9438],{"class":43,"line":213},[41,9439,9440],{},"                response = await client.get(url)\n",[41,9442,9443],{"class":43,"line":226},[41,9444,9445],{},"                return response.json()\n",[41,9447,9448],{"class":43,"line":239},[41,9449,9450],{},"    except asyncio.TimeoutError:\n",[41,9452,9453],{"class":43,"line":250},[41,9454,9455],{},"        logger.error(f\"Timed out after {timeout}s on {url}\")\n",[41,9457,9458],{"class":43,"line":256},[41,9459,9460],{},"        raise\n",[15,9462,9463,9466,9467,9470],{},[19,9464,9465],{},"asyncio.timeout()"," (Python 3.11+) is cleaner than ",[19,9468,9469],{},"asyncio.wait_for()"," for code blocks: it integrates naturally as a context manager and cleanly cancels all nested operations when the timeout fires.",[24,9472,9474],{"id":9473},"clean-shutdown-in-fastapi","Clean Shutdown in FastAPI",[15,9476,9477],{},"An underappreciated problem: when FastAPI receives SIGTERM — a Kubernetes pod termination, a redeployment — in-progress tasks must complete cleanly.",[32,9479,9481],{"className":2213,"code":9480,"language":2215,"meta":37,"style":37},"from contextlib import asynccontextmanager\nimport asyncio\n\nbackground_tasks: set[asyncio.Task] = set()\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    # Startup\n    yield\n    # Shutdown — wait for in-progress tasks\n    if background_tasks:\n        logger.info(f\"Waiting for {len(background_tasks)} task(s) to complete...\")\n        await asyncio.gather(*background_tasks, return_exceptions=True)\n        logger.info(\"All tasks completed.\")\n\napp = FastAPI(lifespan=lifespan)\n",[19,9482,9483,9488,9492,9496,9500,9504,9508,9512,9517,9521,9526,9531,9536,9541,9546,9550],{"__ignoreMap":37},[41,9484,9485],{"class":43,"line":44},[41,9486,9487],{},"from contextlib import asynccontextmanager\n",[41,9489,9490],{"class":43,"line":51},[41,9491,3060],{},[41,9493,9494],{"class":43,"line":61},[41,9495,348],{"emptyLinePlaceholder":347},[41,9497,9498],{"class":43,"line":77},[41,9499,9323],{},[41,9501,9502],{"class":43,"line":90},[41,9503,348],{"emptyLinePlaceholder":347},[41,9505,9506],{"class":43,"line":101},[41,9507,4386],{},[41,9509,9510],{"class":43,"line":107},[41,9511,4391],{},[41,9513,9514],{"class":43,"line":116},[41,9515,9516],{},"    # Startup\n",[41,9518,9519],{"class":43,"line":122},[41,9520,4415],{},[41,9522,9523],{"class":43,"line":135},[41,9524,9525],{},"    # Shutdown — wait for in-progress tasks\n",[41,9527,9528],{"class":43,"line":148},[41,9529,9530],{},"    if background_tasks:\n",[41,9532,9533],{"class":43,"line":161},[41,9534,9535],{},"        logger.info(f\"Waiting for {len(background_tasks)} task(s) to complete...\")\n",[41,9537,9538],{"class":43,"line":192},[41,9539,9540],{},"        await asyncio.gather(*background_tasks, return_exceptions=True)\n",[41,9542,9543],{"class":43,"line":205},[41,9544,9545],{},"        logger.info(\"All tasks completed.\")\n",[41,9547,9548],{"class":43,"line":213},[41,9549,348],{"emptyLinePlaceholder":347},[41,9551,9552],{"class":43,"line":226},[41,9553,9554],{},"app = FastAPI(lifespan=lifespan)\n",[15,9556,3353,9557,9559,9560,1799,9563,9566,9567,9570],{},[19,9558,4376],{}," context manager replaces the deprecated ",[19,9561,9562],{},"@app.on_event(\"startup\")",[19,9564,9565],{},"@app.on_event(\"shutdown\")"," hooks. The ",[19,9568,9569],{},"yield"," cleanly separates the startup and shutdown phases.",[24,9572,9574],{"id":9573},"avoiding-event-loop-blockage","Avoiding Event Loop Blockage",[15,9576,9577],{},"asyncio is single-threaded. A synchronous blocking call inside a coroutine blocks the entire application:",[32,9579,9581],{"className":2213,"code":9580,"language":2215,"meta":37,"style":37},"import asyncio\nfrom concurrent.futures import ThreadPoolExecutor\n\nexecutor = ThreadPoolExecutor(max_workers=4)\n\n# WRONG — blocks the event loop\nasync def process_file(path: str) -> str:\n    with open(path) as f:\n        return f.read()  # Synchronous I/O inside a coroutine\n\n# CORRECT — delegate to the thread pool\nasync def process_file(path: str) -> str:\n    loop = asyncio.get_event_loop()\n    return await loop.run_in_executor(\n        executor,\n        lambda: open(path).read()\n    )\n\n# Better still — aiofiles for file I/O\nimport aiofiles\n\nasync def process_file(path: str) -> str:\n    async with aiofiles.open(path) as f:\n        return await f.read()\n",[19,9582,9583,9587,9592,9596,9601,9605,9610,9615,9620,9625,9629,9634,9638,9643,9648,9653,9658,9662,9666,9671,9676,9680,9684,9689],{"__ignoreMap":37},[41,9584,9585],{"class":43,"line":44},[41,9586,3060],{},[41,9588,9589],{"class":43,"line":51},[41,9590,9591],{},"from concurrent.futures import ThreadPoolExecutor\n",[41,9593,9594],{"class":43,"line":61},[41,9595,348],{"emptyLinePlaceholder":347},[41,9597,9598],{"class":43,"line":77},[41,9599,9600],{},"executor = ThreadPoolExecutor(max_workers=4)\n",[41,9602,9603],{"class":43,"line":90},[41,9604,348],{"emptyLinePlaceholder":347},[41,9606,9607],{"class":43,"line":101},[41,9608,9609],{},"# WRONG — blocks the event loop\n",[41,9611,9612],{"class":43,"line":107},[41,9613,9614],{},"async def process_file(path: str) -> str:\n",[41,9616,9617],{"class":43,"line":116},[41,9618,9619],{},"    with open(path) as f:\n",[41,9621,9622],{"class":43,"line":122},[41,9623,9624],{},"        return f.read()  # Synchronous I/O inside a coroutine\n",[41,9626,9627],{"class":43,"line":135},[41,9628,348],{"emptyLinePlaceholder":347},[41,9630,9631],{"class":43,"line":148},[41,9632,9633],{},"# CORRECT — delegate to the thread pool\n",[41,9635,9636],{"class":43,"line":161},[41,9637,9614],{},[41,9639,9640],{"class":43,"line":192},[41,9641,9642],{},"    loop = asyncio.get_event_loop()\n",[41,9644,9645],{"class":43,"line":205},[41,9646,9647],{},"    return await loop.run_in_executor(\n",[41,9649,9650],{"class":43,"line":213},[41,9651,9652],{},"        executor,\n",[41,9654,9655],{"class":43,"line":226},[41,9656,9657],{},"        lambda: open(path).read()\n",[41,9659,9660],{"class":43,"line":239},[41,9661,1964],{},[41,9663,9664],{"class":43,"line":250},[41,9665,348],{"emptyLinePlaceholder":347},[41,9667,9668],{"class":43,"line":256},[41,9669,9670],{},"# Better still — aiofiles for file I/O\n",[41,9672,9673],{"class":43,"line":262},[41,9674,9675],{},"import aiofiles\n",[41,9677,9678],{"class":43,"line":268},[41,9679,348],{"emptyLinePlaceholder":347},[41,9681,9682],{"class":43,"line":276},[41,9683,9614],{},[41,9685,9686],{"class":43,"line":289},[41,9687,9688],{},"    async with aiofiles.open(path) as f:\n",[41,9690,9691],{"class":43,"line":302},[41,9692,9693],{},"        return await f.read()\n",[15,9695,9696,9697,9700],{},"The rule: any operation that takes more than a few milliseconds and is not natively async must be offloaded via ",[19,9698,9699],{},"run_in_executor"," or a dedicated async library.",[24,9702,9704],{"id":9703},"debugging-coroutines-that-never-execute","Debugging Coroutines That Never Execute",[15,9706,9707],{},"A common beginner pitfall:",[32,9709,9711],{"className":2213,"code":9710,"language":2215,"meta":37,"style":37},"async def my_coroutine():\n    print(\"executed\")\n\n# WRONG — creates a coroutine object without executing it\nmy_coroutine()\n# RuntimeWarning: coroutine 'my_coroutine' was never awaited\n\n# CORRECT\nawait my_coroutine()\n# or\nasyncio.run(my_coroutine())\n",[19,9712,9713,9718,9723,9727,9732,9737,9742,9746,9751,9756,9761],{"__ignoreMap":37},[41,9714,9715],{"class":43,"line":44},[41,9716,9717],{},"async def my_coroutine():\n",[41,9719,9720],{"class":43,"line":51},[41,9721,9722],{},"    print(\"executed\")\n",[41,9724,9725],{"class":43,"line":61},[41,9726,348],{"emptyLinePlaceholder":347},[41,9728,9729],{"class":43,"line":77},[41,9730,9731],{},"# WRONG — creates a coroutine object without executing it\n",[41,9733,9734],{"class":43,"line":90},[41,9735,9736],{},"my_coroutine()\n",[41,9738,9739],{"class":43,"line":101},[41,9740,9741],{},"# RuntimeWarning: coroutine 'my_coroutine' was never awaited\n",[41,9743,9744],{"class":43,"line":107},[41,9745,348],{"emptyLinePlaceholder":347},[41,9747,9748],{"class":43,"line":116},[41,9749,9750],{},"# CORRECT\n",[41,9752,9753],{"class":43,"line":122},[41,9754,9755],{},"await my_coroutine()\n",[41,9757,9758],{"class":43,"line":135},[41,9759,9760],{},"# or\n",[41,9762,9763],{"class":43,"line":148},[41,9764,9765],{},"asyncio.run(my_coroutine())\n",[15,9767,9768],{},"Enabling asyncio's debug mode surfaces this and other anomalies:",[32,9770,9772],{"className":2213,"code":9771,"language":2215,"meta":37,"style":37},"import asyncio\nimport logging\n\nlogging.basicConfig(level=logging.DEBUG)\nasyncio.run(main(), debug=True)\n",[19,9773,9774,9778,9782,9786,9791],{"__ignoreMap":37},[41,9775,9776],{"class":43,"line":44},[41,9777,3060],{},[41,9779,9780],{"class":43,"line":51},[41,9781,3075],{},[41,9783,9784],{"class":43,"line":61},[41,9785,348],{"emptyLinePlaceholder":347},[41,9787,9788],{"class":43,"line":77},[41,9789,9790],{},"logging.basicConfig(level=logging.DEBUG)\n",[41,9792,9793],{"class":43,"line":90},[41,9794,9795],{},"asyncio.run(main(), debug=True)\n",[15,9797,9798],{},"In debug mode, asyncio logs unawaited coroutines, tasks that take longer than 100ms to execute (a sign of event loop blockage), and resources that are not properly closed.",[24,9800,4440],{"id":4439},[15,9802,9803,9804,9806,9807,9809,9810,9812,9813,9815],{},"asyncio is powerful, but its default behaviours are occasionally surprising. The essentials: use ",[19,9805,9252],{}," over ",[19,9808,9006],{}," on Python 3.11+, always maintain a strong reference to tasks created with ",[19,9811,9304],{},", add explicit timeouts to all network operations, and handle shutdown cleanly in FastAPI's ",[19,9814,4376],{},". These four practices address the vast majority of production issues encountered with asyncio.",[2097,9817,2961],{},{"title":37,"searchDepth":51,"depth":51,"links":9819},[9820,9822,9823,9824,9825,9826,9827,9828],{"id":8995,"depth":51,"text":9821},"The Problem with asyncio.gather and Exceptions",{"id":9169,"depth":51,"text":9170},{"id":9260,"depth":51,"text":9261},{"id":9362,"depth":51,"text":9363},{"id":9473,"depth":51,"text":9474},{"id":9573,"depth":51,"text":9574},{"id":9703,"depth":51,"text":9704},{"id":4439,"depth":51,"text":4440},"2024-08-19","asyncio tutorials almost invariably stop at the same point: await asyncio.gather(task1(), task2()), a handful of coroutine examples, and nothing further. Production is more demanding. Silent exceptions, tasks that never complete, shutdowns that hang — here are the real problems and how to address them.",{},"/en/blog/asyncio-production",{"title":8979,"description":9830},"asyncio-production","en/blog/asyncio-production",[4477,9837,2980,9838],"asyncio","Concurrency","R3ROVoDwjFI9k3_IHXjxihwKuaZ-XR6vwovlGIcgX_0",{"id":9841,"title":9842,"body":9843,"date":10865,"description":9850,"excerpt":2112,"extension":2113,"meta":10866,"navigation":347,"path":10867,"readTime":122,"seo":10868,"slug":10869,"stem":10870,"tags":10871,"__hash__":10874},"en_blog/en/blog/playwright-scraping.md","Playwright as a Business Scraping Tool: Beyond E2E Testing",{"type":8,"value":9844,"toc":10855},[9845,9848,9851,9855,9858,9861,9865,9868,10030,10033,10037,10040,10146,10153,10157,10160,10315,10318,10322,10325,10382,10386,10389,10494,10501,10505,10737,10743,10747,10842,10852],[11,9846,9842],{"id":9847},"playwright-as-a-business-scraping-tool-beyond-e2e-testing",[15,9849,9850],{},"The overwhelming majority of Playwright articles discuss end-to-end testing. That is its most visible use case, but far from its only one. When a business portal exposes no API — or one that is incomplete, poorly documented, or restricted to select partners — Playwright becomes a first-class automation tool. Here is how to use it seriously, outside a testing context.",[24,9852,9854],{"id":9853},"the-concrete-problem-a-portal-with-no-usable-api","The Concrete Problem: A Portal With No Usable API",[15,9856,9857],{},"Some business portals offer a rich web interface but a limited or absent API. Data extraction, exports, form submission — everything goes through the browser. BeautifulSoup and requests stop there: they cannot handle JavaScript, single-page applications, or complex authentication flows involving MFA or OAuth2 redirections.",[15,9859,9860],{},"Playwright handles all of this natively.",[24,9862,9864],{"id":9863},"scraper-architecture","Scraper Architecture",[15,9866,9867],{},"The goal is a scraper that authenticates reliably, navigates and extracts structured data, can be restarted without human intervention, and runs in a containerised environment.",[32,9869,9871],{"className":2213,"code":9870,"language":2215,"meta":37,"style":37},"from playwright.async_api import async_playwright, Browser, Page\nfrom dataclasses import dataclass\n\n@dataclass\nclass ScraperConfig:\n    base_url: str\n    username: str\n    password: str\n    headless: bool = True\n    timeout: int = 30_000  # ms\n\nclass BusinessScraper:\n    def __init__(self, config: ScraperConfig):\n        self.config = config\n        self._browser: Browser | None = None\n        self._page: Page | None = None\n\n    async def __aenter__(self):\n        self._playwright = await async_playwright().start()\n        self._browser = await self._playwright.chromium.launch(\n            headless=self.config.headless,\n            args=[\"--no-sandbox\", \"--disable-dev-shm-usage\"]  # Required in Docker\n        )\n        context = await self._browser.new_context(\n            viewport={\"width\": 1280, \"height\": 800},\n            locale=\"en-GB\"\n        )\n        self._page = await context.new_page()\n        return self\n\n    async def __aexit__(self, *args):\n        await self._browser.close()\n        await self._playwright.stop()\n",[19,9872,9873,9878,9882,9886,9890,9895,9900,9905,9910,9915,9920,9924,9929,9934,9939,9944,9949,9953,9958,9963,9968,9973,9978,9982,9987,9992,9997,10001,10006,10011,10015,10020,10025],{"__ignoreMap":37},[41,9874,9875],{"class":43,"line":44},[41,9876,9877],{},"from playwright.async_api import async_playwright, Browser, Page\n",[41,9879,9880],{"class":43,"line":51},[41,9881,8165],{},[41,9883,9884],{"class":43,"line":61},[41,9885,348],{"emptyLinePlaceholder":347},[41,9887,9888],{"class":43,"line":77},[41,9889,7776],{},[41,9891,9892],{"class":43,"line":90},[41,9893,9894],{},"class ScraperConfig:\n",[41,9896,9897],{"class":43,"line":101},[41,9898,9899],{},"    base_url: str\n",[41,9901,9902],{"class":43,"line":107},[41,9903,9904],{},"    username: str\n",[41,9906,9907],{"class":43,"line":116},[41,9908,9909],{},"    password: str\n",[41,9911,9912],{"class":43,"line":122},[41,9913,9914],{},"    headless: bool = True\n",[41,9916,9917],{"class":43,"line":135},[41,9918,9919],{},"    timeout: int = 30_000  # ms\n",[41,9921,9922],{"class":43,"line":148},[41,9923,348],{"emptyLinePlaceholder":347},[41,9925,9926],{"class":43,"line":161},[41,9927,9928],{},"class BusinessScraper:\n",[41,9930,9931],{"class":43,"line":192},[41,9932,9933],{},"    def __init__(self, config: ScraperConfig):\n",[41,9935,9936],{"class":43,"line":205},[41,9937,9938],{},"        self.config = config\n",[41,9940,9941],{"class":43,"line":213},[41,9942,9943],{},"        self._browser: Browser | None = None\n",[41,9945,9946],{"class":43,"line":226},[41,9947,9948],{},"        self._page: Page | None = None\n",[41,9950,9951],{"class":43,"line":239},[41,9952,348],{"emptyLinePlaceholder":347},[41,9954,9955],{"class":43,"line":250},[41,9956,9957],{},"    async def __aenter__(self):\n",[41,9959,9960],{"class":43,"line":256},[41,9961,9962],{},"        self._playwright = await async_playwright().start()\n",[41,9964,9965],{"class":43,"line":262},[41,9966,9967],{},"        self._browser = await self._playwright.chromium.launch(\n",[41,9969,9970],{"class":43,"line":268},[41,9971,9972],{},"            headless=self.config.headless,\n",[41,9974,9975],{"class":43,"line":276},[41,9976,9977],{},"            args=[\"--no-sandbox\", \"--disable-dev-shm-usage\"]  # Required in Docker\n",[41,9979,9980],{"class":43,"line":289},[41,9981,2334],{},[41,9983,9984],{"class":43,"line":302},[41,9985,9986],{},"        context = await self._browser.new_context(\n",[41,9988,9989],{"class":43,"line":313},[41,9990,9991],{},"            viewport={\"width\": 1280, \"height\": 800},\n",[41,9993,9994],{"class":43,"line":319},[41,9995,9996],{},"            locale=\"en-GB\"\n",[41,9998,9999],{"class":43,"line":757},[41,10000,2334],{},[41,10002,10003],{"class":43,"line":762},[41,10004,10005],{},"        self._page = await context.new_page()\n",[41,10007,10008],{"class":43,"line":774},[41,10009,10010],{},"        return self\n",[41,10012,10013],{"class":43,"line":785},[41,10014,348],{"emptyLinePlaceholder":347},[41,10016,10017],{"class":43,"line":798},[41,10018,10019],{},"    async def __aexit__(self, *args):\n",[41,10021,10022],{"class":43,"line":809},[41,10023,10024],{},"        await self._browser.close()\n",[41,10026,10027],{"class":43,"line":1507},[41,10028,10029],{},"        await self._playwright.stop()\n",[15,10031,10032],{},"The context manager ensures the browser closes cleanly even in the event of an exception — essential in production.",[24,10034,10036],{"id":10035},"robust-authentication","Robust Authentication",[15,10038,10039],{},"Authentication is the most fragile part of any scraper. Portals change their UI, introduce additional security steps, or add delays. A few principles for making it reliable:",[32,10041,10043],{"className":2213,"code":10042,"language":2215,"meta":37,"style":37},"async def login(self) -> bool:\n    page = self._page\n    await page.goto(f\"{self.config.base_url}/login\", wait_until=\"networkidle\")\n\n    # Wait for the specific element, not just page load\n    await page.wait_for_selector(\"#username\", state=\"visible\", timeout=10_000)\n    await page.fill(\"#username\", self.config.username)\n    await page.fill(\"#password\", self.config.password)\n\n    # Intercept the login response to detect auth failures precisely\n    async with page.expect_response(\n        lambda r: \"/api/auth\" in r.url and r.status in (200, 401, 403)\n    ) as response_info:\n        await page.click('[type=\"submit\"]')\n\n    response = await response_info.value\n    if response.status != 200:\n        raise AuthenticationError(f\"Login failed: HTTP {response.status}\")\n\n    await page.wait_for_url(f\"{self.config.base_url}/dashboard\", timeout=15_000)\n    return True\n",[19,10044,10045,10050,10055,10060,10064,10069,10074,10079,10084,10088,10093,10098,10103,10108,10113,10117,10122,10127,10132,10136,10141],{"__ignoreMap":37},[41,10046,10047],{"class":43,"line":44},[41,10048,10049],{},"async def login(self) -> bool:\n",[41,10051,10052],{"class":43,"line":51},[41,10053,10054],{},"    page = self._page\n",[41,10056,10057],{"class":43,"line":61},[41,10058,10059],{},"    await page.goto(f\"{self.config.base_url}/login\", wait_until=\"networkidle\")\n",[41,10061,10062],{"class":43,"line":77},[41,10063,348],{"emptyLinePlaceholder":347},[41,10065,10066],{"class":43,"line":90},[41,10067,10068],{},"    # Wait for the specific element, not just page load\n",[41,10070,10071],{"class":43,"line":101},[41,10072,10073],{},"    await page.wait_for_selector(\"#username\", state=\"visible\", timeout=10_000)\n",[41,10075,10076],{"class":43,"line":107},[41,10077,10078],{},"    await page.fill(\"#username\", self.config.username)\n",[41,10080,10081],{"class":43,"line":116},[41,10082,10083],{},"    await page.fill(\"#password\", self.config.password)\n",[41,10085,10086],{"class":43,"line":122},[41,10087,348],{"emptyLinePlaceholder":347},[41,10089,10090],{"class":43,"line":135},[41,10091,10092],{},"    # Intercept the login response to detect auth failures precisely\n",[41,10094,10095],{"class":43,"line":148},[41,10096,10097],{},"    async with page.expect_response(\n",[41,10099,10100],{"class":43,"line":161},[41,10101,10102],{},"        lambda r: \"/api/auth\" in r.url and r.status in (200, 401, 403)\n",[41,10104,10105],{"class":43,"line":192},[41,10106,10107],{},"    ) as response_info:\n",[41,10109,10110],{"class":43,"line":205},[41,10111,10112],{},"        await page.click('[type=\"submit\"]')\n",[41,10114,10115],{"class":43,"line":213},[41,10116,348],{"emptyLinePlaceholder":347},[41,10118,10119],{"class":43,"line":226},[41,10120,10121],{},"    response = await response_info.value\n",[41,10123,10124],{"class":43,"line":239},[41,10125,10126],{},"    if response.status != 200:\n",[41,10128,10129],{"class":43,"line":250},[41,10130,10131],{},"        raise AuthenticationError(f\"Login failed: HTTP {response.status}\")\n",[41,10133,10134],{"class":43,"line":256},[41,10135,348],{"emptyLinePlaceholder":347},[41,10137,10138],{"class":43,"line":262},[41,10139,10140],{},"    await page.wait_for_url(f\"{self.config.base_url}/dashboard\", timeout=15_000)\n",[41,10142,10143],{"class":43,"line":268},[41,10144,10145],{},"    return True\n",[15,10147,10148,10149,10152],{},"Network response interception (",[19,10150,10151],{},"expect_response",") is more reliable than waiting for a CSS selector after the click — it detects authentication failures without depending on how the error message happens to be rendered.",[24,10154,10156],{"id":10155},"extracting-structured-data","Extracting Structured Data",[15,10158,10159],{},"Once authenticated, extraction must be deterministic. Playwright allows combining DOM navigation and network interception, depending on which is more stable:",[32,10161,10163],{"className":2213,"code":10162,"language":2215,"meta":37,"style":37},"async def extract_certificates(self, period: str) -> list[dict]:\n    page = self._page\n    await page.goto(\n        f\"{self.config.base_url}/certificates?period={period}\",\n        wait_until=\"networkidle\"\n    )\n\n    # Strategy 1: intercept the underlying API call when available\n    async with page.expect_response(\n        lambda r: \"/api/certificates\" in r.url\n    ) as api_response:\n        await page.click(\"#load-certificates\")\n\n    data = await (await api_response.value).json()\n    return data.get(\"items\", [])\n\nasync def extract_table_data(self) -> list[dict]:\n    \"\"\"Strategy 2: extract directly from the DOM.\"\"\"\n    rows = await self._page.query_selector_all(\"table.data-grid tbody tr\")\n    results = []\n\n    for row in rows:\n        cells = await row.query_selector_all(\"td\")\n        values = [await cell.inner_text() for cell in cells]\n        results.append({\n            \"id\": values[0].strip(),\n            \"date\": values[1].strip(),\n            \"volume\": float(values[2].replace(\",\", \".\")),\n            \"status\": values[3].strip(),\n        })\n\n    return results\n",[19,10164,10165,10170,10174,10179,10184,10189,10193,10197,10202,10206,10211,10216,10221,10225,10230,10235,10239,10244,10249,10254,10258,10262,10267,10272,10277,10282,10287,10292,10297,10302,10306,10310],{"__ignoreMap":37},[41,10166,10167],{"class":43,"line":44},[41,10168,10169],{},"async def extract_certificates(self, period: str) -> list[dict]:\n",[41,10171,10172],{"class":43,"line":51},[41,10173,10054],{},[41,10175,10176],{"class":43,"line":61},[41,10177,10178],{},"    await page.goto(\n",[41,10180,10181],{"class":43,"line":77},[41,10182,10183],{},"        f\"{self.config.base_url}/certificates?period={period}\",\n",[41,10185,10186],{"class":43,"line":90},[41,10187,10188],{},"        wait_until=\"networkidle\"\n",[41,10190,10191],{"class":43,"line":101},[41,10192,1964],{},[41,10194,10195],{"class":43,"line":107},[41,10196,348],{"emptyLinePlaceholder":347},[41,10198,10199],{"class":43,"line":116},[41,10200,10201],{},"    # Strategy 1: intercept the underlying API call when available\n",[41,10203,10204],{"class":43,"line":122},[41,10205,10097],{},[41,10207,10208],{"class":43,"line":135},[41,10209,10210],{},"        lambda r: \"/api/certificates\" in r.url\n",[41,10212,10213],{"class":43,"line":148},[41,10214,10215],{},"    ) as api_response:\n",[41,10217,10218],{"class":43,"line":161},[41,10219,10220],{},"        await page.click(\"#load-certificates\")\n",[41,10222,10223],{"class":43,"line":192},[41,10224,348],{"emptyLinePlaceholder":347},[41,10226,10227],{"class":43,"line":205},[41,10228,10229],{},"    data = await (await api_response.value).json()\n",[41,10231,10232],{"class":43,"line":213},[41,10233,10234],{},"    return data.get(\"items\", [])\n",[41,10236,10237],{"class":43,"line":226},[41,10238,348],{"emptyLinePlaceholder":347},[41,10240,10241],{"class":43,"line":239},[41,10242,10243],{},"async def extract_table_data(self) -> list[dict]:\n",[41,10245,10246],{"class":43,"line":250},[41,10247,10248],{},"    \"\"\"Strategy 2: extract directly from the DOM.\"\"\"\n",[41,10250,10251],{"class":43,"line":256},[41,10252,10253],{},"    rows = await self._page.query_selector_all(\"table.data-grid tbody tr\")\n",[41,10255,10256],{"class":43,"line":262},[41,10257,9194],{},[41,10259,10260],{"class":43,"line":268},[41,10261,348],{"emptyLinePlaceholder":347},[41,10263,10264],{"class":43,"line":276},[41,10265,10266],{},"    for row in rows:\n",[41,10268,10269],{"class":43,"line":289},[41,10270,10271],{},"        cells = await row.query_selector_all(\"td\")\n",[41,10273,10274],{"class":43,"line":302},[41,10275,10276],{},"        values = [await cell.inner_text() for cell in cells]\n",[41,10278,10279],{"class":43,"line":313},[41,10280,10281],{},"        results.append({\n",[41,10283,10284],{"class":43,"line":319},[41,10285,10286],{},"            \"id\": values[0].strip(),\n",[41,10288,10289],{"class":43,"line":757},[41,10290,10291],{},"            \"date\": values[1].strip(),\n",[41,10293,10294],{"class":43,"line":762},[41,10295,10296],{},"            \"volume\": float(values[2].replace(\",\", \".\")),\n",[41,10298,10299],{"class":43,"line":774},[41,10300,10301],{},"            \"status\": values[3].strip(),\n",[41,10303,10304],{"class":43,"line":785},[41,10305,4121],{},[41,10307,10308],{"class":43,"line":798},[41,10309,348],{"emptyLinePlaceholder":347},[41,10311,10312],{"class":43,"line":809},[41,10313,10314],{},"    return results\n",[15,10316,10317],{},"Strategy 1 (network interception) is preferable when available: raw JSON data is cleaner and less sensitive to layout changes. Strategy 2 (DOM extraction) is the universal fallback.",[24,10319,10321],{"id":10320},"handling-file-downloads","Handling File Downloads",[15,10323,10324],{},"Many portals offer Excel or CSV exports via a download button. Playwright handles this natively:",[32,10326,10328],{"className":2213,"code":10327,"language":2215,"meta":37,"style":37},"async def download_export(self, output_path: str) -> str:\n    async with self._page.expect_download() as download_info:\n        await self._page.click(\"#export-button\")\n\n    download = await download_info.value\n\n    if download.failure():\n        raise ExportError(f\"Download failed: {download.failure()}\")\n\n    await download.save_as(output_path)\n    return output_path\n",[19,10329,10330,10335,10340,10345,10349,10354,10358,10363,10368,10372,10377],{"__ignoreMap":37},[41,10331,10332],{"class":43,"line":44},[41,10333,10334],{},"async def download_export(self, output_path: str) -> str:\n",[41,10336,10337],{"class":43,"line":51},[41,10338,10339],{},"    async with self._page.expect_download() as download_info:\n",[41,10341,10342],{"class":43,"line":61},[41,10343,10344],{},"        await self._page.click(\"#export-button\")\n",[41,10346,10347],{"class":43,"line":77},[41,10348,348],{"emptyLinePlaceholder":347},[41,10350,10351],{"class":43,"line":90},[41,10352,10353],{},"    download = await download_info.value\n",[41,10355,10356],{"class":43,"line":101},[41,10357,348],{"emptyLinePlaceholder":347},[41,10359,10360],{"class":43,"line":107},[41,10361,10362],{},"    if download.failure():\n",[41,10364,10365],{"class":43,"line":116},[41,10366,10367],{},"        raise ExportError(f\"Download failed: {download.failure()}\")\n",[41,10369,10370],{"class":43,"line":122},[41,10371,348],{"emptyLinePlaceholder":347},[41,10373,10374],{"class":43,"line":135},[41,10375,10376],{},"    await download.save_as(output_path)\n",[41,10378,10379],{"class":43,"line":148},[41,10380,10381],{},"    return output_path\n",[24,10383,10385],{"id":10384},"running-in-docker-and-openshift","Running in Docker and OpenShift",[15,10387,10388],{},"Playwright in a container requires Chromium's system dependencies:",[32,10390,10394],{"className":10391,"code":10392,"language":10393,"meta":37,"style":37},"language-dockerfile shiki shiki-themes github-dark github-light","FROM python:3.12-slim\n\nRUN apt-get update && apt-get install -y \\\n    libnss3 libatk1.0-0 libatk-bridge2.0-0 \\\n    libcups2 libdrm2 libxkbcommon0 libxcomposite1 \\\n    libxdamage1 libxfixes3 libxrandr2 libgbm1 \\\n    libasound2 libpango-1.0-0 libcairo2 \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\nRUN chown -R 1001:0 /app && chmod -R g=u /app\n\nCOPY --chown=1001:0 requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt && playwright install chromium\n\nCOPY --chown=1001:0 . .\n\nUSER 1001\n\nCMD [\"python\", \"scraper.py\"]\n","dockerfile",[19,10395,10396,10401,10405,10410,10415,10420,10425,10430,10435,10439,10444,10448,10453,10457,10462,10467,10471,10476,10480,10485,10489],{"__ignoreMap":37},[41,10397,10398],{"class":43,"line":44},[41,10399,10400],{},"FROM python:3.12-slim\n",[41,10402,10403],{"class":43,"line":51},[41,10404,348],{"emptyLinePlaceholder":347},[41,10406,10407],{"class":43,"line":61},[41,10408,10409],{},"RUN apt-get update && apt-get install -y \\\n",[41,10411,10412],{"class":43,"line":77},[41,10413,10414],{},"    libnss3 libatk1.0-0 libatk-bridge2.0-0 \\\n",[41,10416,10417],{"class":43,"line":90},[41,10418,10419],{},"    libcups2 libdrm2 libxkbcommon0 libxcomposite1 \\\n",[41,10421,10422],{"class":43,"line":101},[41,10423,10424],{},"    libxdamage1 libxfixes3 libxrandr2 libgbm1 \\\n",[41,10426,10427],{"class":43,"line":107},[41,10428,10429],{},"    libasound2 libpango-1.0-0 libcairo2 \\\n",[41,10431,10432],{"class":43,"line":116},[41,10433,10434],{},"    && rm -rf /var/lib/apt/lists/*\n",[41,10436,10437],{"class":43,"line":122},[41,10438,348],{"emptyLinePlaceholder":347},[41,10440,10441],{"class":43,"line":135},[41,10442,10443],{},"WORKDIR /app\n",[41,10445,10446],{"class":43,"line":148},[41,10447,348],{"emptyLinePlaceholder":347},[41,10449,10450],{"class":43,"line":161},[41,10451,10452],{},"RUN chown -R 1001:0 /app && chmod -R g=u /app\n",[41,10454,10455],{"class":43,"line":192},[41,10456,348],{"emptyLinePlaceholder":347},[41,10458,10459],{"class":43,"line":205},[41,10460,10461],{},"COPY --chown=1001:0 requirements.txt .\n",[41,10463,10464],{"class":43,"line":213},[41,10465,10466],{},"RUN pip install --no-cache-dir -r requirements.txt && playwright install chromium\n",[41,10468,10469],{"class":43,"line":226},[41,10470,348],{"emptyLinePlaceholder":347},[41,10472,10473],{"class":43,"line":239},[41,10474,10475],{},"COPY --chown=1001:0 . .\n",[41,10477,10478],{"class":43,"line":250},[41,10479,348],{"emptyLinePlaceholder":347},[41,10481,10482],{"class":43,"line":256},[41,10483,10484],{},"USER 1001\n",[41,10486,10487],{"class":43,"line":262},[41,10488,348],{"emptyLinePlaceholder":347},[41,10490,10491],{"class":43,"line":268},[41,10492,10493],{},"CMD [\"python\", \"scraper.py\"]\n",[15,10495,10496,10497,10500],{},"On OpenShift, ",[19,10498,10499],{},"--no-sandbox"," is mandatory: containers do not have the privileges required by Chromium's sandbox. This is not a security concern in this context — the sandbox protects against malicious web content, which does not apply to a scraper targeting a known internal portal.",[24,10502,10504],{"id":10503},"orchestrating-with-a-kubernetes-cronjob","Orchestrating with a Kubernetes CronJob",[32,10506,10510],{"className":10507,"code":10508,"language":10509,"meta":37,"style":37},"language-yaml shiki shiki-themes github-dark github-light","apiVersion: batch/v1\nkind: CronJob\nmetadata:\n  name: business-scraper\nspec:\n  schedule: \"0 6 * * 1-5\"\n  concurrencyPolicy: Forbid\n  jobTemplate:\n    spec:\n      template:\n        spec:\n          containers:\n            - name: scraper\n              image: registry.internal/business-scraper:latest\n              env:\n                - name: SCRAPER_USERNAME\n                  valueFrom:\n                    secretKeyRef:\n                      name: scraper-credentials\n                      key: username\n                - name: SCRAPER_PASSWORD\n                  valueFrom:\n                    secretKeyRef:\n                      name: scraper-credentials\n                      key: password\n          restartPolicy: OnFailure\n","yaml",[19,10511,10512,10522,10532,10540,10549,10556,10566,10576,10583,10590,10597,10604,10611,10624,10634,10641,10653,10660,10667,10677,10687,10698,10704,10710,10718,10727],{"__ignoreMap":37},[41,10513,10514,10517,10519],{"class":43,"line":44},[41,10515,10516],{"class":6589},"apiVersion",[41,10518,67],{"class":47},[41,10520,10521],{"class":70},"batch/v1\n",[41,10523,10524,10527,10529],{"class":43,"line":51},[41,10525,10526],{"class":6589},"kind",[41,10528,67],{"class":47},[41,10530,10531],{"class":70},"CronJob\n",[41,10533,10534,10537],{"class":43,"line":61},[41,10535,10536],{"class":6589},"metadata",[41,10538,10539],{"class":47},":\n",[41,10541,10542,10544,10546],{"class":43,"line":77},[41,10543,560],{"class":6589},[41,10545,67],{"class":47},[41,10547,10548],{"class":70},"business-scraper\n",[41,10550,10551,10554],{"class":43,"line":90},[41,10552,10553],{"class":6589},"spec",[41,10555,10539],{"class":47},[41,10557,10558,10561,10563],{"class":43,"line":101},[41,10559,10560],{"class":6589},"  schedule",[41,10562,67],{"class":47},[41,10564,10565],{"class":70},"\"0 6 * * 1-5\"\n",[41,10567,10568,10571,10573],{"class":43,"line":107},[41,10569,10570],{"class":6589},"  concurrencyPolicy",[41,10572,67],{"class":47},[41,10574,10575],{"class":70},"Forbid\n",[41,10577,10578,10581],{"class":43,"line":116},[41,10579,10580],{"class":6589},"  jobTemplate",[41,10582,10539],{"class":47},[41,10584,10585,10588],{"class":43,"line":122},[41,10586,10587],{"class":6589},"    spec",[41,10589,10539],{"class":47},[41,10591,10592,10595],{"class":43,"line":135},[41,10593,10594],{"class":6589},"      template",[41,10596,10539],{"class":47},[41,10598,10599,10602],{"class":43,"line":148},[41,10600,10601],{"class":6589},"        spec",[41,10603,10539],{"class":47},[41,10605,10606,10609],{"class":43,"line":161},[41,10607,10608],{"class":6589},"          containers",[41,10610,10539],{"class":47},[41,10612,10613,10616,10619,10621],{"class":43,"line":192},[41,10614,10615],{"class":47},"            - ",[41,10617,10618],{"class":6589},"name",[41,10620,67],{"class":47},[41,10622,10623],{"class":70},"scraper\n",[41,10625,10626,10629,10631],{"class":43,"line":205},[41,10627,10628],{"class":6589},"              image",[41,10630,67],{"class":47},[41,10632,10633],{"class":70},"registry.internal/business-scraper:latest\n",[41,10635,10636,10639],{"class":43,"line":213},[41,10637,10638],{"class":6589},"              env",[41,10640,10539],{"class":47},[41,10642,10643,10646,10648,10650],{"class":43,"line":226},[41,10644,10645],{"class":47},"                - ",[41,10647,10618],{"class":6589},[41,10649,67],{"class":47},[41,10651,10652],{"class":70},"SCRAPER_USERNAME\n",[41,10654,10655,10658],{"class":43,"line":239},[41,10656,10657],{"class":6589},"                  valueFrom",[41,10659,10539],{"class":47},[41,10661,10662,10665],{"class":43,"line":250},[41,10663,10664],{"class":6589},"                    secretKeyRef",[41,10666,10539],{"class":47},[41,10668,10669,10672,10674],{"class":43,"line":256},[41,10670,10671],{"class":6589},"                      name",[41,10673,67],{"class":47},[41,10675,10676],{"class":70},"scraper-credentials\n",[41,10678,10679,10682,10684],{"class":43,"line":262},[41,10680,10681],{"class":6589},"                      key",[41,10683,67],{"class":47},[41,10685,10686],{"class":70},"username\n",[41,10688,10689,10691,10693,10695],{"class":43,"line":268},[41,10690,10645],{"class":47},[41,10692,10618],{"class":6589},[41,10694,67],{"class":47},[41,10696,10697],{"class":70},"SCRAPER_PASSWORD\n",[41,10699,10700,10702],{"class":43,"line":276},[41,10701,10657],{"class":6589},[41,10703,10539],{"class":47},[41,10705,10706,10708],{"class":43,"line":289},[41,10707,10664],{"class":6589},[41,10709,10539],{"class":47},[41,10711,10712,10714,10716],{"class":43,"line":302},[41,10713,10671],{"class":6589},[41,10715,67],{"class":47},[41,10717,10676],{"class":70},[41,10719,10720,10722,10724],{"class":43,"line":313},[41,10721,10681],{"class":6589},[41,10723,67],{"class":47},[41,10725,10726],{"class":70},"password\n",[41,10728,10729,10732,10734],{"class":43,"line":319},[41,10730,10731],{"class":6589},"          restartPolicy",[41,10733,67],{"class":47},[41,10735,10736],{"class":70},"OnFailure\n",[15,10738,10739,10742],{},[19,10740,10741],{},"concurrencyPolicy: Forbid"," is critical: if one execution takes longer than expected, you do not want two scrapers authenticating simultaneously with the same account.",[24,10744,10746],{"id":10745},"playwright-vs-the-alternatives","Playwright vs the Alternatives",[2877,10748,10749,10765],{},[2880,10750,10751],{},[2883,10752,10753,10756,10759,10762],{},[2886,10754,10755],{},"Criterion",[2886,10757,10758],{},"requests + BS4",[2886,10760,10761],{},"Selenium",[2886,10763,10764],{},"Playwright",[2896,10766,10767,10780,10793,10804,10817,10831],{},[2883,10768,10769,10772,10775,10778],{},[2901,10770,10771],{},"SPAs / JavaScript",[2901,10773,10774],{},"No",[2901,10776,10777],{},"Yes",[2901,10779,10777],{},[2883,10781,10782,10785,10787,10790],{},[2901,10783,10784],{},"Network interception",[2901,10786,10774],{},[2901,10788,10789],{},"Partial",[2901,10791,10792],{},"Native",[2883,10794,10795,10798,10800,10802],{},[2901,10796,10797],{},"Native async",[2901,10799,10774],{},[2901,10801,10774],{},[2901,10803,10777],{},[2883,10805,10806,10809,10812,10815],{},[2901,10807,10808],{},"CI/CD stability",[2901,10810,10811],{},"Good",[2901,10813,10814],{},"Fragile",[2901,10816,10811],{},[2883,10818,10819,10822,10825,10828],{},[2901,10820,10821],{},"Docker support",[2901,10823,10824],{},"Simple",[2901,10826,10827],{},"Complex",[2901,10829,10830],{},"Reasonable",[2883,10832,10833,10836,10838,10840],{},[2901,10834,10835],{},"Modern API",[2901,10837,10774],{},[2901,10839,10774],{},[2901,10841,10777],{},[15,10843,10844,10845,1799,10848,10851],{},"For simple static sites, ",[19,10846,10847],{},"requests",[19,10849,10850],{},"BeautifulSoup"," remain faster to set up and lighter to operate. However, as soon as complex authentication, dynamic JavaScript, or user interactions are involved — Playwright is the most robust open-source option available today.",[2097,10853,10854],{},"html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sZkSk, html code.shiki .sZkSk{--shiki-dark:#85E89D;--shiki-default:#22863A}html pre.shiki code .sQ3_J, html code.shiki .sQ3_J{--shiki-dark:#E1E4E8;--shiki-default:#24292E}html pre.shiki code .sg6BJ, html code.shiki .sg6BJ{--shiki-dark:#9ECBFF;--shiki-default:#032F62}",{"title":37,"searchDepth":51,"depth":51,"links":10856},[10857,10858,10859,10860,10861,10862,10863,10864],{"id":9853,"depth":51,"text":9854},{"id":9863,"depth":51,"text":9864},{"id":10035,"depth":51,"text":10036},{"id":10155,"depth":51,"text":10156},{"id":10320,"depth":51,"text":10321},{"id":10384,"depth":51,"text":10385},{"id":10503,"depth":51,"text":10504},{"id":10745,"depth":51,"text":10746},"2024-01-03",{},"/en/blog/playwright-scraping",{"title":9842,"description":9850},"playwright-scraping","en/blog/playwright-scraping",[10764,4477,10872,10873],"Scraping","Automation","oQUUW112w2iMAmal0nq2gp-lej3gpyVIgTM7tHGKHWs",1774645634997]