빠른 시작 이 페이지는 영어에서 번역되었습니다. 두 내용이 다를 경우 영어 페이지가 정확합니다. 영어 페이지 읽기
이 페이지에서는 아무것도 없는 상태에서 시작해 내 계정에 카드가 표시될 때까지 앱을 만듭니다. 아래 샘플은 각 언어에서 같은 단계를 따릅니다.
OAuth 클라이언트 만들기
앱은 매니페스트를 가진 OAuth 클라이언트이므로, 클라이언트부터 시작합니다. 콘솔의 OAuth 클라이언트 페이지에서 클라이언트를 하나 만들고, 수업 계획 읽기처럼 앱에 필요한 API 권한을 부여하세요. 클라이언트 ID와 시크릿을 사용하면 서버가 앱으로서 Lingara API를 호출할 수 있습니다. 이 값들은 서버의 환경 변수에 보관하세요.
매니페스트 작성 및 업로드
매니페스트는 Lingara에 앱을 설명합니다.
앱의 이름과 설명(기본 언어로 작성하며, 번역은 선택 사항);
render_url, Lingara가 요청을 보내는 HTTPS 주소;
카드가 표시될 수 있는 슬롯;
볼 수 있도록 요청하는 컨텍스트로, 학습자가 설치할 때 동의합니다(컨텍스트와 동의 참고);
필요한 API 권한으로, 앱의 OAuth 클라이언트가 이미 가지고 있어야 합니다;
튜터에게 메모를 전달할 수 있는지 여부(튜터 메모 참고).
키트는 매니페스트를 JSON으로 만들고 업로드 시 적용되는 규칙에 따라 검사하므로, 실수가 먼저 내 컴퓨터에서 드러납니다. 콘솔의 앱 페이지에서 클라이언트를 선택하고, 앱으로 만든 다음, 파일을 업로드하세요.
서명 시크릿 만들기
같은 페이지에서 서명 시크릿을 만드세요. 시크릿은 한 번만 표시됩니다. 환경 변수를 통해 서버에 전달하고, 절대 소스 관리에 커밋하지 마세요. Lingara는 앱에 보내는 모든 요청에 이 시크릿으로 서명하며, 키트는 서명이 일치하지 않는 요청을 거부합니다. 앱은 시크릿을 동시에 두 개까지 보유할 수 있으므로 중단 없이 하나를 교체할 수 있습니다. 시크릿 두 개가 활성 상태인 동안에는 모든 요청이 두 시크릿으로 서명됩니다.
렌더 함수 작성
렌더 함수는 요청을 받아 카드를 반환합니다. 요청에는 슬롯, 학습자의 언어, 학습자가 공유하기로 동의한 컨텍스트, 그리고 앱에 대해 학습자를 식별하는 안정적인 값인 subject가 담겨 있습니다. 학습자별로 저장하는 모든 것은 subject 아래에 보관하세요. 카드의 버튼은 액션을 보내고, 서버는 새 카드로 응답합니다. 카드에 담을 수 있는 모든 항목은 카드 페이지에 나와 있습니다.
어댑터 마운트
키트의 어댑터는 원시 요청을 읽고, 서명을 확인하고, 함수를 호출한 뒤, 응답을 작성합니다. 매니페스트의 render_url이 가리키는 주소에서 어댑터를 제공하세요. Lingara는 공용 인터넷의 HTTPS 주소만 호출합니다.
내 계정에 설치
콘솔의 앱 페이지에서 앱을 내 계정에 설치하도록 선택하고, 앱이 볼 수 있는 항목을 고른 뒤 확인하세요. 넓은 화면에서 수업 계획 화면을 열거나 웹에서 홈 페이지를 열면 카드가 표시됩니다.
앱의 OAuth 클라이언트는 수업 계획이 준비되는 것과 같은 이벤트도 받습니다. 이러한 이벤트는 API 레퍼런스의 웹훅 및 이벤트 가이드에서 설명합니다.
코드 예제 언어 TypeScript Rust Go Java Kotlin Ruby PHP TypeScript
npm install @lingara/apps@0.1.0-alpha.6 Rust
cargo add lingara-apps@0.1.0-alpha.6 --features axum
cargo add axum@0.8
cargo add tokio --features macros,net,rt-multi-thread Go
go get github.com/Spinning-Cat-Studios/lingara_app_framework_sdk/go@v0.1.0-alpha.6 Java
< dependency >
< groupId >com.getlingara</ groupId >
< artifactId >lingara-apps-java</ artifactId >
< version >0.1.0-alpha.6</ version >
</ dependency > Kotlin
implementation ( "com.getlingara:lingara-apps-kotlin:0.1.0-alpha.6" ) Ruby
gem install lingara-apps -v 0.1.0-alpha.6 PHP
composer require spinningcatstudios/lingara-apps:0.1.0-alpha.6 spinningcatstudios/lingara:@alpha 코드 예제 언어 TypeScript Rust Go Java Kotlin Ruby PHP TypeScript
import { manifest } from "@lingara/apps" ;
const json = manifest ()
. defaultLocale ( "en" )
. name ({ en: "Daily five" , "zh-Hans" : "每日五词" })
. description ( "Five words to review, picked from your plan." )
. renderUrl ( "https://apps.example.com/lingara/render" )
. slots ( "home.side" , "plans.empty_detail" )
. context ( "languages" , "plan_summary" )
// `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
. tutorNote ()
. toJson (); // Throws ManifestError naming the rule an upload would refuse.
writeFileSync ( "manifest.json" , json); // Upload it in the console. Rust
let manifest = manifest ()
. default_locale ( "en" )
. name ( "Daily five" )
. description ( "Five words to review, picked from your plan." )
. render_url ( "https://apps.example.com/lingara/render" )
. slots ([ AppSlotName :: HomeSide , AppSlotName :: PlansEmptyDetail ])
. context ([ ContextSliceKind :: Languages , ContextSliceKind :: PlanSummary ])
// `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
. tutor_note ( true )
. build () ? ;
std :: fs :: write ( "manifest.json" , manifest . to_json ()) ? ; Go
manifest, err := lingaraapps. NewManifest ().
DefaultLocale ( "en" ).
Names ( map [ string ] string { "en" : "Daily five" , "zh-Hans" : "每日五词" }).
Description ( "Five words to review, picked from your plan." ).
RenderURL ( "https://apps.example.com/lingara" ).
Slots (lingaraapps.AppSlotNameHomeSide, lingaraapps.AppSlotNamePlansEmptyDetail).
Context (lingaraapps.ContextSliceKindLanguages, lingaraapps.ContextSliceKindPlanSummary).
// `.Scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
TutorNote ( true ).
Build ()
if err != nil {
return err // a *lingaraapps.ManifestError names the broken rule
}
data, err := manifest. ToJSON ()
if err != nil {
return err
}
// Upload manifest.json in the console.
err = os. WriteFile ( "manifest.json" , data, 0o 644 ) Java
import com.getlingara.apps.Manifest;
import com.getlingara.apps.model.AppSlotName;
import com.getlingara.apps.model.ContextSliceKind;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
String json =
Manifest. manifest ()
. defaultLocale ( "en" )
. name ( "Daily five" )
. description ( "Five words to review, picked from your plan." )
. renderUrl ( "https://apps.example.com/lingara/render" )
. slots (AppSlotName.HOME_SIDE)
. context (ContextSliceKind.LANGUAGES, ContextSliceKind.PLAN_SUMMARY)
// `.scopes(…)` lists the API scopes your client uses, for the learner's consent
// page. This app uses none.
. tutorNote ( true )
. build () // refuses what the upload would, naming the rule
. toJson ();
// Upload manifest.json in the console, beside the app's icon.
Files. writeString (Path. of ( "manifest.json" ), json); Kotlin
import com.getlingara.apps.kotlin.manifest
import com.getlingara.apps.kotlin.model.AppSlotName
import com.getlingara.apps.kotlin.model.ContextSliceKind
import java.io.File
val json =
manifest {
defaultLocale = "en"
name ( "Daily five" )
description ( "Five words to review, picked from your plan." )
renderUrl = "https://apps.example.com/lingara/render"
slots (AppSlotName.HOME_PERIOD_SIDE)
context (ContextSliceKind.LANGUAGES, ContextSliceKind.PLAN_SUMMARY)
// `scopes(…)` lists the API scopes your client uses, for the learner's consent page.
// This app uses none.
tutorNote = true
}. toJson () // refuses what the upload would, naming the rule
// Upload manifest.json in the console, beside the app's icon.
File ( "manifest.json" ). writeText (json) Ruby
manifest = Lingara :: Apps . manifest
. default_locale ( "en" )
. name ( "Daily five" ) # A bare String is the default locale's; or {"en" => …, "zh-Hans" => …}.
. description ( "Five words to review, picked from your plan." )
. render_url ( "https://apps.example.com/lingara" )
. slots ( "home.side" , "plans.empty_detail" )
. context ( "languages" , "plan_summary" )
# `.scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
. tutor_note
# Raises Lingara::Apps::ManifestError naming the rule the upload would refuse.
File . write ( "manifest.json" , manifest. to_json ) PHP
use Lingara\Apps\Generated\AppSlotName ;
use Lingara\Apps\Manifest ;
$json = Manifest :: create ()
-> defaultLocale ( 'en' )
-> name ([ 'en' => 'Daily five' , 'zh-Hans' => '每日五词' ])
-> description ( 'Five words to review, picked from your plan.' )
-> renderUrl ( 'https://apps.example.com/lingara/render' )
-> slots ( AppSlotName :: HOME_SIDE , AppSlotName :: PLANS_EMPTY_DETAIL )
-> context ( 'languages' , 'plan_summary' )
// `->scopes(…)` lists the API scopes your client uses, for the learner's consent page. This app uses none.
-> tutorNote ()
-> toJson (); // Throws ManifestException naming the rule an upload would refuse.
file_put_contents ( 'manifest.json' , $json); // Upload it in the console. 코드 예제 언어 TypeScript Rust Go Java Kotlin Ruby PHP TypeScript
const app = lingaraApp ({
// lgr_whsec_…, from the app's settings; pass two during a rotation.
secret: process.env. LINGARA_APP_SECRET ! ,
// Hold per-learner state on `subject`, the same value your events carry.
render : ( request ) => (request.slot === "home.side" ? withNote (request) : todayCard (streaks. get (request.subject) ?? 0 )),
actions: {
// The button's `action`; may arrive twice, so make it safe to repeat.
done : ( request ) => {
streaks. set (request.subject, (streaks. get (request.subject) ?? 0 ) + 1 );
return todayCard (streaks. get (request.subject) ! );
},
},
}); Rust
// The app's signing secret (lgr_whsec_…), from the environment.
let seen = Seen :: default ();
let (render_seen, action_seen) = ( Arc :: clone ( & seen), Arc :: clone ( & seen));
let app = App :: new ([ std :: env :: var ( "LINGARA_APP_SECRET" ) ? ], move | request : AppRenderRequest | {
render ( Arc :: clone ( & render_seen), request)
}) ?
// The button's `action` comes back as `action_id`. An action can arrive
// twice, so keep it safe to repeat.
. action ( "next" , move | request : AppActionRequest | {
let seen = Arc :: clone ( & action_seen);
async move {
* seen . lock () . map_err ( | _ | "poisoned" ) ?. entry (request . subject) . or_default () += 1 ;
today_card ( "Next word" , "zh" )
}
}); Go
// Per-learner state is keyed on Subject, the lgr_sub_… value the app's
// events carry too.
var mu sync . Mutex
streaks := map [ string ] int {}
render := func ( ctx context . Context , req lingaraapps . AppRenderRequest ) ( lingaraapps . Replier , error ) {
mu. Lock ()
days := streaks[req.Subject]
mu. Unlock ()
return lingaraapps. NewCard ().
Heading ( "Daily five" , 1 ).
Progress ( float64 (days % 5 ) / 5 , "This week" ).
Button ( "Done today" , "done" ).
Build ()
}
// An action may arrive twice: make it safe to run twice.
done := func ( ctx context . Context , req lingaraapps . AppActionRequest ) ( lingaraapps . Replier , error ) {
mu. Lock ()
streaks[req.Subject] ++
mu. Unlock ()
return lingaraapps. NewCard (). Heading ( "Nice work" , 1 ). Text ( "See you tomorrow." ). Build ()
}
app := lingaraapps . App {
Render: render,
Actions: map [ string ] lingaraapps . ActionFunc { "done" : done},
} Java
import com.getlingara.apps.Card;
import com.getlingara.apps.LingaraApp;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Per-learner state is keyed on subject, the value the app's events carry too.
Map< String , Integer > seen = new ConcurrentHashMap<>();
LingaraApp app =
LingaraApp. builder ()
// The app's signing secret (lgr_whsec_…); pass two during a rotation.
. secrets (System. getenv ( "LINGARA_APP_SECRET" ))
. render (
request -> {
int count = seen. getOrDefault (request. getSubject (), 0 );
return Card. card ()
. heading ( "Today's five" , 1 )
. text ( "Reviewed " + count + " so far." )
. button ( "Next" , "next" )
. build ();
})
// The button's action comes back as the request's action_id. It may arrive
// twice, so make it safe to repeat.
. action (
"next" ,
request -> {
int count = seen. merge (request. getSubject (), 1 , Integer :: sum);
return Card. card (). progress (Math. min ( 1.0 , count / 5.0 ), count + " of 5" ). build ();
})
. build (); Kotlin
import com.getlingara.apps.kotlin.LingaraApp
import com.getlingara.apps.kotlin.card
import com.getlingara.apps.kotlin.lingaraApp
import java.util.concurrent.ConcurrentHashMap
// Per-learner state is keyed on subject, the value the app's events carry too.
val seen = ConcurrentHashMap < String , Int >()
val app =
lingaraApp {
// The app's signing secret (lgr_whsec_…); pass two during a rotation.
secrets (System. getenv ( "LINGARA_APP_SECRET" ))
render { request ->
val count = seen. getOrDefault (request.subject, 0 )
card {
heading ( "Today's five" , 1 )
text ( "Reviewed $count so far." )
button ( "Next" , "next" )
}
}
// The button's action comes back as the request's action_id. It may arrive twice,
// so make it safe to repeat.
action ( "next" ) { request ->
val count = seen. merge (request.subject, 1 , Int:: plus ) ?: 1
card { progress ( minOf ( 1.0 , count / 5.0 ), " $count of 5" ) }
}
} Ruby
# lgr_whsec_…, from the app's settings; pass an Array of two during a rotation.
app = Lingara :: Apps :: App . new ( secret: ENV . fetch ( "LINGARA_APP_SECRET" ))
# Hold per-learner state on `subject`, the same value your events carry.
app. render do |request|
(request. slot == "home.side" ) ? with_note (request) : today_card ( STREAKS [request. subject ])
end
# The button's `action`; it may arrive twice, so make it safe to repeat.
app. action ( "done" ) do |request|
STREAKS [request. subject ] += 1
today_card ( STREAKS [request. subject ])
end PHP
use Lingara\Apps\App ;
use Lingara\Apps\Generated\AppActionRequest ;
use Lingara\Apps\Generated\AppRenderRequest ;
$app = new App (
// lgr_whsec_…, from the app's settings; pass a list of two during a rotation.
secret : ( string ) getenv ( 'LINGARA_APP_SECRET' ),
// Hold per-learner state on `subject`, the same value your events carry.
render : static fn ( AppRenderRequest $request) => $request -> getSlot () === AppSlotName :: HOME_SIDE
? withNote ($request)
: todayCard ($streaks[$request -> getSubject ()] ?? 0 ),
actions : [
// The button's `action`; may arrive twice, so make it safe to repeat.
'done' => static function ( AppActionRequest $request) use ( & $streaks) : CardModel {
$streaks[$request -> getSubject ()] = ($streaks[$request -> getSubject ()] ?? 0 ) + 1 ;
return todayCard ($streaks[$request -> getSubject ()]);
},
],
); 코드 예제 언어 TypeScript Rust Go Java Kotlin Ruby PHP TypeScript
// A node:http request listener: it reads the raw body, verifies, dispatches and replies.
createServer ( nodeHandler (app)). listen ( 8787 ); Rust
// Nest the adapter at the path your manifest's render_url names.
let routes = axum :: Router :: new () . nest ( "/lingara/render" , lingara_apps :: axum :: router (app));
let listener = tokio :: net :: TcpListener :: bind ( "0.0.0.0:8080" ) .await? ;
axum :: serve (listener, routes) .await? ; Go
// The app's signing secret, lgr_whsec_…, or both during a rotation.
handler, err := lingaraapps. NewHandler (app, os. Getenv ( "LINGARA_APP_SECRET" ))
if err != nil {
return err // a malformed secret fails here, at startup
}
mux := http. NewServeMux ()
mux. Handle ( "/lingara" , handler)
err = http. ListenAndServe ( ":8080" , mux) Java
import com.getlingara.apps.JdkHttpHandler;
import com.getlingara.apps.LingaraApp;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
// The JDK's own server: the handler reads the raw body, verifies it, then dispatches.
HttpServer server = HttpServer. create ( new InetSocketAddress ( 8080 ), 0 );
server. createContext ( "/lingara/render" , JdkHttpHandler. of (app));
server. start (); Kotlin
import com.getlingara.apps.kotlin.LingaraApp
import com.getlingara.apps.kotlin.ktor.lingaraApp
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
// Route.lingaraApp reads the raw body itself, so content negotiation never parses it.
embeddedServer (CIO, port = 8080 ) {
routing {
route ( "/lingara/render" ) { lingaraApp (app) }
}
}. start (wait = true ) Ruby
# config.ru: any Rack server (Puma, Falcon, rackup) runs it. It reads the
# raw body itself, so mount it where nothing has parsed the JSON first.
run Lingara :: Apps :: RackApp . new (app) PHP
use Lingara\Apps\Psr15Handler ;
use Nyholm\Psr7\Factory\Psr17Factory ;
use Nyholm\Psr7Server\ServerRequestCreator ;
// A PSR-15 handler: mount it on your framework's route, or answer from a
// front controller as here. It reads the raw body, verifies, dispatches and replies.
$factory = new Psr17Factory ();
$handler = new Psr15Handler ($app, $factory, $factory);
$response = $handler -> handle (( new ServerRequestCreator ($factory, $factory, $factory, $factory)) -> fromGlobals ());
http_response_code ($response -> getStatusCode ());
foreach ($response -> getHeaders () as $name => $values) {
header ( "{ $name }: " . implode ( ', ' , $values));
}
echo $response -> getBody ();