Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The New Elasticsearch .NET Client: Getting Started and Behind the Scenes

The New Elasticsearch .NET Client: Getting Started and Behind the Scenes

Elasticsearch is a leading search and analytics solution used by thousands of companies worldwide for use cases, including search, analytics, monitoring and security information and event management (SIEM). With an emphasis on speed, scale, and relevance, it's transforming how the world uses data.

In this session, we'll learn about leveraging the power of Elasticsearch within .NET applications, utilising the new Elasticsearch .NET client library. Join Steve to learn about the .NET client and how to install it in .NET applications and use it to begin indexing and searching documents. We'll even take a sneak peek at how we maintain the library using code generation.

This session is aimed at software developers looking to get started by combining the capabilities of Elasticsearch with their .NET applications. You'll leave with the core knowledge required to begin using Elasticsearch and the .NET client library.

Steve Gordon

June 23, 2022
Tweet

More Decks by Steve Gordon

Other Decks in Technology

Transcript

  1. 1
    Steve Gordon (Senior Engineer @ Elastic)
    @stevejgordon | stevejgordon.co.uk
    The New Elasticsearch .NET Client
    Getting Started and Behind the Scenes

    View full-size slide

  2. 2
    Agenda
    • Introduction to Elasticsearch
    • .NET Client for Elasticsearch
    ‒ Problems with the existing client
    ‒ Introducing the new v8 client
    • Demos
    • Behind the scenes
    ‒ Building the Elasticsearch specification
    ‒ Code generation of the new .NET client

    View full-size slide

  3. We build search
    solutions on a
    single stack
    Enterprise Search
    Observability
    Security

    View full-size slide

  4. 4
    Store, Search, &
    Analyze
    Visualize &
    Manage
    Ingest
    Elastic Stack
    SOLUTIONS
    Kibana
    Elasticsearch
    Beats Logstash
    SaaS On-Prem
    Elastic cloud Elastic cloud
    Enterprise
    Standalone
    Elastic cloud
    On Kubernetes
    Elastic Agent

    View full-size slide

  5. 6
    Elasticsearch Terminology

    View full-size slide

  6. 7
    Basic Terminology
    CLUSTER
    A collection of one or more nodes (servers) that together hold your data and provide federated
    indexing and search capabilities across all nodes.

    View full-size slide

  7. 8
    Basic Terminology
    CLUSTER
    A single server that is part of your cluster, stores your data, and participates in the clusters
    indexing and search capabilities.
    NODE 1 NODE 2 NODE 3

    View full-size slide

  8. 9
    Basic Terminology
    CLUSTER
    A collection of documents that have somewhat similar characteristics.
    NODE 1 NODE 2 NODE 3
    INDEX

    View full-size slide

  9. 10
    Basic Terminology
    CLUSTER
    Elasticsearch provides the ability to subdivide your index into multiple pieces called shards.
    NODE 1 NODE 2 NODE 3
    INDEX
    SHARD (PRIMARY)
    P1
    SHARD (PRIMARY)
    P2
    SHARD (PRIMARY)
    P3
    SHARD (REPLICA)
    R3
    SHARD (REPLICA)
    R1
    SHARD (REPLICA)
    R2

    View full-size slide

  10. 11
    Basic Terminology
    CLUSTER
    Elasticsearch provides the ability to subdivide your index into multiple pieces called shards.
    NODE 1 NODE 2 NODE 3
    INDEX
    SHARD (PRIMARY)
    P1
    SHARD (PRIMARY)
    P2
    SHARD (PRIMARY)
    P3
    SHARD (REPLICA)
    R3
    SHARD (REPLICA)
    R1
    SHARD (REPLICA)
    R2

    View full-size slide

  11. 12
    Basic Terminology
    CLUSTER
    The basic unit of information that can be indexed in JSON form.
    NODE 1 NODE 2 NODE 3
    INDEX
    SHARD (PRIMARY)
    P1
    SHARD (PRIMARY)
    P2
    SHARD (PRIMARY)
    P3
    SHARD (REPLICA)
    R3
    SHARD (REPLICA)
    R1
    SHARD (REPLICA)
    R2
    DOC DOC
    DOC DOC
    DOC DOC DOC DOC
    DOC DOC DOC DOC

    View full-size slide

  12. 13
    13
    HTTP Interface

    View full-size slide

  13. 14
    The Elasticsearch API in Numbers
    • > 400 API endpoints
    • > 2000 data structures
    ‒ 50 query types
    ‒ 70 aggregation types
    ‒ 30 field types

    View full-size slide

  14. 15
    Language Clients
    • .NET
    • Java
    • JavaScript
    • Ruby
    • Go
    • PHP
    • Perl
    • Python
    • Rust

    View full-size slide

  15. 16
    Existing Elasticsearch .NET Client (7.x)
    NEST
    High Level Client
    • Methods for every API
    • Strongly-typed
    requests & responses
    • Aggregations
    • Mappings
    • Query DSL
    • Fluent syntax
    • Helpers
    Elasticsearch.Net
    Low Level Client
    • Dependency free,
    unopinionated client.
    • Handles transport
    • Client-side load
    balancing
    • Request parameters
    (query string)
    • Serialisation
    • API URL Resolution

    View full-size slide

  16. 17
    Architecture of Existing .NET Client
    NEST
    Elasticsearch.NET
    HttpWebRequest or
    HttpClient
    Elasticsearch
    Server
    HTTP

    View full-size slide

  17. 18
    Problems with the Existing Client
    • Hand written
    ‒ API is not always consistent
    ‒ A lot of maintenance work (400 endpoints and thousands of
    types!)
    • Legacy internalised JSON serialiser based on
    Utf8Json
    • ~12 years of historical decisions

    View full-size slide

  18. 20
    Introducing Elastic.Clients.Elasticsearch
    • A new generation of the Elasticsearch client
    • Code Generated
    ‒ Based on a formal specification of the Elasticsearch API
    • Uses System.Text.Json serializer
    • Built on a common Elastic.Transport layer
    • Removes some of the legacy of the past to create a
    cleaner API
    The new .NET client for v8.0

    View full-size slide

  19. 21
    Architecture of New .NET Client
    Elastic.Clients.Elasticsearch
    Elastic.Transport
    HttpWebRequest or
    HttpClient
    Elasticsearch
    Server
    HTTP
    Other Clients

    View full-size slide

  20. 23
    Building the Elasticsearch
    API specification

    View full-size slide

  21. 24
    REST API specification → OpenAPI?
    OpenAPI is too limited
    • Elasticsearch API is complex and not “canonical”
    • Would require custom extensions
    • Our problem is mostly about data structures, not so
    much URLs
    OpenAPI is complex
    • “The Schema Object is a superset of the JSON Schema
    Specification Draft 2020-12” 😱
    • 400 endpoint, 2000 structures… in YAML/JSON 😓

    View full-size slide

  22. 25
    JSON API Specification → TypeScript!
    TypeScript’s type system is built to represent
    JSON/JS
    • Static type checking of the API
    • Strong IDE support
    • ts-morph: a library to build TS code processors
    ‒ Setup, navigation, and manipulation of the TypeScript AST can be a challenge. This
    library wraps the TypeScript compiler API so it's simple.

    View full-size slide

  23. 27
    Example: Search Request
    /**
    * @rest_spec_name search
    * @since 0.0.0
    * @stability stable
    */
    export interface Request extends RequestBase {
    path_parts: {
    index?: Indices
    }
    query_parameters: {
    allow_no_indices?: Boolean
    ...
    size?: integer
    from?: integer
    sort?: string | string[]
    }
    body: {
    /** @aliases aggs */ // ES uses "aggregations" in serialization
    aggregations?: Dictionary
    collapse?: FieldCollapse
    /**
    * If true, returns detailed information about score computation as part of a hit.
    * @server_default false
    */
    explain?: boolean * @server_default 0
    */
    from?: integer
    ...
    }
    export type IndexName = string
    export type Indices = IndexName | IndexName[]
    Meta information
    Alias tag
    Documentation
    comment

    View full-size slide

  24. 28
    export class Response {
    body: ResponseBody
    }
    export class ResponseBody {
    took: long
    timed_out: boolean
    _shards: ShardStatistics
    hits: HitsMetadata
    aggregations?: Dictionary
    _clusters?: ClusterStatistics
    fields?: Dictionary
    max_score?: double
    num_reduce_phases?: long
    profile?: Profile
    pit_id?: Id
    _scroll_id?: ScrollId
    suggest?: Dictionary[]>
    terminated_early?: boolean
    }
    Example: Search Response
    User-provided type

    View full-size slide

  25. 29
    export class Response {
    body: ResponseBody
    }
    export class ResponseBody {
    took: long
    timed_out: boolean
    _shards: ShardStatistics
    hits: HitsMetadata
    aggregations?: Dictionary
    _clusters?: ClusterStatistics
    fields?: Dictionary
    max_score?: double
    num_reduce_phases?: long
    profile?: Profile
    pit_id?: Id
    _scroll_id?: ScrollId
    suggest?: Dictionary[]>
    terminated_early?: boolean
    }
    Example: Search Response
    export class HitsMetadata {
    total?: TotalHits | long
    hits: Hit[]
    max_score?: double | null
    }
    export class HitMetadata {
    _id: Id
    _index: IndexName
    _primary_term: long
    _routing: string
    _seq_no: SequenceNumber
    _source: TDocument
    _version: VersionNumber
    }

    View full-size slide

  26. 30
    Example: Query
    /**
    * @variants container
    * @non_exhaustive
    * @doc_id query-dsl
    */
    export class QueryContainer {
    bool?: BoolQuery
    boosting?: BoostingQuery
    /** @deprecated 7.3.0 */
    common?: SingleKeyDictionary
    /** @since 7.13.0 */
    combined_fields?: CombinedFieldsQuery
    constant_score?: ConstantScoreQuery
    dis_max?: DisMaxQuery
    distance_feature?: DistanceFeatureQuery
    exists?: ExistsQuery
    function_score?: FunctionScoreQuery
    fuzzy?: SingleKeyDictionary
    geo_bounding_box?: GeoBoundingBoxQuery
    geo_distance?: GeoDistanceQuery
    geo_polygon?: GeoPolygonQuery
    ...
    Container variant is used
    for types that contain all
    the variants inside the
    definition
    Properties can be tagged
    as deprecation since a
    particular version
    We also track versions
    where new properties
    have been added

    View full-size slide

  27. 31
    Validating the Specification
    • Piggy-back on Elasticsearch
    integration tests

    Capture request and response JSON

    Does it fit in the corresponding TS type?

    > 5400 validation tests!

    View full-size slide

  28. 32
    Validating the Specification

    View full-size slide

  29. 33
    Code Generation

    View full-size slide

  30. 34
    TypeScript to Code
    Generating code from the TypeScript AST
    • Too low level
    • Not constrained enough
    Transform TypeScript to a simpler schema
    • Tailor-made for Elastic’s specific needs
    • Simple unambiguous meta-model

    View full-size slide

  31. 35
    Code Generation Pipeline
    Spec compiler
    schema.json
    TypeScript
    API request & response
    bodies
    specification.ts
    Endpoints,
    Request & response
    bodies +
    Rich annotations
    .NET Code
    Generator
    .NET Client
    More Code
    Generators
    Java, Go, JS,
    Python, Rust,
    Ruby, PHP clients
    OpenAPI OpenAPI API Docs
    Even more
    generators

    View full-size slide

  32. 36
    .NET Code Generator
    Process
    Deserialise JSON
    Build contexts
    Mark and enrich
    contexts
    Build Roslyn AST
    Write .cs files

    View full-size slide

  33. 37
    .NET Code Generator
    • Establish naming and namespaces for generated types
    • Walk type hierarchy
    • Identify relationships
    • Mark request types
    • Mark containers and variants
    • Simplify type aliases to built-in types
    • Determine which types require which descriptors
    • Mark specialised serialisation needs (Bulk etc.)
    Marking and Enrichment

    View full-size slide

  34. 38
    .NET Code Generator
    • Roslyn includes a very rich API
    • Not a great deal of documentation
    • roslynquoter.azurewebsites.net
    Build Roslyn AST
    https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/compiler-api-model

    View full-size slide

  35. 39
    Generator Code

    View full-size slide

  36. 40
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  37. 41
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  38. 42
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  39. 43
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  40. 44
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  41. 45
    // ** REQUEST PARAMETERS
    var requestParametersClass = ClassDeclaration(request.RequestParametersName)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.SealedKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"RequestParameters<{request.RequestParametersName}>")))
    .AddMembers(request.QueryStringParameters.Select(a => a.QueryParameterProperty())
    .Where(p => p is not null).ToArray());
    AddClass(requestParametersClass);
    var (constructors, descriptorConstructors) = CreateConstructors();
    // ** REQUEST CLASS
    var requestClass = ClassDeclaration(request.TypeInfo.Name)
    .AddModifiers(Token(SyntaxKind.PublicKeyword), Token(SyntaxKind.PartialKeyword))
    .AddBaseListTypes(SimpleBaseType(ParseName($"PlainRequestBase<{request.RequestParametersName}>")))
    .AddMembers(constructors.ToArray())
    .AddMembers(request.GetCommonRequestProperties())
    .AddMembers(request.GenericArguments.Where(x => x.Name == "TDocument")
    .Select(p => p.GenericPropertySyntax()).Where(p => p is not null).ToArray())
    .AddMembers(request.QueryStringParameters.Select(p => p.QueryStringProperty())
    .Where(p => p is not null).ToArray())
    .AddMembers(request.BodyProperties.Select(p => p.SerializablePropertySyntax())
    .Where(p => p is not null).ToArray());
    ...

    View full-size slide

  42. 46
    public static PropertyDeclarationSyntax CreateSerializableProperty(PropertyV2 property, bool
    selfDeserialisable = false)
    {
    if (TryResolveMemberToPropertyType(property.Type, property, out var typeSyntax))
    {
    var propertyDeclaration = PropertyDeclaration(typeSyntax.TypeSyntax,
    Identifier(property.CodegenName))
    .AddModifiers(Token(SyntaxKind.PublicKeyword));
    propertyDeclaration = propertyDeclaration.AddAttributeLists(
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("JsonInclude")))),
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("JsonPropertyName"))
    .AddArgumentListArguments(AttributeArgument(
    LiteralExpression(SyntaxKind.StringLiteralExpression,
    Literal(property.JsonName)))))));
    if (property.IsSourceProperty)
    propertyDeclaration = propertyDeclaration.AddAttributeLists(
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("SourceConverter")))));
    ...

    View full-size slide

  43. 47
    public static PropertyDeclarationSyntax CreateSerializableProperty(PropertyV2 property, bool
    selfDeserialisable = false)
    {
    if (TryResolveMemberToPropertyType(property.Type, property, out var typeSyntax))
    {
    var propertyDeclaration = PropertyDeclaration(typeSyntax.TypeSyntax,
    Identifier(property.CodegenName))
    .AddModifiers(Token(SyntaxKind.PublicKeyword));
    propertyDeclaration = propertyDeclaration.AddAttributeLists(
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("JsonInclude")))),
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("JsonPropertyName"))
    .AddArgumentListArguments(AttributeArgument(
    LiteralExpression(SyntaxKind.StringLiteralExpression,
    Literal(property.JsonName)))))));
    if (property.IsSourceProperty)
    propertyDeclaration = propertyDeclaration.AddAttributeLists(
    AttributeList(SingletonSeparatedList(Attribute(IdentifierName("SourceConverter")))));
    ...

    View full-size slide

  44. 48
    ...
    if (property.OwningType.UsedInRequest)
    {
    propertyDeclaration = propertyDeclaration.AddAccessorListAccessors(
    AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
    .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
    AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
    .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)));
    }
    else
    {
    propertyDeclaration = propertyDeclaration.AddAccessorListAccessors(
    AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
    .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
    AccessorDeclaration(SyntaxKind.InitAccessorDeclaration)
    .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)));
    }
    return propertyDeclaration;
    }
    return null;
    }

    View full-size slide

  45. 49
    Resources
    • github.com/elastic/elasticsearch-net
    • elastic.co/guide/en/elasticsearch/client/net-api
    • nuget.org/packages/NEST
    • nuget.org/packages/Elastic.Clients.Elasticsearch
    • github.com/stevejgordon/elasticsearch-examples
    • github.com/elastic/elasticsearch-specification
    • discuss.elastic.co

    View full-size slide

  46. 50
    Thank you!
    @stevejgordon

    View full-size slide